fix(unread): stop stuck/resurrecting read indicators
handleReceipt recomputed unread from getUnreadNotificationCount, which is
server-computed and stale on the synchronous synthetic receipt echo (the SDK
only zeroes it immediately when the last event is our own message). Reading
someone else's message therefore PUT the stale non-zero count back -> dot stuck
or resurrected on the ack-sync ordering race. Restore upstream cinny's
optimistic DELETE on our own receipt; the UnreadNotifications listener re-asserts
the accurate badge on the server ack.
Also collapse a {total:0,highlight:0} PUT to a DELETE in the reducer (a present
map entry lights the dot via hasUnread=!!unread, so phantom {0,0} PUTs from the
UnreadNotifications listener left stuck dots).
Mark-as-Unread (MSC2867): clear the flag directly in markAsRead (opening an
already-read room sends no receipt, so the receipt-driven auto-clear never
fired), and gate the receipt auto-clear to main/unthreaded receipts so reading
one thread no longer wipes the whole-room flag.
Tests: 700/700 pass; typecheck + prod build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -20,16 +20,22 @@ type RoomOpts = {
|
||||
readUpTo?: string | null;
|
||||
threads?: any[];
|
||||
threadUnread?: Record<string, number>;
|
||||
markedUnread?: boolean;
|
||||
};
|
||||
|
||||
const setup = (opts: RoomOpts) => {
|
||||
const calls: ReceiptCall[] = [];
|
||||
const accountDataWrites: Array<{ type: string; content: any }> = [];
|
||||
const room = {
|
||||
getLiveTimeline: () => ({ getEvents: () => opts.timeline ?? [] }),
|
||||
getEventReadUpTo: () => opts.readUpTo ?? null,
|
||||
getThreads: () => opts.threads ?? [],
|
||||
getThreadUnreadNotificationCount: (threadId: string, _type: NotificationCountType) =>
|
||||
opts.threadUnread?.[threadId] ?? 0,
|
||||
getAccountData: (type: string) =>
|
||||
opts.markedUnread && type === 'm.marked_unread'
|
||||
? { getContent: () => ({ unread: true }) }
|
||||
: undefined,
|
||||
};
|
||||
const mx = {
|
||||
getRoom: () => room,
|
||||
@@ -38,8 +44,12 @@ const setup = (opts: RoomOpts) => {
|
||||
calls.push({ eventId: event.getId(), receiptType, unthreaded });
|
||||
return {};
|
||||
},
|
||||
setRoomAccountData: async (_roomId: string, type: string, content: any) => {
|
||||
accountDataWrites.push({ type, content });
|
||||
return {};
|
||||
},
|
||||
} as any;
|
||||
return { mx, calls };
|
||||
return { mx, calls, accountDataWrites };
|
||||
};
|
||||
|
||||
test('main timeline: unthreaded receipt at the latest event', async () => {
|
||||
@@ -107,6 +117,27 @@ test('everything read: no receipts sent', async () => {
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('marked-unread + already fully read: clears the flag even though no receipt is sent', async () => {
|
||||
const { mx, calls, accountDataWrites } = setup({
|
||||
timeline: [evt('a'), evt('b')],
|
||||
readUpTo: 'b', // nothing newer → no receipt
|
||||
markedUnread: true,
|
||||
});
|
||||
await markAsRead(mx, '!r:server', false);
|
||||
assert.equal(calls.length, 0); // no receipt (the stuck-dot case)
|
||||
// ...but the marked-unread flag is cleared directly (both keys, unread:false)
|
||||
assert.ok(accountDataWrites.some((w) => w.type === 'm.marked_unread' && w.content.unread === false));
|
||||
});
|
||||
|
||||
test('not marked-unread: markAsRead does not touch account data', async () => {
|
||||
const { mx, accountDataWrites } = setup({
|
||||
timeline: [evt('a'), evt('b')],
|
||||
readUpTo: 'a',
|
||||
});
|
||||
await markAsRead(mx, '!r:server', false);
|
||||
assert.equal(accountDataWrites.length, 0);
|
||||
});
|
||||
|
||||
test('sending thread reply is skipped', async () => {
|
||||
const t = thread('$root', evt('$reply', true)); // isSending → skip
|
||||
const { mx, calls } = setup({
|
||||
|
||||
Reference in New Issue
Block a user