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:
@@ -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.
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { config, toRem } from 'folds';
|
||||
|
||||
export const ThreadsListPanel = style({
|
||||
width: toRem(340),
|
||||
'@media': {
|
||||
'(max-width: 750px)': {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
zIndex: 500,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const ThreadsListHeader = style({
|
||||
flexShrink: 0,
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
});
|
||||
|
||||
export const ThreadsListToolbar = style({
|
||||
flexShrink: 0,
|
||||
padding: config.space.S200,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
});
|
||||
|
||||
export const ThreadsListContent = style({
|
||||
padding: config.space.S200,
|
||||
});
|
||||
@@ -0,0 +1,384 @@
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
|
||||
import { NotificationCountType, Room, Thread } from 'matrix-js-sdk';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Scroll,
|
||||
Text,
|
||||
color,
|
||||
config,
|
||||
} from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import * as css from './ThreadsListPanel.css';
|
||||
import { ContainerColor } from '../../../styles/ContainerColor.css';
|
||||
import { VirtualTile } from '../../../components/virtualizer';
|
||||
import { UserAvatar } from '../../../components/user-avatar';
|
||||
import { StackedAvatar } from '../../../components/stacked-avatar';
|
||||
import { useMemberAvatar } from '../../../hooks/useMemberAvatar';
|
||||
import { trimReplyFromBody } from '../../../utils/room';
|
||||
import { scaleSystemEmoji } from '../../../plugins/react-custom-html-parser';
|
||||
import { threadNotificationsAtom } from '../../../state/threadNotifications';
|
||||
import { getMutedThreads } from '../../../utils/threadNotifications';
|
||||
import { nameInitials } from '../../../utils/common';
|
||||
import {
|
||||
ThreadFilter,
|
||||
ThreadSort,
|
||||
ThreadSnapshot,
|
||||
filterThreads,
|
||||
sortThreads,
|
||||
isThreadFilter,
|
||||
isThreadSort,
|
||||
} from '../../../utils/threadList';
|
||||
import { useRoomThreads } from '../../../hooks/useRoomThreads';
|
||||
|
||||
// Persisted across panel opens (the panel unmounts on close). getOnInit reads
|
||||
// localStorage synchronously so the chosen filter/sort apply on first render.
|
||||
const threadFilterAtom = atomWithStorage<ThreadFilter>(
|
||||
'cinny_threads_filter_v1',
|
||||
'all',
|
||||
createJSONStorage(() => localStorage),
|
||||
{ getOnInit: true },
|
||||
);
|
||||
const threadSortAtom = atomWithStorage<ThreadSort>(
|
||||
'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 (
|
||||
<Button
|
||||
size="300"
|
||||
variant={active ? 'Primary' : 'Secondary'}
|
||||
fill={active ? 'Solid' : 'Soft'}
|
||||
radii="300"
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Text size="B300">{label}</Text>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function ParticipantAvatar({ room, userId }: { room: Room; userId: string }) {
|
||||
const { name, avatarUrl } = useMemberAvatar(room, userId);
|
||||
return (
|
||||
<StackedAvatar title={name} variant="SurfaceVariant" size="200" radii="Pill">
|
||||
<UserAvatar
|
||||
userId={userId}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</StackedAvatar>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box
|
||||
as="button"
|
||||
direction="Column"
|
||||
gap="100"
|
||||
onClick={() => 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',
|
||||
}}
|
||||
>
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Avatar size="200" radii="300">
|
||||
<UserAvatar
|
||||
userId={rootSender}
|
||||
src={avatarUrl}
|
||||
alt={rootName}
|
||||
renderFallback={() => <Text size="H6">{nameInitials(rootName)}</Text>}
|
||||
/>
|
||||
</Avatar>
|
||||
<Text size="T200" truncate style={{ flexGrow: 1, fontWeight: config.fontWeight.W600 }}>
|
||||
{rootName}
|
||||
</Text>
|
||||
{unread > 0 && (
|
||||
<Badge variant="Success" fill="Solid" radii="Pill" size="200" style={{ flexShrink: 0 }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Text
|
||||
size="T200"
|
||||
priority="400"
|
||||
style={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{snippet}
|
||||
</Text>
|
||||
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Icon size="50" src={Icons.Thread} />
|
||||
<Text size="T200" priority="300" truncate style={{ flexGrow: 1 }}>
|
||||
{count} {count === 1 ? 'reply' : 'replies'}
|
||||
{typeof lastTs === 'number' ? ` · ${formatTimeAgo(lastTs)}` : ''}
|
||||
</Text>
|
||||
<Box shrink="No" alignItems="Center">
|
||||
{participants.slice(0, MAX_PARTICIPANTS).map((userId) => (
|
||||
<ParticipantAvatar key={userId} room={room} userId={userId} />
|
||||
))}
|
||||
{extra > 0 && (
|
||||
<Text size="T200" priority="300" style={{ marginLeft: config.space.S100 }}>
|
||||
+{extra}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, number>();
|
||||
const partsMap = new Map<string, string[]>();
|
||||
const byId = new Map<string, Thread>();
|
||||
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<string>();
|
||||
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<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
||||
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 (
|
||||
<Box
|
||||
className={classNames(css.ThreadsListPanel, ContainerColor({ variant: 'Background' }))}
|
||||
shrink="No"
|
||||
direction="Column"
|
||||
>
|
||||
<Header className={css.ThreadsListHeader} variant="Background" size="600">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Icon src={Icons.Thread} size="200" />
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Text size="H4" truncate>
|
||||
Threads
|
||||
</Text>
|
||||
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
||||
{room.name}
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" radii="300" aria-label="Close threads" onClick={onClose}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Header>
|
||||
|
||||
<Box className={css.ThreadsListToolbar} direction="Column" gap="200">
|
||||
<Box role="group" aria-label="Filter threads" gap="100" wrap="Wrap">
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
<SegButton
|
||||
key={opt.value}
|
||||
label={opt.label}
|
||||
active={filter === opt.value}
|
||||
onClick={() => setFilter(opt.value)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Box role="group" aria-label="Sort threads" gap="100">
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<SegButton
|
||||
key={opt.value}
|
||||
label={opt.label}
|
||||
active={sort === opt.value}
|
||||
onClick={() => setSort(opt.value)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box grow="Yes" style={{ minHeight: 0 }}>
|
||||
<Scroll ref={scrollRef} variant="Background" size="300" visibility="Hover" hideTrack>
|
||||
{visible.length === 0 ? (
|
||||
<Box
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
gap="300"
|
||||
style={{ padding: config.space.S700, textAlign: 'center' }}
|
||||
>
|
||||
<Icon size="600" src={Icons.Thread} style={{ opacity: config.opacity.Disabled }} />
|
||||
<Text size="T300" priority="300" align="Center">
|
||||
{emptyMessage}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box className={css.ThreadsListContent} direction="Column">
|
||||
<div style={{ position: 'relative', height: virtualizer.getTotalSize(), width: '100%' }}>
|
||||
{virtualizer.getVirtualItems().map((vItem) => {
|
||||
const snap = visible[vItem.index];
|
||||
const thread = threadById.get(snap.id);
|
||||
if (!thread) return null;
|
||||
return (
|
||||
<VirtualTile
|
||||
key={snap.id}
|
||||
virtualItem={vItem}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{ width: '100%', paddingBottom: config.space.S200 }}
|
||||
>
|
||||
<ThreadRow
|
||||
room={room}
|
||||
thread={thread}
|
||||
unread={unreadById.get(snap.id) ?? 0}
|
||||
participants={participantsById.get(snap.id) ?? []}
|
||||
onOpen={onOpenThread}
|
||||
/>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
// Whether the room-level Threads list panel is open (mirrors mediaGalleryAtom).
|
||||
export const threadsListAtom = atom<boolean>(false);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<T extends ThreadSnapshot>(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<T extends ThreadSnapshot>(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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user