diff --git a/src/db/sqlite/threads.ts b/src/db/sqlite/threads.ts index 7200f2d..504cc73 100644 --- a/src/db/sqlite/threads.ts +++ b/src/db/sqlite/threads.ts @@ -117,6 +117,15 @@ export async function countThreadParticipants(threadId: string, rootMessageId: s return Number(row?.c ?? 0); } +/** Everyone who wrote in a thread, the root's author included. */ +export async function listThreadParticipants(threadId: string, rootMessageId: string): Promise { + const db = getSqliteDb(); + const rows = db + .prepare(`SELECT DISTINCT sender_server_id AS id FROM messages WHERE thread_id = ? OR message_id = ?`) + .all(threadId, rootMessageId) as { id: string }[]; + return rows.map((r) => r.id); +} + /** Up to three people who replied most recently in each thread, newest first. The root author isn't counted. */ export async function listRecentRepliers(threadIds: string[]): Promise> { const out = new Map(); diff --git a/src/socket/handlers/chat.ts b/src/socket/handlers/chat.ts index fea6bc4..5e1cdd5 100644 --- a/src/socket/handlers/chat.ts +++ b/src/socket/handlers/chat.ts @@ -50,6 +50,7 @@ import { listThreadsByConversation, listThreadsByRootMessageIds, countThreadParticipants, + listThreadParticipants, listRecentRepliers, setThreadStatus, setThreadTags, @@ -506,6 +507,7 @@ export function registerChatHandlers(ctx: HandlerContext): EventHandlerMap { // Same channel and permissions as any other message, plus a thread_id // so the client can place it. Threads live in channels, not DMs. let threadId: string | null = null; + let threadRoot: string | null = null; if (typeof payload.threadId === "string" && payload.threadId) { if (access.kind === "dm") { refuse({ error: "threads_not_allowed", message: "Threads are not available in direct messages." }); @@ -521,6 +523,7 @@ export function registerChatHandlers(ctx: HandlerContext): EventHandlerMap { return; } threadId = thread.thread_id; + threadRoot = thread.root_message_id; } // Identity verification @@ -952,6 +955,24 @@ export function registerChatHandlers(ctx: HandlerContext): EventHandlerMap { } } + /* Everyone who wrote in the thread hears about a reply, and nobody else (GRYT-2008). + After the mention push, so a mention in a thread is still one notification. */ + if (threadId && threadRoot && access.kind !== "dm") { + try { + const sender = auth.tokenPayload.serverUserId; + const blockers = await blockersOfSender(sender); + const told: string[] = []; + for (const id of await listThreadParticipants(threadId, threadRoot)) { + if (id === sender || blockers.has(id) || id === "system") continue; + if (!(await mayViewChannel(created.conversation_id, id))) continue; + told.push(id); + } + pushNotify(clientsInfo, told, "message", created.conversation_id, { preview: pushPreview }); + } catch (err) { + consola.warn("thread push failed", created.message_id, err); + } + } + // After the message is out, so a promotion can neither slow one down // nor stop one. One roles read on a server using none of this. const promoted = await applyAutoRoles( diff --git a/src/socket/handlers/pushAudience.test.ts b/src/socket/handlers/pushAudience.test.ts index f469873..a5078b6 100644 --- a/src/socket/handlers/pushAudience.test.ts +++ b/src/socket/handlers/pushAudience.test.ts @@ -516,3 +516,41 @@ describe("push:register", () => { assert.deepEqual(reply, { ok: false, error: "invalid_payload" }); }); }); + +describe("thread replies (GRYT-2008)", () => { + async function thread(): Promise { + const root = line("voice cuts out for me"); + await send(alice, OPEN, root); + const posted = alice.emitted.find((e) => e.event === "chat:new" && (e.payload as { text?: string }).text === root) + ?.payload as { message_id: string }; + await alice.handlers["thread:create"]({ accessToken: alice.accessToken, conversationId: OPEN, rootMessageId: posted.message_id }); + const created = alice.emitted.find((e) => e.event === "thread:created")?.payload as { thread_id: string }; + assert.ok(created?.thread_id, "no thread was created"); + return created.thread_id; + } + + async function reply(from: Member, threadId: string, text: string): Promise { + await from.handlers["chat:send"]({ accessToken: from.accessToken, conversationId: OPEN, text, threadId }); + const refused = from.emitted.find((e) => e.event === "chat:error" || e.event === "thread:error"); + assert.equal(refused, undefined, `${from.name}'s reply was refused: ${JSON.stringify(refused?.payload)}`); + } + + it("wake whoever started the thread or replied in it, and nobody else", async () => { + const id = await thread(); + await reply(carol, id, line("same here")); + hits.length = 0; + resetPushState(); + for (const m of [alice, carol, dave, erin]) away(m); + await reply(erin, id, line("try another headset")); + assert.deepEqual(await woken(), ["Alice:message", "Carol:message"]); + }); + + it("don't wake the person who replied", async () => { + const id = await thread(); + hits.length = 0; + resetPushState(); + away(alice); + await reply(alice, id, line("never mind, fixed it")); + assert.deepEqual(await woken(), []); + }); +});