feat(api+web): notification feed — recent @-mentions across meshes
Universe dashboard gets a "Recent mentions" section listing every topic_message from the last 7 days that references the viewer via `@<displayName>` (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=<ISO> 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<div className="@container relative h-full p-6 md:p-10">
|
||||
{/* Subtle radial backdrop, matching marketing hero */}
|
||||
@@ -143,6 +212,8 @@ export default async function UniversePage() {
|
||||
appBaseUrl={appConfig.url ?? "https://claudemesh.com"}
|
||||
/>
|
||||
|
||||
<MentionsSection mentions={mentions} />
|
||||
|
||||
<MeshesGrid meshes={meshesWithTopics} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user