diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index d5dbf62d7..0db25048d 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -865,6 +865,12 @@ Full threaded-conversation support (`m.thread`, matrix-js-sdk `threadSupport`), A right-side drawer (mirrors the members drawer; fullscreen on mobile) with the thread's root message emphasized at top, an "N replies" divider, the full reply timeline (virtualized, back-paginates via `/relations`, decrypts E2EE threads), reactions/edits/redactions, and its own composer. Open it from **Reply in Thread** in the message menu, a reply's thread indicator, or a summary chip; close with **×** or Escape. Reading the panel sends threaded read receipts so per-thread unread counts clear. +### Threads List Panel + +A room-level overview of **all** threads, opened from a **Threads** button (🧵) in the room header (mirrors the gallery/widgets toggles). Each row shows the root sender + message snippet, an unread dot, a meta line ("N replies · last reply 5m ago") and a **participant avatar pile**. A segmented **filter** (All / Unread / Participating — the latter via `thread.hasCurrentUserParticipated`) and **sort** (Recent / Oldest, by last-reply time) sit in the toolbar; both persist in localStorage (`cinny_threads_filter_v1` / `cinny_threads_sort_v1`). Clicking a row opens the existing single-thread `ThreadPanel` (reuses `setActiveThreadId`), and reading it clears the row's unread badge live. The list stays live via `ThreadEvent.New/NewReply/Update/Delete` + `RoomEvent.UnreadNotifications` and is virtualized for busy rooms. + +- Files: `features/room/thread/ThreadsListPanel.tsx`, `hooks/useRoomThreads.ts` (populates via `room.fetchRoomThreads()` + `room.getThreads()`), `state/threadsList.ts`, pure filter/sort in `utils/threadList.ts` (`filterThreads`/`sortThreads`, unit-tested). Reuses `useThreadSummary` data, `UnreadBadge`, `StackedAvatar`/`useMemberAvatar`, and the Bookmarks-panel segmented-control pattern. + ### Summary Chips Root messages in the main timeline show a **"N replies · time"** chip (server-aggregated `m.thread` bundle, or the live Thread once loaded) with an unread badge — threaded replies no longer render inline in the main timeline, so the chip is how conversations stay discoverable. diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index 8aba0051d..e36a6c137 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -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 ( @@ -151,6 +182,22 @@ export function Room() { /> )} + {showThreadsList && ( + <> + {screenSize === ScreenSize.Desktop && ( + + )} + setThreadsListOpen(false)} + onOpenThread={(threadId) => { + setActiveThreadId(threadId); + setThreadsListOpen(false); + }} + /> + + )} {showThreadPanel && activeThreadId && ( <> {screenSize === ScreenSize.Desktop && ( diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 3fa9ef645..7b1c634c3 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -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)) && } + {screenSize === ScreenSize.Desktop && ( + + {threadsListOpen ? 'Hide Threads' : 'Threads'} + + } + > + {(triggerRef) => ( + setThreadsListOpen(!threadsListOpen)} + aria-label="Toggle threads" + aria-pressed={threadsListOpen} + > + + + )} + + )} {screenSize === ScreenSize.Desktop && ( ( + 'cinny_threads_filter_v1', + 'all', + createJSONStorage(() => localStorage), + { getOnInit: true }, +); +const threadSortAtom = atomWithStorage( + 'cinny_threads_sort_v1', + 'recent', + createJSONStorage(() => localStorage), + { getOnInit: true }, +); + +const FILTER_OPTIONS: { value: ThreadFilter; label: string }[] = [ + { value: 'all', label: 'All' }, + { value: 'unread', label: 'Unread' }, + { value: 'participating', label: 'Participating' }, +]; +const SORT_OPTIONS: { value: ThreadSort; label: string }[] = [ + { value: 'recent', label: 'Recent' }, + { value: 'oldest', label: 'Oldest' }, +]; + +const MAX_PARTICIPANTS = 5; + +function formatTimeAgo(ts: number): string { + const diff = Date.now() - ts; + const minutes = Math.floor(diff / 60_000); + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days === 1) return 'yesterday'; + if (days < 7) return `${days}d ago`; + return new Date(ts).toLocaleDateString(); +} + +// Segmented button, mirroring the Bookmarks panel sort control for consistency. +function SegButton({ + label, + active, + onClick, +}: { + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function ParticipantAvatar({ room, userId }: { room: Room; userId: string }) { + const { name, avatarUrl } = useMemberAvatar(room, userId); + return ( + + } + /> + + ); +} + +type ThreadRowProps = { + room: Room; + thread: Thread; + unread: number; + participants: string[]; + onOpen: (threadId: string) => void; +}; +function ThreadRow({ room, thread, unread, participants, onOpen }: ThreadRowProps) { + const rootEvent = thread.rootEvent; + const rootSender = rootEvent?.getSender() ?? ''; + const { name: rootName, avatarUrl } = useMemberAvatar(room, rootSender); + const bodyRaw = + typeof rootEvent?.getContent().body === 'string' + ? (rootEvent.getContent().body as string) + : ''; + const snippet = bodyRaw ? scaleSystemEmoji(trimReplyFromBody(bodyRaw)) : '(no preview)'; + const count = thread.length; + const lastTs = thread.lastReply()?.getTs() ?? rootEvent?.getTs(); + const extra = participants.length - MAX_PARTICIPANTS; + + return ( + onOpen(thread.id)} + aria-label={`Open thread by ${rootName}`} + style={{ + width: '100%', + textAlign: 'left', + cursor: 'pointer', + padding: config.space.S300, + borderRadius: config.radii.R300, + background: color.SurfaceVariant.Container, + border: 'none', + }} + > + + + {nameInitials(rootName)}} + /> + + + {rootName} + + {unread > 0 && ( + + )} + + + + {snippet} + + + + + + {count} {count === 1 ? 'reply' : 'replies'} + {typeof lastTs === 'number' ? ` · ${formatTimeAgo(lastTs)}` : ''} + + + {participants.slice(0, MAX_PARTICIPANTS).map((userId) => ( + + ))} + {extra > 0 && ( + + +{extra} + + )} + + + + ); +} + +export type ThreadsListPanelProps = { + room: Room; + onClose: () => void; + onOpenThread: (threadId: string) => void; +}; +export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPanelProps) { + const threads = useRoomThreads(room); + const threadNotifications = useAtomValue(threadNotificationsAtom); + const [storedFilter, setFilter] = useAtom(threadFilterAtom); + const [storedSort, setSort] = useAtom(threadSortAtom); + const filter: ThreadFilter = isThreadFilter(storedFilter) ? storedFilter : 'all'; + const sort: ThreadSort = isThreadSort(storedSort) ? storedSort : 'recent'; + + // Escape closes the panel (parity with the app's other drawers). + useEffect(() => { + const handleKeyDown = (evt: KeyboardEvent) => { + if (evt.key === 'Escape' && !evt.defaultPrevented) { + evt.preventDefault(); + evt.stopPropagation(); + onClose(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + const { visible, unreadById, participantsById, threadById } = useMemo(() => { + const mutedSet = getMutedThreads(threadNotifications, room.roomId); + const unreadMap = new Map(); + const partsMap = new Map(); + const byId = new Map(); + const snaps: ThreadSnapshot[] = threads.map((t) => { + byId.set(t.id, t); + const rawUnread = + room.getThreadUnreadNotificationCount(t.id, NotificationCountType.Total) ?? 0; + const unread = mutedSet.has(t.id) ? 0 : rawUnread; + unreadMap.set(t.id, unread); + + // Participant senders: root first, then timeline order, deduped. + const seen = new Set(); + const parts: string[] = []; + const push = (s?: string | null) => { + if (s && !seen.has(s)) { + seen.add(s); + parts.push(s); + } + }; + push(t.rootEvent?.getSender()); + t.timeline.forEach((e) => push(e.getSender())); + partsMap.set(t.id, parts); + + return { + id: t.id, + latestTs: t.lastReply()?.getTs() ?? t.rootEvent?.getTs() ?? 0, + unread, + participated: t.hasCurrentUserParticipated, + }; + }); + const visibleSnaps = sortThreads(filterThreads(snaps, filter), sort); + return { + visible: visibleSnaps, + unreadById: unreadMap, + participantsById: partsMap, + threadById: byId, + }; + }, [threads, room, filter, sort, threadNotifications]); + + const scrollRef = useRef(null) as React.RefObject; + const virtualizer = useVirtualizer({ + count: visible.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 96, + overscan: 8, + getItemKey: (index) => visible[index].id, + }); + + const totalThreads = threads.length; + const emptyMessage = + totalThreads === 0 + ? 'No threads in this room yet. Start one from a message’s “Reply in Thread”.' + : 'No threads match this filter.'; + + return ( + +
+ + + + + Threads + + + {room.name} + + + + + + +
+ + + + {FILTER_OPTIONS.map((opt) => ( + setFilter(opt.value)} + /> + ))} + + + {SORT_OPTIONS.map((opt) => ( + setSort(opt.value)} + /> + ))} + + + + + + {visible.length === 0 ? ( + + + + {emptyMessage} + + + ) : ( + +
+ {virtualizer.getVirtualItems().map((vItem) => { + const snap = visible[vItem.index]; + const thread = threadById.get(snap.id); + if (!thread) return null; + return ( + + + + ); + })} +
+
+ )} +
+
+
+ ); +} diff --git a/src/app/hooks/useRoomThreads.ts b/src/app/hooks/useRoomThreads.ts new file mode 100644 index 000000000..307d1856b --- /dev/null +++ b/src/app/hooks/useRoomThreads.ts @@ -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(() => room.getThreads()); + const sigRef = useRef(''); + + 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; +} diff --git a/src/app/state/threadsList.ts b/src/app/state/threadsList.ts new file mode 100644 index 000000000..d5d99b3db --- /dev/null +++ b/src/app/state/threadsList.ts @@ -0,0 +1,4 @@ +import { atom } from 'jotai'; + +// Whether the room-level Threads list panel is open (mirrors mediaGalleryAtom). +export const threadsListAtom = atom(false); diff --git a/src/app/utils/threadList.test.ts b/src/app/utils/threadList.test.ts new file mode 100644 index 000000000..74ce1d0df --- /dev/null +++ b/src/app/utils/threadList.test.ts @@ -0,0 +1,91 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + filterThreads, + sortThreads, + isThreadFilter, + isThreadSort, + ThreadSnapshot, +} from './threadList'; + +const t = ( + id: string, + latestTs: number, + unread = 0, + participated = false, +): ThreadSnapshot => ({ id, latestTs, unread, participated }); + +test('filterThreads all returns every thread', () => { + const input = [t('a', 1, 0, false), t('b', 2, 3, true)]; + assert.deepEqual( + filterThreads(input, 'all').map((x) => x.id), + ['a', 'b'], + ); +}); + +test('filterThreads unread keeps only threads with unread > 0', () => { + const input = [t('a', 1, 0), t('b', 2, 2), t('c', 3, 0)]; + assert.deepEqual( + filterThreads(input, 'unread').map((x) => x.id), + ['b'], + ); +}); + +test('filterThreads participating keeps only participated threads', () => { + const input = [t('a', 1, 0, true), t('b', 2, 0, false), t('c', 3, 0, true)]; + assert.deepEqual( + filterThreads(input, 'participating').map((x) => x.id), + ['a', 'c'], + ); +}); + +test('sortThreads recent orders by latestTs descending', () => { + const input = [t('a', 100), t('b', 300), t('c', 200)]; + assert.deepEqual( + sortThreads(input, 'recent').map((x) => x.id), + ['b', 'c', 'a'], + ); +}); + +test('sortThreads oldest orders by latestTs ascending', () => { + const input = [t('a', 100), t('b', 300), t('c', 200)]; + assert.deepEqual( + sortThreads(input, 'oldest').map((x) => x.id), + ['a', 'c', 'b'], + ); +}); + +test('sortThreads breaks ties deterministically by id', () => { + const input = [t('z', 100), t('a', 100), t('m', 100)]; + assert.deepEqual( + sortThreads(input, 'recent').map((x) => x.id), + ['a', 'm', 'z'], + ); + assert.deepEqual( + sortThreads(input, 'oldest').map((x) => x.id), + ['a', 'm', 'z'], + ); +}); + +test('filterThreads / sortThreads do not mutate input', () => { + const input = [t('a', 100), t('b', 300)]; + const before = input.map((x) => x.id); + filterThreads(input, 'unread'); + sortThreads(input, 'oldest'); + assert.deepEqual( + input.map((x) => x.id), + before, + ); +}); + +test('isThreadFilter / isThreadSort accept valid and reject junk', () => { + assert.equal(isThreadFilter('all'), true); + assert.equal(isThreadFilter('unread'), true); + assert.equal(isThreadFilter('participating'), true); + assert.equal(isThreadFilter('bogus'), false); + assert.equal(isThreadFilter(undefined), false); + assert.equal(isThreadSort('recent'), true); + assert.equal(isThreadSort('oldest'), true); + assert.equal(isThreadSort('nope'), false); + assert.equal(isThreadSort(3), false); +}); diff --git a/src/app/utils/threadList.ts b/src/app/utils/threadList.ts new file mode 100644 index 000000000..cfe142308 --- /dev/null +++ b/src/app/utils/threadList.ts @@ -0,0 +1,44 @@ +// Pure filter/sort logic for the Threads list panel. Operates on plain snapshots +// so it's unit-testable without real matrix-js-sdk Thread objects. + +export type ThreadFilter = 'all' | 'unread' | 'participating'; +export type ThreadSort = 'recent' | 'oldest'; + +export type ThreadSnapshot = { + id: string; + latestTs: number; + unread: number; + participated: boolean; +}; + +const THREAD_FILTERS: readonly ThreadFilter[] = ['all', 'unread', 'participating']; +const THREAD_SORTS: readonly ThreadSort[] = ['recent', 'oldest']; + +/** Type guard for persisted/untrusted filter values (localStorage can hold junk). */ +export function isThreadFilter(value: unknown): value is ThreadFilter { + return typeof value === 'string' && (THREAD_FILTERS as readonly string[]).includes(value); +} + +/** Type guard for persisted/untrusted sort values. */ +export function isThreadSort(value: unknown): value is ThreadSort { + return typeof value === 'string' && (THREAD_SORTS as readonly string[]).includes(value); +} + +/** Keep only the threads matching the filter. Pure — returns a new array. */ +export function filterThreads(threads: T[], filter: ThreadFilter): T[] { + if (filter === 'unread') return threads.filter((t) => t.unread > 0); + if (filter === 'participating') return threads.filter((t) => t.participated); + return [...threads]; +} + +/** + * Order threads by last activity. `recent` = newest first, `oldest` = oldest first. + * Ties broken by id for stable, deterministic output. Pure — returns a new array. + */ +export function sortThreads(threads: T[], sort: ThreadSort): T[] { + const idCmp = (a: T, b: T): number => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); + return [...threads].sort((a, b) => { + const diff = sort === 'oldest' ? a.latestTs - b.latestTs : b.latestTs - a.latestTs; + return diff !== 0 ? diff : idCmp(a, b); + }); +}