From 4e3f672d2a141ff878188a8dc3926b3840871e22 Mon Sep 17 00:00:00 2001 From: Hermes Date: Wed, 28 Oct 2026 09:28:52 +0100 Subject: [PATCH] configs.services.matrix.cinny: fix empty thread, thread list times and thread unread badges --- .../services/matrix/cinny-threads-ui.patch | 145 ++++++++++++++---- 1 file changed, 115 insertions(+), 30 deletions(-) diff --git a/configs/services/matrix/cinny-threads-ui.patch b/configs/services/matrix/cinny-threads-ui.patch index 381ff14f..dfd8cd62 100644 --- a/configs/services/matrix/cinny-threads-ui.patch +++ b/configs/services/matrix/cinny-threads-ui.patch @@ -426,7 +426,7 @@ index 4667542..652e64b 100644 size="300" radii="300" diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx -index c533528..6975088 100644 +index c533528..9170852 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -19,10 +19,12 @@ import { @@ -658,6 +658,15 @@ index c533528..6975088 100644 // if user is at bottom of timeline // keep paginating timeline and conditionally mark as read // otherwise we update timeline without paginating +@@ -616,7 +773,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli + // Check if the document is in focus (user is actively viewing the app), + // and either there are no unread messages or the latest message is from the current user. + // If either condition is met, trigger the markAsRead function to send a read receipt. +- requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!, hideActivity)); ++ requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!, hideActivity, true)); + } + + if (!document.hasFocus() && !unreadInfo) { @@ -644,6 +801,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ) ); @@ -672,6 +681,22 @@ index c533528..6975088 100644 const handleOpenEvent = useCallback( async ( evtId: string, +@@ -709,13 +873,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli + const tryAutoMarkAsRead = useCallback(() => { + const readUptoEventId = readUptoEventIdRef.current; + if (!readUptoEventId) { +- requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity)); ++ requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity, true)); + return; + } + const evtTimeline = getEventTimeline(room, readUptoEventId); + const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward); + if (latestTimeline === room.getLiveTimeline()) { +- requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity)); ++ requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity, true)); + } + }, [mx, room, hideActivity]); + @@ -958,6 +1122,16 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli console.warn('Button should have "data-event-id" attribute!'); return; @@ -1192,10 +1217,10 @@ index 8089b5e..754d44a 100644 diff --git a/src/app/features/room/ThreadBrowser.tsx b/src/app/features/room/ThreadBrowser.tsx new file mode 100644 -index 0000000..8deb54a +index 0000000..7a50495 --- /dev/null +++ b/src/app/features/room/ThreadBrowser.tsx -@@ -0,0 +1,372 @@ +@@ -0,0 +1,396 @@ +import React, { + ChangeEventHandler, + MouseEventHandler, @@ -1217,8 +1242,10 @@ index 0000000..8deb54a + Avatar, + config, + Chip, ++ Badge, +} from 'folds'; +import { MatrixEvent, Room } from 'matrix-js-sdk'; ++import { NotificationCountType, RoomEvent } from 'matrix-js-sdk/lib/models/room'; +import { Thread, ThreadEvent } from 'matrix-js-sdk/lib/models/thread'; +import { HTMLReactParserOptions } from 'html-react-parser'; +import { Opts as LinkifyOpts } from 'linkifyjs'; @@ -1253,6 +1280,11 @@ index 0000000..8deb54a +import { EncryptedContent } from './message'; +import * as css from './ThreadDrawer.css'; + ++// Time of the thread's latest reply (the SDK's replyToEvent: the server's ++// bundled latest_event until the thread timeline is loaded), else the root. ++const getThreadActivityTs = (thread: Thread): number => ++ (thread.replyToEvent ?? thread.rootEvent)?.getTs() ?? 0; ++ +type ThreadPreviewProps = { + room: Room; + thread: Thread; @@ -1313,14 +1345,17 @@ index 0000000..8deb54a + (ev: MatrixEvent) => ev.getId() !== thread.id && !reactionOrEditEvent(ev) + ).length; + -+ const lastReply = thread.events -+ .filter((ev: MatrixEvent) => ev.getId() !== thread.id && !reactionOrEditEvent(ev)) -+ .at(-1); ++ const lastReply = thread.replyToEvent; + const lastSenderId = lastReply?.getSender() ?? ''; + const lastDisplayName = + getMemberDisplayName(room, lastSenderId) ?? getMxIdLocalPart(lastSenderId) ?? lastSenderId; + const lastContent = lastReply?.getContent(); + const lastBody: string = typeof lastContent?.body === 'string' ? lastContent.body : ''; ++ const unreadTotal = room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total); ++ const unreadHighlight = room.getThreadUnreadNotificationCount( ++ thread.id, ++ NotificationCountType.Highlight ++ ); + + return ( + + + -+ ++ ++ {unreadTotal > 0 && ( ++ 0 ? 'Critical' : 'Secondary'} ++ size="400" ++ fill="Solid" ++ radii="Pill" ++ > ++ ++ {unreadTotal} ++ ++ ++ )} + + Jump + @@ -1445,24 +1492,26 @@ index 0000000..8deb54a + const [query, setQuery] = useState(''); + const searchRef = useRef(null); + -+ // Re-render when threads change. ++ // Re-render when threads or their unread counts change. + useEffect(() => { + const onUpdate = () => forceUpdate((n) => n + 1); + room.on(ThreadEvent.New as any, onUpdate); + room.on(ThreadEvent.Update as any, onUpdate); + room.on(ThreadEvent.NewReply as any, onUpdate); ++ room.on(RoomEvent.UnreadNotifications, onUpdate); ++ room.on(RoomEvent.Receipt, onUpdate); + return () => { + room.off(ThreadEvent.New as any, onUpdate); + room.off(ThreadEvent.Update as any, onUpdate); + room.off(ThreadEvent.NewReply as any, onUpdate); ++ room.off(RoomEvent.UnreadNotifications, onUpdate); ++ room.off(RoomEvent.Receipt, onUpdate); + }; + }, [room]); + -+ const allThreads = room.getThreads().sort((a: Thread, b: Thread) => { -+ const aTs = a.events.at(-1)?.getTs() ?? a.rootEvent?.getTs() ?? 0; -+ const bTs = b.events.at(-1)?.getTs() ?? b.rootEvent?.getTs() ?? 0; -+ return bTs - aTs; -+ }); ++ const allThreads = room ++ .getThreads() ++ .sort((a: Thread, b: Thread) => getThreadActivityTs(b) - getThreadActivityTs(a)); + + const lowerQuery = query.trim().toLowerCase(); + const threads = lowerQuery @@ -1643,18 +1692,18 @@ index 0000000..b579e74 +}); diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx new file mode 100644 -index 0000000..107ae5b +index 0000000..7f649da --- /dev/null +++ b/src/app/features/room/ThreadDrawer.tsx -@@ -0,0 +1,788 @@ +@@ -0,0 +1,811 @@ +import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Box, Header, Icon, IconButton, Icons, Scroll, Text, config } from 'folds'; -+import { Direction } from 'matrix-js-sdk/lib/models/event-timeline'; ++import { Direction, EventTimeline } from 'matrix-js-sdk/lib/models/event-timeline'; +import { RelationType } from 'matrix-js-sdk/lib/@types/event'; +import { ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts'; +import { MatrixEvent } from 'matrix-js-sdk/lib/models/event'; +import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk/lib/models/room'; -+import { ThreadEvent } from 'matrix-js-sdk/lib/models/thread'; ++import { Thread, ThreadEvent } from 'matrix-js-sdk/lib/models/thread'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { ReactEditor } from 'slate-react'; +import { HTMLReactParserOptions } from 'html-react-parser'; @@ -1704,6 +1753,20 @@ index 0000000..107ae5b +import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing'; +import * as css from './ThreadDrawer.css'; + ++// The thread's timelines, oldest first. thread.events only reads the live ++// timeline, but a gappy sync resets it (Room.resetLiveTimeline resets every ++// thread) and keeps the known events in the old timeline; back-paginating the ++// new live timeline links it to the old one instead of moving them over. ++const getThreadTimelines = (thread: Thread): EventTimeline[] => { ++ const timelines: EventTimeline[] = []; ++ let timeline: EventTimeline | null = thread.liveTimeline; ++ while (timeline) { ++ timelines.unshift(timeline); ++ timeline = timeline.getNeighbouringTimeline(Direction.Backward); ++ } ++ return timelines; ++}; ++ +type ThreadMessageProps = { + room: Room; + mEvent: MatrixEvent; @@ -2017,6 +2080,8 @@ index 0000000..107ae5b + // until that initial fetch has reset and filled the thread timeline, then + // paginate it backwards until the SDK clears the backwards token (start of + // the thread reached). Re-run when the SDK resets the thread timeline. ++ // Paginate the oldest linked timeline: after a reset, paginating the live ++ // one only links it to the old timeline and keeps its token unchanged. + useEffect(() => { + const thread = + room.getThread(threadRootId) ?? @@ -2027,9 +2092,11 @@ index 0000000..107ae5b + if (loading || !thread.initialEventsFetched) return; + loading = true; + try { -+ while (!disposed && thread.liveTimeline.getPaginationToken(Direction.Backward) !== null) { ++ while (!disposed) { ++ const [oldest] = getThreadTimelines(thread); ++ if (oldest.getPaginationToken(Direction.Backward) === null) break; + // eslint-disable-next-line no-await-in-loop -+ await mx.paginateEventTimeline(thread.liveTimeline, { backwards: true }); ++ await mx.paginateEventTimeline(oldest, { backwards: true }); + } + } catch (err) { + // eslint-disable-next-line no-console @@ -2054,7 +2121,12 @@ index 0000000..107ae5b + // window before the Thread object is registered by the SDK. + const replyEvents: MatrixEvent[] = (() => { + const thread = room.getThread(threadRootId); -+ const fromThread = thread?.events ?? []; ++ const fromThread = thread ++ ? getThreadTimelines(thread).reduce( ++ (events, timeline) => events.concat(timeline.getEvents()), ++ [] ++ ) ++ : []; + if (fromThread.length > 0) { + return fromThread.filter( + (ev: MatrixEvent) => ev.getId() !== threadRootId && !reactionOrEditEvent(ev) @@ -2595,10 +2667,20 @@ index 4c86c4e..56d4851 100644 + ); +}; diff --git a/src/app/utils/notifications.ts b/src/app/utils/notifications.ts -index a23bd1a..4ba2d2b 100644 +index a23bd1a..f826bcc 100644 --- a/src/app/utils/notifications.ts +++ b/src/app/utils/notifications.ts -@@ -5,22 +5,28 @@ export async function markAsRead(mx: MatrixClient, roomId: string, privateReceip +@@ -1,26 +1,40 @@ + import { MatrixClient, ReceiptType } from 'matrix-js-sdk'; + +-export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) { ++export async function markAsRead( ++ mx: MatrixClient, ++ roomId: string, ++ privateReceipt: boolean, ++ mainTimelineOnly = false ++) { + const room = mx.getRoom(roomId); if (!room) return; const timeline = room.getLiveTimeline().getEvents(); @@ -2626,17 +2708,20 @@ index a23bd1a..4ba2d2b 100644 + } + if (!latestEvent) return; -+ // Send an UNTHREADED receipt. A room's unread in cinny is the main-timeline -+ // count plus the sum of every thread's count (Room.getUnreadNotificationCount), -+ // so a plain main/threaded receipt leaves the room marked unread whenever any -+ // thread still has unread notifications - the room stays bold even after -+ // "Mark as Read". An unthreaded receipt clears the main timeline and every -+ // thread's unread bucket in one shot. ++ // An explicit "Mark as Read" sends an UNTHREADED receipt. A room's unread in ++ // cinny is the main-timeline count plus the sum of every thread's count ++ // (Room.getUnreadNotificationCount), so a plain main/threaded receipt leaves ++ // the room marked unread whenever any thread still has unread notifications - ++ // the room stays bold even after "Mark as Read". An unthreaded receipt clears ++ // the main timeline and every thread's unread bucket in one shot. ++ // Automatic marking while viewing the main timeline (mainTimelineOnly) sends ++ // the SDK's default threaded receipt for the main timeline instead, so unread ++ // threads stay unread until they are opened. await mx.sendReadReceipt( latestEvent, - privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read + privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read, -+ true ++ !mainTimelineOnly ); } diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts