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;