feat(threads): room-level Threads list panel

Lotus could view one thread at a time but had no overview of a room's
threads. Add a Threads list side panel, opened from a new Threads toggle
in the room header (mirrors the gallery/widgets toggles).

- Lists every thread with a rich row: root sender + snippet, unread dot,
  "N replies - last reply <time>", and a participant avatar pile.
- Segmented filter (All / Unread / Participating) and sort (Recent /
  Oldest by last-reply time), both persisted in localStorage
  (cinny_threads_filter_v1 / cinny_threads_sort_v1) and normalized via
  type guards.
- Clicking a row opens the existing single-thread ThreadPanel by reusing
  setActiveThreadId; reading it clears the row's unread badge live.
- Stays live via ThreadEvent.New/NewReply/Update/Delete +
  RoomEvent.UnreadNotifications, with a signature guard to avoid churn,
  and is virtualized (@tanstack/react-virtual) for busy rooms.

Reuses room.getThreads()/fetchRoomThreads(), thread.hasCurrentUser-
Participated / lastReply() / length, getThreadUnreadNotificationCount
(muted threads zeroed), useMemberAvatar/StackedAvatar/UserAvatar,
scaleSystemEmoji/trimReplyFromBody, UnreadBadge, and the Bookmarks-panel
segmented-control + localStorage-atom patterns. Filter/sort logic is pure
in utils/threadList.ts with 8 unit tests. New panel is wired into
Room.tsx's mutually-exclusive content-panel switching.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 01:08:34 -04:00
co-authored by Claude Opus 4.8
parent 57f21e5cac
commit d6d1f5a233
9 changed files with 693 additions and 2 deletions
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useRef, useState } from 'react';
import { NotificationCountType, Room, RoomEvent, Thread, ThreadEvent } from 'matrix-js-sdk';
// Cheap signature over the fields the Threads list actually depends on (id,
// last-activity ts, unread count, participation). We only push a new snapshot
// when this changes, so noisy repeated ThreadEvent.Update / UnreadNotifications
// re-emits that don't alter the list don't cause a re-render.
const signatureOf = (room: Room, threads: Thread[]): string =>
threads
.map((t) => {
const ts = t.lastReply()?.getTs() ?? t.rootEvent?.getTs() ?? 0;
const unread = room.getThreadUnreadNotificationCount(t.id, NotificationCountType.Total) ?? 0;
return `${t.id}:${ts}:${unread}:${t.hasCurrentUserParticipated ? 1 : 0}`;
})
.sort()
.join('|');
/**
* Live list of a room's threads. Populates the full server list once via
* `fetchRoomThreads`, then keeps a snapshot in sync with the room's thread and
* unread events. Returns the raw `Thread[]`; the caller derives/filters/sorts.
*/
export function useRoomThreads(room: Room): Thread[] {
const [threads, setThreads] = useState<Thread[]>(() => room.getThreads());
const sigRef = useRef<string>('');
useEffect(() => {
let cancelled = false;
const refresh = () => {
if (cancelled) return;
const next = room.getThreads();
const sig = signatureOf(room, next);
if (sig === sigRef.current) return;
sigRef.current = sig;
setThreads(next);
};
// Force the first push, then pull the full server-known thread list.
sigRef.current = '';
refresh();
room.fetchRoomThreads().then(refresh).catch(() => undefined);
room.on(ThreadEvent.New, refresh);
room.on(ThreadEvent.NewReply, refresh);
room.on(ThreadEvent.Update, refresh);
room.on(ThreadEvent.Delete, refresh);
room.on(RoomEvent.UnreadNotifications, refresh);
return () => {
cancelled = true;
room.removeListener(ThreadEvent.New, refresh);
room.removeListener(ThreadEvent.NewReply, refresh);
room.removeListener(ThreadEvent.Update, refresh);
room.removeListener(ThreadEvent.Delete, refresh);
room.removeListener(RoomEvent.UnreadNotifications, refresh);
};
}, [room]);
return threads;
}