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
+49 -2
View File
@@ -25,7 +25,9 @@ import { CallChatView } from './CallChatView';
import { useCallEmbed } from '../../hooks/useCallEmbed';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
import { threadsListAtom } from '../../state/threadsList';
import { ThreadPanel } from './thread';
import { ThreadsListPanel } from './thread/ThreadsListPanel';
export function Room() {
const { eventId } = useParams();
@@ -43,6 +45,8 @@ export function Room() {
const setGalleryOpen = useSetAtom(mediaGalleryAtom);
const widgetsOpen = useAtomValue(widgetsPanelAtom);
const setWidgetsOpen = useSetAtom(widgetsPanelAtom);
const threadsListOpen = useAtomValue(threadsListAtom);
const setThreadsListOpen = useSetAtom(threadsListAtom);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const screenSize = useScreenSizeContext();
const powerLevels = usePowerLevels(room);
@@ -74,24 +78,43 @@ export function Room() {
const prevThreadRef = useRef(activeThreadId);
const prevGalleryRef = useRef(galleryOpen);
const prevWidgetsRef = useRef(widgetsOpen);
const prevThreadsListRef = useRef(threadsListOpen);
useEffect(() => {
const threadJustOpened = Boolean(activeThreadId) && !prevThreadRef.current;
const galleryJustOpened = galleryOpen && !prevGalleryRef.current;
const widgetsJustOpened = widgetsOpen && !prevWidgetsRef.current;
const threadsListJustOpened = threadsListOpen && !prevThreadsListRef.current;
if (threadJustOpened) {
if (galleryOpen) setGalleryOpen(false);
if (widgetsOpen) setWidgetsOpen(false);
if (threadsListOpen) setThreadsListOpen(false);
} else if (galleryJustOpened) {
if (activeThreadId) setActiveThreadId(null);
if (widgetsOpen) setWidgetsOpen(false);
if (threadsListOpen) setThreadsListOpen(false);
} else if (widgetsJustOpened) {
if (activeThreadId) setActiveThreadId(null);
if (galleryOpen) setGalleryOpen(false);
if (threadsListOpen) setThreadsListOpen(false);
} else if (threadsListJustOpened) {
if (activeThreadId) setActiveThreadId(null);
if (galleryOpen) setGalleryOpen(false);
if (widgetsOpen) setWidgetsOpen(false);
}
prevThreadRef.current = activeThreadId;
prevGalleryRef.current = galleryOpen;
prevWidgetsRef.current = widgetsOpen;
}, [activeThreadId, galleryOpen, widgetsOpen, setGalleryOpen, setActiveThreadId, setWidgetsOpen]);
prevThreadsListRef.current = threadsListOpen;
}, [
activeThreadId,
galleryOpen,
widgetsOpen,
threadsListOpen,
setGalleryOpen,
setActiveThreadId,
setWidgetsOpen,
setThreadsListOpen,
]);
// On non-desktop screens at most one right-side panel may show, priority
// thread > gallery > widgets > members. On desktop thread + members may coexist
@@ -100,8 +123,16 @@ export function Room() {
const showThreadPanel = !callView && Boolean(activeThreadId);
const showGallery = !callView && galleryOpen && (isDesktop || !activeThreadId);
const showWidgets = !callView && widgetsOpen && (isDesktop || (!activeThreadId && !galleryOpen));
// The single-thread panel always replaces the list (they share the content slot).
const showThreadsList =
!callView &&
threadsListOpen &&
!activeThreadId &&
(isDesktop || (!galleryOpen && !widgetsOpen));
const showMembers =
!callView && isDrawer && (isDesktop || (!activeThreadId && !galleryOpen && !widgetsOpen));
!callView &&
isDrawer &&
(isDesktop || (!activeThreadId && !galleryOpen && !widgetsOpen && !threadsListOpen));
return (
<PowerLevelsContextProvider value={powerLevels}>
@@ -151,6 +182,22 @@ export function Room() {
/>
</>
)}
{showThreadsList && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<ThreadsListPanel
key={room.roomId}
room={room}
onClose={() => setThreadsListOpen(false)}
onOpenThread={(threadId) => {
setActiveThreadId(threadId);
setThreadsListOpen(false);
}}
/>
</>
)}
{showThreadPanel && activeThreadId && (
<>
{screenSize === ScreenSize.Desktop && (