feat(api+broker+web): write-time mention fan-out via notification table
Phase 1 of v0.3.0 — replaces the regex-on-decoded-ciphertext scan in /v1/notifications and the dashboard MentionsSection with reads from a new mesh.notification table populated at write time. Schema 0025: mesh.notification (id, mesh_id, topic_id, message_id, recipient_member_id, sender_member_id, kind, created_at, read_at) with a unique (message_id, recipient) so a re-fanned message yields one row per recipient. Backfills existing v0.2.0 messages by regex-matching the (still-base64-plaintext) bodies — guarded with a base64 + length check so binary ciphertext doesn't crash the migration. Writers (POST /v1/messages + broker appendTopicMessage) now extract @-mentions from either an explicit `mentions: string[]` on the request OR a regex over the base64 plaintext (transitional fallback). Targets are intersected with the mesh roster + capped at 32 per message. Web chat panel sends the explicit array now so it keeps working after phase 2 lands. Readers switch to JOIN-on-notification: /v1/notifications — table-backed, supports ?unread=1 POST /v1/notifications/read — new, mark by ids or all-up-to MentionsSection (RSC) — same JOIN, returns readAt for each row GET /v1/notifications also gains a read_at field per row so a future bell UI can show unread vs read. Once per-topic encryption (phase 2) lands, the regex fallback becomes a no-op for v2 messages — clients MUST send `mentions`, which they already do. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -414,6 +414,21 @@ export function TopicChatPanel({
|
||||
[draft, mentionState],
|
||||
);
|
||||
|
||||
// Extract @-mention tokens from the draft body so the server can
|
||||
// populate mesh.notification rows without having to read the
|
||||
// ciphertext (forward-compat with v0.3.0 per-topic encryption).
|
||||
// Capped at 16 to bound notification fan-out.
|
||||
const extractMentions = (text: string): string[] => {
|
||||
const found = new Set<string>();
|
||||
const re = /(^|[^A-Za-z0-9_-])@([A-Za-z0-9_-]{1,64})(?=$|[^A-Za-z0-9_-])/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
found.add(m[2]!.toLowerCase());
|
||||
if (found.size >= 16) break;
|
||||
}
|
||||
return [...found];
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
const text = draft.trim();
|
||||
if (!text) return;
|
||||
@@ -421,10 +436,16 @@ export function TopicChatPanel({
|
||||
setError(null);
|
||||
try {
|
||||
const { ciphertext, nonce } = encodeOutgoing(text);
|
||||
const mentions = extractMentions(text);
|
||||
const res = await fetch("/api/v1/messages", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ topic: topicName, ciphertext, nonce }),
|
||||
body: JSON.stringify({
|
||||
topic: topicName,
|
||||
ciphertext,
|
||||
nonce,
|
||||
...(mentions.length > 0 ? { mentions } : {}),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
|
||||
Reference in New Issue
Block a user