feat(workspace): claudemesh me activity + dashboard parity
ships v0.4.0 phase 4. final aggregating verb after this is me search (phase 5). api: GET /v1/me/activity returns topic messages across every mesh the user belongs to in a 24h default window (?since=iso override), excluding messages the caller authored themselves. "what is happening that i missed", capped at 200. cli (1.13.0): claudemesh me activity prints a condensed feed with mesh + topic + sender + relative timestamp + snippet (or [encrypted] for v2 ciphertext). web: /dashboard/activity clusters consecutive messages from the same topic into thread blocks for readability. sidebar gains an activity entry between notifications and invites. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudemesh-cli",
|
"name": "claudemesh-cli",
|
||||||
"version": "1.12.0",
|
"version": "1.13.0",
|
||||||
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude-code",
|
"claude-code",
|
||||||
|
|||||||
@@ -294,6 +294,86 @@ export async function runMeNotifications(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WorkspaceActivity {
|
||||||
|
messageId: string;
|
||||||
|
topicId: string;
|
||||||
|
topicName: string;
|
||||||
|
meshId: string;
|
||||||
|
meshSlug: string;
|
||||||
|
meshName: string;
|
||||||
|
senderName: string;
|
||||||
|
senderMemberId: string;
|
||||||
|
snippet: string | null;
|
||||||
|
ciphertext: string | null;
|
||||||
|
bodyVersion: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceActivityResponse {
|
||||||
|
activity: WorkspaceActivity[];
|
||||||
|
totals: { events: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeActivityFlags extends MeFlags {
|
||||||
|
since?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runMeActivity(flags: MeActivityFlags): Promise<number> {
|
||||||
|
return withRestKey(
|
||||||
|
{
|
||||||
|
meshSlug: flags.mesh ?? null,
|
||||||
|
purpose: "workspace-activity",
|
||||||
|
capabilities: ["read"],
|
||||||
|
},
|
||||||
|
async ({ secret }) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (flags.since) params.set("since", flags.since);
|
||||||
|
const path =
|
||||||
|
"/api/v1/me/activity" +
|
||||||
|
(params.toString() ? `?${params.toString()}` : "");
|
||||||
|
const ws = await request<WorkspaceActivityResponse>({
|
||||||
|
path,
|
||||||
|
token: secret,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (flags.json) {
|
||||||
|
console.log(JSON.stringify(ws, null, 2));
|
||||||
|
return EXIT.SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
render.section(
|
||||||
|
`${clay("activity")} — ${ws.totals.events} ${dim(
|
||||||
|
flags.since ? `since ${flags.since}` : "in the last 24h",
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ws.activity.length === 0) {
|
||||||
|
process.stdout.write(dim(" quiet — no activity in window\n"));
|
||||||
|
return EXIT.SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slugWidth = Math.max(
|
||||||
|
...ws.activity.map((a) => a.meshSlug.length),
|
||||||
|
6,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const a of ws.activity) {
|
||||||
|
const slug = dim(a.meshSlug.padEnd(slugWidth));
|
||||||
|
const topic = cyan(`#${a.topicName}`);
|
||||||
|
const sender = a.senderName ?? "?";
|
||||||
|
const ago = formatRelativeTime(a.createdAt);
|
||||||
|
const snippet =
|
||||||
|
a.snippet ?? (a.ciphertext ? dim("[encrypted]") : dim("[empty]"));
|
||||||
|
process.stdout.write(
|
||||||
|
` ${slug} ${topic} ${dim(sender + " ·")} ${dim(ago)}\n` +
|
||||||
|
` ${snippet.length > 200 ? snippet.slice(0, 200) + "…" : snippet}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return EXIT.SUCCESS;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatRelativeTime(iso: string): string {
|
function formatRelativeTime(iso: string): string {
|
||||||
const then = new Date(iso).getTime();
|
const then = new Date(iso).getTime();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ Topic (conversation scope, v0.2.0)
|
|||||||
claudemesh me cross-mesh workspace overview (v0.4.0)
|
claudemesh me cross-mesh workspace overview (v0.4.0)
|
||||||
claudemesh me topics cross-mesh topic list [--unread]
|
claudemesh me topics cross-mesh topic list [--unread]
|
||||||
claudemesh me notifications cross-mesh @-mentions [--all] [--since=ISO]
|
claudemesh me notifications cross-mesh @-mentions [--all] [--since=ISO]
|
||||||
|
claudemesh me activity cross-mesh recent messages [--since=ISO]
|
||||||
claudemesh member list mesh roster with online state [--online]
|
claudemesh member list mesh roster with online state [--online]
|
||||||
claudemesh notification list recent @-mentions of you [--since <ISO>]
|
claudemesh notification list recent @-mentions of you [--since <ISO>]
|
||||||
|
|
||||||
@@ -697,6 +698,14 @@ async function main(): Promise<void> {
|
|||||||
since: flags.since as string | undefined,
|
since: flags.since as string | undefined,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
} else if (sub === "activity") {
|
||||||
|
const { runMeActivity } = await import("~/commands/me.js");
|
||||||
|
process.exit(
|
||||||
|
await runMeActivity({
|
||||||
|
...f,
|
||||||
|
since: flags.since as string | undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
console.error(
|
||||||
"Usage: claudemesh me (cross-mesh overview)\n" +
|
"Usage: claudemesh me (cross-mesh overview)\n" +
|
||||||
@@ -704,7 +713,9 @@ async function main(): Promise<void> {
|
|||||||
" claudemesh me topics --unread (only unread topics)\n" +
|
" claudemesh me topics --unread (only unread topics)\n" +
|
||||||
" claudemesh me notifications (unread @-mentions, last 7d)\n" +
|
" claudemesh me notifications (unread @-mentions, last 7d)\n" +
|
||||||
" claudemesh me notifications --all (include already-read)\n" +
|
" claudemesh me notifications --all (include already-read)\n" +
|
||||||
" claudemesh me notifications --since=ISO (custom window)",
|
" claudemesh me notifications --since=ISO (custom window)\n" +
|
||||||
|
" claudemesh me activity (recent messages, last 24h)\n" +
|
||||||
|
" claudemesh me activity --since=ISO (custom window)",
|
||||||
);
|
);
|
||||||
process.exit(EXIT.INVALID_ARGS);
|
process.exit(EXIT.INVALID_ARGS);
|
||||||
}
|
}
|
||||||
|
|||||||
203
apps/web/src/app/[locale]/dashboard/(user)/activity/page.tsx
Normal file
203
apps/web/src/app/[locale]/dashboard/(user)/activity/page.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { db } from "@turbostarter/db/server";
|
||||||
|
import {
|
||||||
|
mesh,
|
||||||
|
meshMember,
|
||||||
|
meshTopic,
|
||||||
|
meshTopicMessage,
|
||||||
|
} from "@turbostarter/db/schema/mesh";
|
||||||
|
import { aliasedTable, and, desc, eq, gt, inArray, isNull, notInArray } from "drizzle-orm";
|
||||||
|
|
||||||
|
import { pathsConfig } from "~/config/paths";
|
||||||
|
import { getSession } from "~/lib/auth/server";
|
||||||
|
import { getMetadata } from "~/lib/metadata";
|
||||||
|
import { Reveal } from "~/modules/dashboard/universe/reveal";
|
||||||
|
|
||||||
|
export const generateMetadata = getMetadata({
|
||||||
|
title: "Activity",
|
||||||
|
description: "Recent messages across every mesh, last 24 hours.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatRelative = (iso: string) => {
|
||||||
|
const sec = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000));
|
||||||
|
if (sec < 60) return `${sec}s ago`;
|
||||||
|
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
|
||||||
|
if (sec < 86_400) return `${Math.floor(sec / 3600)}h ago`;
|
||||||
|
if (sec < 86_400 * 30) return `${Math.floor(sec / 86_400)}d ago`;
|
||||||
|
if (sec < 86_400 * 365) return `${Math.floor(sec / (86_400 * 30))}mo ago`;
|
||||||
|
return `${Math.floor(sec / (86_400 * 365))}y ago`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const decode = (b64: string) => {
|
||||||
|
try {
|
||||||
|
return Buffer.from(b64, "base64").toString("utf-8");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function WorkspaceActivityPage() {
|
||||||
|
const { user } = await getSession();
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const memberships = await db
|
||||||
|
.select({ memberId: meshMember.id, meshId: meshMember.meshId })
|
||||||
|
.from(meshMember)
|
||||||
|
.innerJoin(mesh, eq(mesh.id, meshMember.meshId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(meshMember.userId, user.id),
|
||||||
|
isNull(meshMember.revokedAt),
|
||||||
|
isNull(mesh.archivedAt),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const myMemberIds = memberships.map((m) => m.memberId);
|
||||||
|
const meshIds = memberships.map((m) => m.meshId);
|
||||||
|
const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const senderMember = aliasedTable(meshMember, "sender_member");
|
||||||
|
const rows = meshIds.length && myMemberIds.length
|
||||||
|
? await db
|
||||||
|
.select({
|
||||||
|
messageId: meshTopicMessage.id,
|
||||||
|
topicName: meshTopic.name,
|
||||||
|
meshId: meshTopic.meshId,
|
||||||
|
meshSlug: mesh.slug,
|
||||||
|
senderName: senderMember.displayName,
|
||||||
|
ciphertext: meshTopicMessage.ciphertext,
|
||||||
|
bodyVersion: meshTopicMessage.bodyVersion,
|
||||||
|
createdAt: meshTopicMessage.createdAt,
|
||||||
|
})
|
||||||
|
.from(meshTopicMessage)
|
||||||
|
.innerJoin(meshTopic, eq(meshTopic.id, meshTopicMessage.topicId))
|
||||||
|
.innerJoin(mesh, eq(mesh.id, meshTopic.meshId))
|
||||||
|
.leftJoin(
|
||||||
|
senderMember,
|
||||||
|
eq(senderMember.id, meshTopicMessage.senderMemberId),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(meshTopic.meshId, meshIds),
|
||||||
|
isNull(meshTopic.archivedAt),
|
||||||
|
gt(meshTopicMessage.createdAt, since),
|
||||||
|
notInArray(meshTopicMessage.senderMemberId, myMemberIds),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(meshTopicMessage.createdAt))
|
||||||
|
.limit(200)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const items = rows.map((r) => ({
|
||||||
|
messageId: r.messageId,
|
||||||
|
topicName: r.topicName,
|
||||||
|
meshId: r.meshId,
|
||||||
|
meshSlug: r.meshSlug,
|
||||||
|
senderName: r.senderName ?? "?",
|
||||||
|
snippet: r.bodyVersion === 1 ? decode(r.ciphertext).slice(0, 240) : null,
|
||||||
|
encrypted: r.bodyVersion === 2,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Group consecutive entries by mesh+topic so a chatty thread reads
|
||||||
|
// as a cluster rather than 20 identical headers.
|
||||||
|
const clusters: Array<{ meshId: string; meshSlug: string; topicName: string; messages: typeof items }> = [];
|
||||||
|
for (const m of items) {
|
||||||
|
const last = clusters[clusters.length - 1];
|
||||||
|
if (last && last.meshId === m.meshId && last.topicName === m.topicName) {
|
||||||
|
last.messages.push(m);
|
||||||
|
} else {
|
||||||
|
clusters.push({
|
||||||
|
meshId: m.meshId,
|
||||||
|
meshSlug: m.meshSlug,
|
||||||
|
topicName: m.topicName,
|
||||||
|
messages: [m],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="@container relative h-full p-6 md:p-10">
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 z-0"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"radial-gradient(ellipse 70% 50% at 25% -10%, rgba(188,209,202,0.08), transparent 70%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="relative z-10 mx-auto max-w-[900px]">
|
||||||
|
<header className="mb-10 grid gap-6 border-b border-[var(--cm-border-soft,rgba(217,119,87,0.1))] pb-8 md:mb-14 md:grid-cols-[1fr_auto] md:items-end md:pb-10">
|
||||||
|
<Reveal delay={0}>
|
||||||
|
<h1
|
||||||
|
className="text-[clamp(2rem,1.6rem+2.5vw,3.25rem)] leading-[1.05] tracking-tight"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)", fontWeight: 400 }}
|
||||||
|
>
|
||||||
|
<span className="italic text-[var(--cm-fg-tertiary)]">What you</span>{" "}
|
||||||
|
<span className="italic text-[var(--cm-clay)]">missed</span>.
|
||||||
|
</h1>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<Reveal delay={1}>
|
||||||
|
<div className="flex items-baseline gap-6 font-mono text-[12px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]">
|
||||||
|
<span>
|
||||||
|
<span className="mr-2 text-[var(--cm-fg)]">{items.length}</span>
|
||||||
|
events
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="mr-2 text-[var(--cm-fg)]">{clusters.length}</span>
|
||||||
|
threads
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="mr-2 text-[var(--cm-fg)]">24h</span>
|
||||||
|
window
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{clusters.length === 0 ? (
|
||||||
|
<p className="text-[var(--cm-fg-secondary)]">
|
||||||
|
Quiet on every front. Nothing posted in the last 24 hours.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-8">
|
||||||
|
{clusters.map((c, ci) => (
|
||||||
|
<Reveal key={`${c.meshId}-${c.topicName}-${ci}`} delay={Math.min(ci, 8)}>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={pathsConfig.dashboard.user.meshes.topic(c.meshId, c.topicName)}
|
||||||
|
className="mb-3 flex items-baseline gap-3 font-mono text-[11px] uppercase tracking-[0.16em] text-[var(--cm-fg-tertiary)] hover:text-[var(--cm-clay)]"
|
||||||
|
>
|
||||||
|
<span>{c.meshSlug}</span>
|
||||||
|
<span className="text-[var(--cm-clay)]">#{c.topicName}</span>
|
||||||
|
<span className="ml-auto">{c.messages.length} msg{c.messages.length === 1 ? "" : "s"}</span>
|
||||||
|
</Link>
|
||||||
|
<ol className="flex flex-col gap-2 border-l border-[var(--cm-border-soft,rgba(217,119,87,0.1))] pl-4">
|
||||||
|
{c.messages.map((m) => (
|
||||||
|
<li key={m.messageId} className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-baseline gap-2 text-[12px]">
|
||||||
|
<span className="font-medium text-[var(--cm-fg)]">{m.senderName}</span>
|
||||||
|
<span className="text-[var(--cm-fg-tertiary)]">{formatRelative(m.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="text-[14px] leading-[1.55] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
{m.encrypted
|
||||||
|
? <span className="italic text-[var(--cm-fg-tertiary)]">(encrypted)</span>
|
||||||
|
: m.snippet || <span className="italic text-[var(--cm-fg-tertiary)]">(empty)</span>}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</li>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,6 +35,11 @@ const menu = [
|
|||||||
href: pathsConfig.dashboard.user.notifications,
|
href: pathsConfig.dashboard.user.notifications,
|
||||||
icon: Icons.Bell,
|
icon: Icons.Bell,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "activity",
|
||||||
|
href: pathsConfig.dashboard.user.activity,
|
||||||
|
icon: Icons.Activity,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "invites",
|
title: "invites",
|
||||||
href: pathsConfig.dashboard.user.invites,
|
href: pathsConfig.dashboard.user.invites,
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ const pathsConfig = {
|
|||||||
},
|
},
|
||||||
topics: `${DASHBOARD_PREFIX}/topics`,
|
topics: `${DASHBOARD_PREFIX}/topics`,
|
||||||
notifications: `${DASHBOARD_PREFIX}/notifications`,
|
notifications: `${DASHBOARD_PREFIX}/notifications`,
|
||||||
|
activity: `${DASHBOARD_PREFIX}/activity`,
|
||||||
invites: `${DASHBOARD_PREFIX}/invites`,
|
invites: `${DASHBOARD_PREFIX}/invites`,
|
||||||
settings: {
|
settings: {
|
||||||
index: `${DASHBOARD_PREFIX}/settings`,
|
index: `${DASHBOARD_PREFIX}/settings`,
|
||||||
|
|||||||
@@ -264,11 +264,21 @@ level, or wire claudemesh to messaging surfaces beyond Claude Code.
|
|||||||
`/dashboard/notifications` with a "show all" toggle, matching
|
`/dashboard/notifications` with a "show all" toggle, matching
|
||||||
the universe page's mention card aesthetic. *Shipped 2026-05-03
|
the universe page's mention card aesthetic. *Shipped 2026-05-03
|
||||||
in CLI v1.12.0.*
|
in CLI v1.12.0.*
|
||||||
- **v0.4.0 phase 4+ — `me activity`, `me search`** — remaining
|
- **v0.4.0 phase 4 — `claudemesh me activity` + dashboard
|
||||||
aggregating verbs over `/v1/me/*` mirroring the existing
|
parity** — `GET /v1/me/activity` returns recent topic messages
|
||||||
per-mesh reads. Default aggregation rule for existing read verbs
|
across every joined mesh in a 24h default window
|
||||||
(`task list`, `state list`, `memory recall`) when no `--mesh` is
|
(`?since=ISO`), excluding messages the caller authored
|
||||||
passed.
|
themselves ("what's happening that I missed"). CLI verb prints
|
||||||
|
a condensed feed; web `/dashboard/activity` clusters
|
||||||
|
consecutive messages from the same topic into thread blocks
|
||||||
|
with sender + relative timestamp. *Shipped 2026-05-03 in CLI
|
||||||
|
v1.13.0.*
|
||||||
|
- **v0.4.0 phase 5 — `me search`** — final aggregating verb.
|
||||||
|
Cross-mesh full-text search across decrypted (v1) snippets +
|
||||||
|
topic names + sender names + memory entries. Default
|
||||||
|
aggregation rule for existing read verbs (`task list`, `state
|
||||||
|
list`, `memory recall`) when no `--mesh` is passed lands here
|
||||||
|
too.
|
||||||
- **v0.3.2 — multi-session DM routing + broadcast self-loopback** —
|
- **v0.3.2 — multi-session DM routing + broadcast self-loopback** —
|
||||||
fixes two production bugs: (1) replies via `claudemesh send
|
fixes two production bugs: (1) replies via `claudemesh send
|
||||||
<from_id>` rejected with "no connected peer" when the sender's
|
<from_id>` rejected with "no connected peer" when the sender's
|
||||||
|
|||||||
@@ -623,6 +623,114 @@ export const v1Router = new Hono<Env>()
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// GET /v1/me/activity — cross-mesh recent message stream.
|
||||||
|
//
|
||||||
|
// Topic messages from any mesh the user belongs to in a 24-hour
|
||||||
|
// default window (?since=ISO override). Excludes messages the
|
||||||
|
// caller authored themselves — this is "what's happening that I
|
||||||
|
// missed", not a self-audit log. Returns sender + topic + mesh
|
||||||
|
// context plus a snippet (v1) or ciphertext (v2). Sorted desc by
|
||||||
|
// createdAt, capped at 200 rows.
|
||||||
|
.get("/me/activity", async (c) => {
|
||||||
|
const key = c.var.apiKey;
|
||||||
|
requireCapability(key, "read");
|
||||||
|
if (!key.issuedByMemberId) {
|
||||||
|
return c.json({ error: "api_key_has_no_issuer" }, 400);
|
||||||
|
}
|
||||||
|
const [issuer] = await db
|
||||||
|
.select({ userId: meshMember.userId })
|
||||||
|
.from(meshMember)
|
||||||
|
.where(eq(meshMember.id, key.issuedByMemberId));
|
||||||
|
if (!issuer?.userId) {
|
||||||
|
return c.json({ error: "issuer_member_has_no_user" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberships = await db
|
||||||
|
.select({ memberId: meshMember.id, meshId: meshMember.meshId })
|
||||||
|
.from(meshMember)
|
||||||
|
.innerJoin(mesh, eq(mesh.id, meshMember.meshId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(meshMember.userId, issuer.userId),
|
||||||
|
isNull(meshMember.revokedAt),
|
||||||
|
isNull(mesh.archivedAt),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (memberships.length === 0) {
|
||||||
|
return c.json({ activity: [], totals: { events: 0 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const myMemberIds = memberships.map((m) => m.memberId);
|
||||||
|
const meshIds = memberships.map((m) => m.meshId);
|
||||||
|
const sinceParam = c.req.query("since");
|
||||||
|
const sinceDate = sinceParam
|
||||||
|
? new Date(sinceParam)
|
||||||
|
: new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const senderMember = aliasedTable(meshMember, "sender_member");
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
messageId: meshTopicMessage.id,
|
||||||
|
topicId: meshTopicMessage.topicId,
|
||||||
|
topicName: meshTopic.name,
|
||||||
|
meshId: meshTopic.meshId,
|
||||||
|
meshSlug: mesh.slug,
|
||||||
|
meshName: mesh.name,
|
||||||
|
senderName: senderMember.displayName,
|
||||||
|
senderMemberId: meshTopicMessage.senderMemberId,
|
||||||
|
ciphertext: meshTopicMessage.ciphertext,
|
||||||
|
bodyVersion: meshTopicMessage.bodyVersion,
|
||||||
|
createdAt: meshTopicMessage.createdAt,
|
||||||
|
})
|
||||||
|
.from(meshTopicMessage)
|
||||||
|
.innerJoin(meshTopic, eq(meshTopic.id, meshTopicMessage.topicId))
|
||||||
|
.innerJoin(mesh, eq(mesh.id, meshTopic.meshId))
|
||||||
|
.leftJoin(
|
||||||
|
senderMember,
|
||||||
|
eq(senderMember.id, meshTopicMessage.senderMemberId),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(meshTopic.meshId, meshIds),
|
||||||
|
isNull(meshTopic.archivedAt),
|
||||||
|
gt(meshTopicMessage.createdAt, sinceDate),
|
||||||
|
notInArray(meshTopicMessage.senderMemberId, myMemberIds),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(meshTopicMessage.createdAt))
|
||||||
|
.limit(200);
|
||||||
|
|
||||||
|
const decode = (b64: string) => {
|
||||||
|
try {
|
||||||
|
return Buffer.from(b64, "base64").toString("utf-8");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const activity = rows.map((r) => ({
|
||||||
|
messageId: r.messageId,
|
||||||
|
topicId: r.topicId,
|
||||||
|
topicName: r.topicName,
|
||||||
|
meshId: r.meshId,
|
||||||
|
meshSlug: r.meshSlug,
|
||||||
|
meshName: r.meshName,
|
||||||
|
senderName: r.senderName ?? "?",
|
||||||
|
senderMemberId: r.senderMemberId,
|
||||||
|
snippet:
|
||||||
|
r.bodyVersion === 1 ? decode(r.ciphertext).slice(0, 240) : null,
|
||||||
|
ciphertext: r.bodyVersion === 2 ? r.ciphertext : null,
|
||||||
|
bodyVersion: r.bodyVersion,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
activity,
|
||||||
|
totals: { events: activity.length },
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
// GET /v1/me/topics — cross-mesh topic list for the caller's user.
|
// GET /v1/me/topics — cross-mesh topic list for the caller's user.
|
||||||
//
|
//
|
||||||
// For each topic across every mesh the user belongs to, returns
|
// For each topic across every mesh the user belongs to, returns
|
||||||
|
|||||||
Reference in New Issue
Block a user