Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/db/sqlite/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]> {
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<Map<string, string[]>> {
const out = new Map<string, string[]>();
Expand Down
21 changes: 21 additions & 0 deletions src/socket/handlers/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
listThreadsByConversation,
listThreadsByRootMessageIds,
countThreadParticipants,
listThreadParticipants,
listRecentRepliers,
setThreadStatus,
setThreadTags,
Expand Down Expand Up @@ -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." });
Expand All @@ -521,6 +523,7 @@ export function registerChatHandlers(ctx: HandlerContext): EventHandlerMap {
return;
}
threadId = thread.thread_id;
threadRoot = thread.root_message_id;
}

// Identity verification
Expand Down Expand Up @@ -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(
Expand Down
38 changes: 38 additions & 0 deletions src/socket/handlers/pushAudience.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,3 +516,41 @@ describe("push:register", () => {
assert.deepEqual(reply, { ok: false, error: "invalid_payload" });
});
});

describe("thread replies (GRYT-2008)", () => {
async function thread(): Promise<string> {
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<void> {
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(), []);
});
});
Loading