Files
cinny/src/app/features/room/thread/ThreadsListPanel.tsx
T
Lotus CIandClaude Opus 5.5 a932b1999a feat(threads): "Go to message" from the Threads list (#165)
A row in the Threads list only opened the thread panel; there was no way to
get to the root message in the room's timeline. Each row now has a small
"Go to message" button (a sibling of the row button, laid over its corner —
no nested buttons) that navigates the room to the root event. On a phone,
where the list covers the timeline, it also closes the list, and the button
is the bigger touch size.

Verified in Chromium: desktop jumps to the root with the list still open;
Pixel 7 jumps and closes the list (34 px target).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 21:58:14 -04:00

461 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAtom, useAtomValue } from 'jotai';
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
import { NotificationCountType, Room, Thread } from 'matrix-js-sdk';
import {
Avatar,
Box,
Button,
Chip,
Header,
Icon,
IconButton,
Icons,
Scroll,
Text,
config,
toRem,
} 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 { UnreadBadge, UnreadBadgeCenter } from '../../../components/unread-badge';
import { useMemberAvatar } from '../../../hooks/useMemberAvatar';
import { trimReplyFromBody } from '../../../utils/room';
import { scaleSystemEmoji } from '../../../plugins/react-custom-html-parser';
import { markAllThreadsRead } from '../../../utils/markThreadsRead';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
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';
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
import { formatRelativeAge } from '../../../utils/formatTimestamp';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
// 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;
// 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;
highlight: number;
participants: string[];
onOpen: (threadId: string) => void;
onJump: (threadId: string) => void;
/** Phone layout: a bigger touch target for the jump button. */
touch?: boolean;
};
function ThreadRow({
room,
thread,
unread,
highlight,
participants,
onOpen,
onJump,
touch,
}: ThreadRowProps) {
const { prefs } = useTimestampFormatter();
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.replyToEvent?.getTs() ?? rootEvent?.getTs();
const extra = participants.length - MAX_PARTICIPANTS;
const replyLabel = `${count} ${count === 1 ? 'reply' : 'replies'}`;
const ariaLabel = `Open thread by ${rootName}${unread > 0 ? ', unread' : ''}, ${replyLabel}${
typeof lastTs === 'number' ? `, last reply ${formatRelativeAge(lastTs, prefs)}` : ''
}`;
return (
<Box direction="Column" style={{ position: 'relative' }}>
<Box
as="button"
direction="Column"
gap="100"
className={css.ThreadRow}
onClick={() => onOpen(thread.id)}
aria-label={ariaLabel}
>
{/* Leaves room for the "Go to message" button laid over this corner. */}
<Box alignItems="Center" gap="200" style={{ paddingRight: toRem(touch ? 36 : 28) }}>
<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 && (
<UnreadBadgeCenter>
<UnreadBadge highlight={highlight > 0} count={unread} />
</UnreadBadgeCenter>
)}
</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' ? ` · ${formatRelativeAge(lastTs, prefs)}` : ''}
</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>
{/* [Gitea #165] Jump to the thread's first message in the room. A sibling
of the row button, not nested in it (no buttons inside buttons). */}
<IconButton
size={touch ? '400' : '300'}
radii="300"
variant="SurfaceVariant"
aria-label={`Go to the message that started ${rootName}'s thread`}
title="Go to message"
onClick={() => onJump(thread.id)}
style={{ position: 'absolute', top: toRem(6), right: toRem(6) }}
>
<Icon size="100" src={Icons.ArrowGoRight} />
</IconButton>
</Box>
);
}
export type ThreadsListPanelProps = {
room: Room;
onClose: () => void;
onOpenThread: (threadId: string) => void;
};
export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPanelProps) {
const { navigateRoom } = useRoomNavigate();
const screenSize = useScreenSizeContext();
const handleJump = useCallback(
(threadId: string) => {
navigateRoom(room.roomId, threadId);
// On a phone the list covers the timeline; get out of the way.
if (screenSize === ScreenSize.Mobile) onClose();
},
[navigateRoom, room.roomId, screenSize, onClose],
);
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, highlightById, participantsById, threadById } = useMemo(() => {
const mutedSet = getMutedThreads(threadNotifications, room.roomId);
const unreadMap = new Map<string, number>();
const highlightMap = 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 muted = mutedSet.has(t.id);
const unread = muted
? 0
: (room.getThreadUnreadNotificationCount(t.id, NotificationCountType.Total) ?? 0);
const highlight = muted
? 0
: (room.getThreadUnreadNotificationCount(t.id, NotificationCountType.Highlight) ?? 0);
unreadMap.set(t.id, unread);
highlightMap.set(t.id, highlight);
// Participant senders: root first, then timeline order, plus the last
// replier (from the server bundle) so it's present even if not paginated
// into the loaded timeline yet. Deduped, order preserved.
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()));
push(t.replyToEvent?.getSender());
partsMap.set(t.id, parts);
return {
id: t.id,
latestTs: t.replyToEvent?.getTs() ?? t.rootEvent?.getTs() ?? 0,
unread,
participated: t.hasCurrentUserParticipated,
};
});
const visibleSnaps = sortThreads(filterThreads(snaps, filter), sort);
return {
visible: visibleSnaps,
unreadById: unreadMap,
highlightById: highlightMap,
participantsById: partsMap,
threadById: byId,
};
}, [threads, room, filter, sort, threadNotifications]);
// [Gitea #165] "Mark all read" — one threaded receipt per unread thread.
const mx = useMatrixClient();
const [marking, setMarking] = useState(false);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [privateReadReceipts] = useSetting(settingsAtom, 'privateReadReceipts');
const totalUnread = useMemo(
() => [...unreadById.values()].reduce((n, c) => n + c, 0),
[unreadById],
);
const handleMarkAllRead = useCallback(() => {
setMarking(true);
markAllThreadsRead(mx, room, hideActivity || privateReadReceipts).finally(() =>
setMarking(false),
);
}, [mx, room, hideActivity, privateReadReceipts]);
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>
{totalUnread > 0 && (
<Chip
variant="Secondary"
fill="Soft"
radii="300"
outlined
disabled={marking}
onClick={handleMarkAllRead}
before={<Icon size="50" src={Icons.Check} />}
>
<Text size="B300">Mark all read</Text>
</Chip>
)}
<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}
highlight={highlightById.get(snap.id) ?? 0}
participants={participantsById.get(snap.id) ?? []}
onOpen={onOpenThread}
onJump={handleJump}
touch={screenSize === ScreenSize.Mobile}
/>
</VirtualTile>
);
})}
</div>
</Box>
)}
</Scroll>
</Box>
</Box>
);
}