From 7defff422d3034e232c57c6f2ea5514a1cea36b9 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Mon, 10 Aug 2026 15:31:19 +0100 Subject: [PATCH] fix: re-derive conversation authorship from history Rooms that Reddit returns without timeline events in /sync fell back to isFromUs=false, so an unanswered opener of ours was projected as inbound and surfaced in the dashboard's "Needs reply" queue. loadDetails already paginates the authoritative history for lastMessageSnippet/Time, so take isFromUs from the same newest message. Co-Authored-By: Claude Opus 5 --- src/services/chat/service.ts | 10 +++- .../chat-service-progressive-backfill.test.ts | 50 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/services/chat/service.ts b/src/services/chat/service.ts index d73b7c0..b37a358 100644 --- a/src/services/chat/service.ts +++ b/src/services/chat/service.ts @@ -1135,8 +1135,14 @@ export class ChatService { return; } - chat.lastMessageSnippet = chat.messages[chat.messages.length - 1].text; - chat.lastMessageTime = chat.messages[chat.messages.length - 1].timestamp; + const newest = chat.messages[chat.messages.length - 1]; + chat.lastMessageSnippet = newest.text; + chat.lastMessageTime = newest.timestamp; + // Re-derive authorship from the authoritative history. Room summaries built + // from /sync default `isFromUs` to false when the room carries no timeline + // events (Reddit omits them for idle rooms), which would otherwise leave an + // unanswered opener of ours sitting in the operator's "Needs reply" queue. + chat.isFromUs = newest.isSelf; const firstSelf = chat.messages.find((m) => m.isSelf); if (firstSelf) chat.originMessage = firstSelf.text; diff --git a/tests/chat-service-progressive-backfill.test.ts b/tests/chat-service-progressive-backfill.test.ts index 2d6e9ae..1e448c5 100644 --- a/tests/chat-service-progressive-backfill.test.ts +++ b/tests/chat-service-progressive-backfill.test.ts @@ -214,6 +214,56 @@ describe('ChatService progressive full backfill', () => { } }); + it('derives isFromUs from history when /sync returns no timeline for the room', async () => { + const logger = createMockLogger(); + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-authorship-backfill-')); + try { + const matrix = new MatrixClient(credentials, logger, { minRequestIntervalMs: 0 }); + // Reddit omits timeline events for idle rooms, so the seeded summary + // carries no authorship signal at all. + matrix.sync = vi.fn(async () => ({ next_batch: 's1', rooms: { join: {} } })) as typeof matrix.sync; + matrix.getJoinedRooms = vi.fn(async () => ['!unanswered:reddit.com', '!answered:reddit.com']); + matrix.getRoomMessages = vi.fn(async (roomId: string) => ({ + start: 'start', + chunk: roomId === '!unanswered:reddit.com' + ? [{ + event_id: '$opener', + type: 'm.room.message', + sender: '@me:reddit.com', + origin_server_ts: 1_000, + content: { body: 'Hello there' }, + }] + : [{ + event_id: '$reply', + type: 'm.room.message', + sender: '@t2_bob:reddit.com', + origin_server_ts: 2_000, + content: { body: 'Hi back' }, + }, { + event_id: '$opener', + type: 'm.room.message', + sender: '@me:reddit.com', + origin_server_ts: 1_000, + content: { body: 'Hello there' }, + }], + })); + + const service = new ChatService( + matrix, logger, undefined, undefined, undefined, undefined, + new ChatCache(logger, path.join(tmpDir, 'chat-cache.json')), + undefined, undefined, authenticatedAccountAuth, + ); + + const chats = await service.fetchAllMessages({ requireComplete: true }); + const unanswered = chats.find((c) => c.roomId === '!unanswered:reddit.com'); + const answered = chats.find((c) => c.roomId === '!answered:reddit.com'); + expect(unanswered?.isFromUs).toBe(true); + expect(answered?.isFromUs).toBe(false); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + it('paginates beyond the former 50-page history ceiling', async () => { const logger = createMockLogger(); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-long-backfill-'));