From de698a2aa33f132e8dcf6ae60a30f7e903195cf9 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 9 Apr 2026 14:12:09 -0400 Subject: [PATCH] feat(comments): notify all space members on new comments and replies Broadcasts in-app, push, and email notifications to all space members when a comment pin is created or replied to. @mentioned users get their specific mention notification instead (no double-notify). Fixes pre-existing TS error in rtasks local-first-client (missing dueDate default). Co-Authored-By: Claude Opus 4.6 --- lib/folk-comment-pin.ts | 40 +++++++++++++++++++ modules/rtasks/local-first-client.ts | 2 +- server/index.ts | 57 +++++++++++++++++++++++++++- server/notification-service.ts | 2 +- 4 files changed, 98 insertions(+), 3 deletions(-) diff --git a/lib/folk-comment-pin.ts b/lib/folk-comment-pin.ts index 10be6c67..c93e9c6c 100644 --- a/lib/folk-comment-pin.ts +++ b/lib/folk-comment-pin.ts @@ -226,6 +226,11 @@ export class CommentPinManager { this.#notifyMentions(pinId, did, name, mentionedDids); } + // Fire-and-forget notification to all space members + const pin = this.#sync.doc.commentPins?.[pinId]; + const isReply = (pin?.messages?.length ?? 0) > 1; + this.#notifySpaceMembers(pinId, text, isReply, mentionedDids); + // Re-render popover if open if (this.#openPinId === pinId) { this.#openPinPopover(pinId, false); @@ -836,6 +841,41 @@ export class CommentPinManager { } } + async #notifySpaceMembers( + pinId: string, + text: string, + isReply: boolean, + mentionedDids?: string[], + ) { + const did = this.#getLocalDID(); + const name = this.#getLocalUsername(); + const pins = this.#sync.doc.commentPins || {}; + const sortedPins = Object.values(pins).sort((a, b) => a.createdAt - b.createdAt); + const pinIndex = sortedPins.findIndex((p) => p.id === pinId) + 1; + + try { + const sess = JSON.parse(localStorage.getItem("encryptid_session") || "{}"); + await fetch(`${getModuleApiBase("rspace")}/api/comment-pins/notify`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(sess?.accessToken ? { Authorization: `Bearer ${sess.accessToken}` } : {}), + }, + body: JSON.stringify({ + pinId, + authorDid: did, + authorName: name, + text, + pinIndex, + isReply, + mentionedDids: mentionedDids || [], + }), + }); + } catch { + // Silent fail — notification is best-effort + } + } + // ── Reminder ── async #createReminder(pinId: string) { diff --git a/modules/rtasks/local-first-client.ts b/modules/rtasks/local-first-client.ts index b8a7ac62..06c39c17 100644 --- a/modules/rtasks/local-first-client.ts +++ b/modules/rtasks/local-first-client.ts @@ -70,7 +70,7 @@ export class TasksLocalFirstClient { const docId = boardDocId(this.#space, boardId) as DocumentId; this.#sync.change(docId, `Update task ${taskId}`, (d) => { if (!d.tasks[taskId]) { - d.tasks[taskId] = { id: taskId, spaceId: boardId, title: '', description: '', status: 'TODO', priority: null, labels: [], assigneeId: null, createdBy: null, sortOrder: 0, createdAt: Date.now(), updatedAt: Date.now(), ...changes }; + d.tasks[taskId] = { id: taskId, spaceId: boardId, title: '', description: '', status: 'TODO', priority: null, labels: [], assigneeId: null, createdBy: null, sortOrder: 0, dueDate: null, createdAt: Date.now(), updatedAt: Date.now(), ...changes }; } else { Object.assign(d.tasks[taskId], changes); d.tasks[taskId].updatedAt = Date.now(); diff --git a/server/index.ts b/server/index.ts index 20b2aa56..135290c9 100644 --- a/server/index.ts +++ b/server/index.ts @@ -915,7 +915,7 @@ app.get("/api/modules/:moduleId/landing", (c) => { }); // ── Comment Pin API ── -import { listAllUsersWithTrust } from "../src/encryptid/db"; +import { listAllUsersWithTrust, listSpaceMembers } from "../src/encryptid/db"; // Space members for @mention autocomplete app.get("/:space/api/space-members", async (c) => { @@ -934,6 +934,61 @@ app.get("/:space/api/space-members", async (c) => { } }); +// Space-wide comment notification (all members) +app.post("/:space/api/comment-pins/notify", async (c) => { + const space = c.req.param("space"); + try { + const body = await c.req.json(); + const { pinId, authorDid, authorName, text, pinIndex, isReply, mentionedDids } = body; + if (!pinId || !authorDid) { + return c.json({ error: "Missing fields" }, 400); + } + + const members = await listSpaceMembers(space); + const excludeDids = new Set([authorDid, ...(mentionedDids || [])]); + const title = isReply + ? `${authorName || "Someone"} replied to a comment` + : `New comment from ${authorName || "Someone"}`; + const preview = text ? text.slice(0, 120) + (text.length > 120 ? "..." : "") : ""; + + // In-app + push notifications for all members (excluding author and @mentioned) + await Promise.all( + members + .filter((m) => !excludeDids.has(m.userDID)) + .map((m) => + notify({ + userDid: m.userDID, + category: "module", + eventType: "canvas_comment", + title, + body: preview || `Comment pin #${pinIndex || "?"} in ${space}`, + spaceSlug: space, + moduleId: "rspace", + actionUrl: `/rspace#pin-${pinId}`, + actorDid: authorDid, + actorUsername: authorName, + }), + ), + ); + + // Email notification to all space members (fire-and-forget) + import("../modules/rinbox/agent-notify") + .then(({ sendSpaceNotification }) => { + sendSpaceNotification( + space, + title, + `

${preview}

View comment

`, + ); + }) + .catch(() => {}); + + return c.json({ ok: true }); + } catch (err) { + console.error("[comment-pins] notify-all error:", err); + return c.json({ error: "Failed to send notifications" }, 500); + } +}); + // Mention notification app.post("/:space/api/comment-pins/notify-mention", async (c) => { const space = c.req.param("space"); diff --git a/server/notification-service.ts b/server/notification-service.ts index ce2b64ca..ca247120 100644 --- a/server/notification-service.ts +++ b/server/notification-service.ts @@ -76,7 +76,7 @@ export type NotificationEventType = | 'nest_request' | 'nest_created' | 'space_invite' // Module | 'inbox_new_mail' | 'inbox_approval_needed' | 'choices_result' - | 'notes_shared' | 'canvas_mention' + | 'notes_shared' | 'canvas_mention' | 'canvas_comment' // System | 'guardian_invite' | 'guardian_accepted' | 'recovery_initiated' | 'recovery_approved' | 'device_linked' | 'security_alert'