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) => (
+ -
+
+
+ {m.meshName}
+ ·
+
+ #
+ {m.topicName}
+
+ ·
+ {fmtRelative(m.createdAt)}
+
+ open →
+
+
+
+
+ {m.senderName}
+ {" "}
+ {renderSnippet(m.snippet)}
+
+
+
+ ))}
+
+
+ );
+};
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;