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
+25
View File
@@ -75,6 +75,7 @@ import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc';
import { mediaGalleryAtom } from '../../state/mediaGallery';
import { widgetsPanelAtom } from '../../state/widgetsPanel';
import { threadsListAtom } from '../../state/threadsList';
import { usePendingKnocks } from '../../hooks/usePendingKnocks';
import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
@@ -491,6 +492,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [galleryOpen, setGalleryOpen] = useAtom(mediaGalleryAtom);
const [widgetsOpen, setWidgetsOpen] = useAtom(widgetsPanelAtom);
const [threadsListOpen, setThreadsListOpen] = useAtom(threadsListAtom);
const pendingKnocks = usePendingKnocks(room);
const handleSearchClick = () => {
@@ -704,6 +706,29 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
(direct ||
(room.getJoinRule() === 'invite' &&
getStateEvents(room, StateEvent.SpaceParent).length === 0)) && <CallButton />}
{screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>{threadsListOpen ? 'Hide Threads' : 'Threads'}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
ref={triggerRef}
onClick={() => setThreadsListOpen(!threadsListOpen)}
aria-label="Toggle threads"
aria-pressed={threadsListOpen}
>
<Icon size="400" src={Icons.Thread} filled={threadsListOpen} />
</IconButton>
)}
</TooltipProvider>
)}
{screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"