This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

cinny-threads: send thread read receipt against the thread's true latest reply

markThreadAsRead sent the receipt against thread.events[last]. Two ways
that fails to clear the thread server-side:

- The SDK's threadIdForReceipt() routes a receipt for the thread root,
or a relation to it (e.g. a reaction on the root), to the "main"
timeline, which never touches the thread's unread bucket.
- Receipting an event that is not the thread's true latest reply leaves
a residual server-side count, so the notification reappears on the
next sync/reload even for threads that were opened.

Target thread.lastReply() - the SDK's canonical latest m.thread reply,
taken from the authoritative thread timeline (including the bundled
latest_event from /threads) and the same event the SDK uses for its
read-up-to comparison. Fall back to a newest-first scan of the live
timeline / rendered replies only when the Thread object is not hydrated.

Assisted-by: Claude Code (claude-opus-4-8[1m])

+33 -6
+33 -6
2configs/services/matrix/cinny-threads-ui.patch
··· 1643 1643 +}); 1644 1644 diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx 1645 1645 new file mode 100644 1646 - index 0000000..9de30c5 1646 + index 0000000..d3c98ba 1647 1647 --- /dev/null 1648 1648 +++ b/src/app/features/room/ThreadDrawer.tsx 1649 - @@ -0,0 +1,724 @@ 1649 + @@ -0,0 +1,751 @@ 1650 1650 +import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; 1651 1651 +import { Box, Header, Icon, IconButton, Icons, Scroll, Text, config } from 'folds'; 1652 1652 +import { RelationType } from 'matrix-js-sdk/lib/@types/event'; ··· 2043 2043 + const thread = room.getThread(threadRootId); 2044 2044 + if (!thread) return; 2045 2045 + 2046 - + const events = thread.events || []; 2047 - + if (events.length === 0) return; 2048 - + 2049 - + const lastEvent = events[events.length - 1]; 2046 + + // The receipt must target the latest genuine *in-thread reply*. The SDK's 2047 + + // threadIdForReceipt() routes a receipt for the thread root — or for a 2048 + + // relation to it, e.g. a reaction to the root — to the "main" timeline, 2049 + + // which clears the room's main unread but never the thread's unread 2050 + + // bucket. And it must be the thread's *true* latest reply: receipting an 2051 + + // older event leaves a residual server-side count, so the notification 2052 + + // reappears on the next sync/reload even for threads we opened. 2053 + + // 2054 + + // thread.lastReply() is the SDK's own canonical latest m.thread reply, 2055 + + // taken from the authoritative thread timeline (which includes the 2056 + + // server-bundled latest_event from /threads) and is the same event the 2057 + + // SDK uses for its read-up-to comparison. Prefer it; fall back to a 2058 + + // newest-first scan of the live timeline / rendered replies only when the 2059 + + // Thread object is not yet hydrated. 2060 + + const pickLastReply = (evs: MatrixEvent[]): MatrixEvent | undefined => { 2061 + + for (let i = evs.length - 1; i >= 0; i -= 1) { 2062 + + const ev = evs[i]; 2063 + + if ( 2064 + + ev.getId() !== threadRootId && 2065 + + ev.threadRootId === threadRootId && 2066 + + !ev.isSending() 2067 + + ) { 2068 + + return ev; 2069 + + } 2070 + + } 2071 + + return undefined; 2072 + + }; 2073 + + const lastEvent = 2074 + + thread.lastReply() ?? 2075 + + pickLastReply(thread.events || []) ?? 2076 + + pickLastReply(replyEventsRef.current); 2050 2077 + if (!lastEvent || lastEvent.isSending()) return; 2051 2078 + 2052 2079 + const userId = mx.getUserId();