From a9160a09657d3fdb6cc13148ac36cd953cf201dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 2 May 2026 19:26:02 +0100 Subject: [PATCH] =?UTF-8?q?feat(api+web):=20notification=20feed=20?= =?UTF-8?q?=E2=80=94=20recent=20@-mentions=20across=20meshes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Universe dashboard gets a "Recent mentions" section listing every topic_message from the last 7 days that references the viewer via `@` (per-mesh — a user can carry different display names in different meshes). One union'd OR query, capped at 20. Each mention card links straight into the topic chat at the right mesh. Snippet is the first 240 chars of the decoded ciphertext with @-tokens highlighted in clay, matching the in-chat renderer. GET /v1/notifications mirrors the same scan for api-key-authed clients (CLI, bots) — accepts ?since= for incremental polling. Both paths use Postgres regex on the decoded base64 plaintext; when per-topic encryption lands in v0.3.0 they'll move to a notification table populated at write time. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/[locale]/dashboard/(user)/page.tsx | 75 +++++++++++- .../modules/dashboard/universe/mentions.tsx | 113 ++++++++++++++++++ packages/api/src/modules/mesh/v1-router.ts | 78 ++++++++++++ 3 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/modules/dashboard/universe/mentions.tsx diff --git a/apps/web/src/app/[locale]/dashboard/(user)/page.tsx b/apps/web/src/app/[locale]/dashboard/(user)/page.tsx index 6b13a78..bc5755d 100644 --- a/apps/web/src/app/[locale]/dashboard/(user)/page.tsx +++ b/apps/web/src/app/[locale]/dashboard/(user)/page.tsx @@ -7,12 +7,13 @@ import { import { handle } from "@turbostarter/api/utils"; import { db } from "@turbostarter/db/server"; import { + mesh, meshMember, meshTopic, meshTopicMember, meshTopicMessage, } from "@turbostarter/db/schema/mesh"; -import { and, count, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { and, count, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm"; import { appConfig } from "~/config/app"; import { pathsConfig } from "~/config/paths"; @@ -20,6 +21,7 @@ import { api } from "~/lib/api/server"; import { getSession } from "~/lib/auth/server"; import { getMetadata } from "~/lib/metadata"; import { InvitationsSection } from "~/modules/dashboard/universe/invitations"; +import { MentionsSection } from "~/modules/dashboard/universe/mentions"; import { MeshesGrid } from "~/modules/dashboard/universe/meshes-grid"; import { UniverseWelcome } from "~/modules/dashboard/universe/welcome"; @@ -72,7 +74,11 @@ export default async function UniversePage() { // never opened this topic" — every message in such a topic is unread. const myMembers = user && meshIds.length ? await db - .select({ id: meshMember.id }) + .select({ + id: meshMember.id, + meshId: meshMember.meshId, + displayName: meshMember.displayName, + }) .from(meshMember) .where( and( @@ -120,6 +126,69 @@ export default async function UniversePage() { unreadCount: unreadMap.get(m.id) ?? 0, })); + // Recent @-mentions of the viewer across every mesh they belong to. + // Build a (memberId, regex) pair per mesh and OR them together so we + // catch users with different display names in different meshes. The + // ciphertext is base64 plaintext in v0.2.0; per-topic encryption in + // v0.3.0 will move this scan to a notification table populated at + // write time. 7-day window keeps the query bounded. + const mentionWindow = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + const mentionConditions = myMembers.map((m) => { + const escaped = m.displayName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = `(^|\\s|[^A-Za-z0-9_-])@${escaped}($|[^A-Za-z0-9_-])`; + return and( + eq(meshTopic.meshId, m.meshId), + sql`${meshTopicMessage.senderMemberId} <> ${m.id}`, + sql`convert_from(decode(${meshTopicMessage.ciphertext}, 'base64'), 'UTF8') ~* ${pattern}`, + ); + }); + const mentionRows = mentionConditions.length + ? await db + .select({ + id: meshTopicMessage.id, + topicId: meshTopicMessage.topicId, + topicName: meshTopic.name, + meshId: meshTopic.meshId, + meshName: mesh.name, + senderName: meshMember.displayName, + ciphertext: meshTopicMessage.ciphertext, + createdAt: meshTopicMessage.createdAt, + }) + .from(meshTopicMessage) + .innerJoin(meshTopic, eq(meshTopic.id, meshTopicMessage.topicId)) + .innerJoin(mesh, eq(mesh.id, meshTopic.meshId)) + .innerJoin( + meshMember, + eq(meshMember.id, meshTopicMessage.senderMemberId), + ) + .where( + and( + isNull(meshTopic.archivedAt), + gt(meshTopicMessage.createdAt, mentionWindow), + or(...mentionConditions), + ), + ) + .orderBy(desc(meshTopicMessage.createdAt)) + .limit(20) + : []; + + const decode = (b64: string) => { + try { + return Buffer.from(b64, "base64").toString("utf-8"); + } catch { + return "[decode failed]"; + } + }; + const mentions = mentionRows.map((r) => ({ + id: r.id, + meshId: r.meshId, + meshName: r.meshName, + topicName: r.topicName, + senderName: r.senderName, + snippet: decode(r.ciphertext).slice(0, 240), + createdAt: r.createdAt.toISOString(), + })); + return (
{/* Subtle radial backdrop, matching marketing hero */} @@ -143,6 +212,8 @@ export default async function UniversePage() { appBaseUrl={appConfig.url ?? "https://claudemesh.com"} /> + +
diff --git a/apps/web/src/modules/dashboard/universe/mentions.tsx b/apps/web/src/modules/dashboard/universe/mentions.tsx new file mode 100644 index 0000000..d467f86 --- /dev/null +++ b/apps/web/src/modules/dashboard/universe/mentions.tsx @@ -0,0 +1,113 @@ +import Link from "next/link"; + +import { pathsConfig } from "~/config/paths"; + +interface Mention { + id: string; + meshId: string; + meshName: string; + topicName: string; + senderName: string; + snippet: string; + createdAt: string; +} + +const monoStyle = { fontFamily: "var(--cm-font-mono)" } as const; +const serifStyle = { fontFamily: "var(--cm-font-serif)", fontWeight: 400 } as const; + +function fmtRelative(iso: string): string { + const ms = Date.now() - new Date(iso).getTime(); + if (ms < 60_000) return "now"; + if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`; + if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h`; + return `${Math.floor(ms / 86_400_000)}d`; +} + +/** + * Highlight @mentions in clay so the reader's eye lands on the call-out. + * Matches the in-chat renderer; kept inline here to avoid pulling the + * client component into the server-rendered universe page. + */ +function renderSnippet(text: string): React.ReactNode[] { + const parts: React.ReactNode[] = []; + const re = /(^|\s)(@[A-Za-z0-9_-]+)/g; + let lastIndex = 0; + let m: RegExpExecArray | null; + let key = 0; + while ((m = re.exec(text)) !== null) { + const start = m.index + (m[1]?.length ?? 0); + if (start > lastIndex) { + parts.push({text.slice(lastIndex, start)}); + } + parts.push( + + {m[2]} + , + ); + lastIndex = start + (m[2]?.length ?? 0); + } + if (lastIndex < text.length) { + parts.push({text.slice(lastIndex)}); + } + return parts; +} + +export const MentionsSection = ({ mentions }: { mentions: Mention[] }) => { + if (mentions.length === 0) return null; + + return ( +
+
+

+ Recent mentions +

+ + {mentions.length} · last 7 days + +
+ +
    + {mentions.map((m) => ( +
  1. + +
    + {m.meshName} + · + + # + {m.topicName} + + · + {fmtRelative(m.createdAt)} + + open → + +
    +

    + + {m.senderName} + {" "} + {renderSnippet(m.snippet)} +

    + +
  2. + ))} +
+
+ ); +}; diff --git a/packages/api/src/modules/mesh/v1-router.ts b/packages/api/src/modules/mesh/v1-router.ts index d9c2bc0..284cb22 100644 --- a/packages/api/src/modules/mesh/v1-router.ts +++ b/packages/api/src/modules/mesh/v1-router.ts @@ -507,6 +507,84 @@ export const v1Router = new Hono() }); }) + // GET /v1/notifications — recent @-mentions of the viewer across all + // topics in the key's mesh. v0.2.0 plaintext-base64 ciphertext lets + // us regex match server-side; in v0.3.0 (per-topic encryption) this + // moves to a notification table populated at write time. + // + // Query: ?since= to incrementally fetch only newer mentions + // (e.g. for a polling notification bell). Default: last 24h. + .get("/notifications", async (c) => { + const key = c.var.apiKey; + requireCapability(key, "read"); + if (!key.issuedByMemberId) { + return c.json({ notifications: [] }); + } + + const [me] = await db + .select({ displayName: meshMember.displayName }) + .from(meshMember) + .where(eq(meshMember.id, key.issuedByMemberId)); + if (!me) return c.json({ notifications: [] }); + + const sinceParam = c.req.query("since"); + const since = sinceParam + ? new Date(sinceParam) + : new Date(Date.now() - 24 * 60 * 60 * 1000); + if (Number.isNaN(since.getTime())) { + return c.json({ error: "invalid_since" }, 400); + } + + // Postgres regex with case-insensitive match + word boundary on + // both sides. Decode the base64 ciphertext (plaintext envelope in + // v0.2.0) so we're matching readable text, not the base64 alphabet. + const escaped = me.displayName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = `(^|\\s|[^A-Za-z0-9_-])@${escaped}($|[^A-Za-z0-9_-])`; + + const rows = await db + .select({ + id: meshTopicMessage.id, + topicId: meshTopicMessage.topicId, + topicName: meshTopic.name, + senderMemberId: meshTopicMessage.senderMemberId, + senderName: meshMember.displayName, + senderPubkey: meshMember.peerPubkey, + ciphertext: meshTopicMessage.ciphertext, + createdAt: meshTopicMessage.createdAt, + }) + .from(meshTopicMessage) + .innerJoin(meshTopic, eq(meshTopic.id, meshTopicMessage.topicId)) + .innerJoin( + meshMember, + eq(meshMember.id, meshTopicMessage.senderMemberId), + ) + .where( + and( + eq(meshTopic.meshId, key.meshId), + isNull(meshTopic.archivedAt), + gt(meshTopicMessage.createdAt, since), + sql`${meshTopicMessage.senderMemberId} <> ${key.issuedByMemberId}`, + sql`convert_from(decode(${meshTopicMessage.ciphertext}, 'base64'), 'UTF8') ~* ${pattern}`, + ), + ) + .orderBy(desc(meshTopicMessage.createdAt)) + .limit(50); + + return c.json({ + notifications: rows.map((r) => ({ + id: r.id, + topicId: r.topicId, + topicName: r.topicName, + senderName: r.senderName, + senderPubkey: r.senderPubkey, + ciphertext: r.ciphertext, + createdAt: r.createdAt.toISOString(), + })), + since: since.toISOString(), + mentionedAs: me.displayName, + }); + }) + // GET /v1/peers — connected peers in the key's mesh // Dedupe by memberId — a member can have multiple active presence // rows (one per session). Status reflects the most recent presence;