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
This commit is contained in:
Lotus CI
2026-09-24 21:58:14 -04:00
co-authored by Claude Opus 5.5
parent 64af736c6e
commit a932b1999a
@@ -14,6 +14,7 @@ import {
Scroll,
Text,
config,
toRem,
} from 'folds';
import classNames from 'classnames';
import { useVirtualizer } from '@tanstack/react-virtual';
@@ -45,6 +46,8 @@ import {
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.
@@ -118,8 +121,20 @@ type ThreadRowProps = {
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 }: ThreadRowProps) {
function ThreadRow({
room,
thread,
unread,
highlight,
participants,
onOpen,
onJump,
touch,
}: ThreadRowProps) {
const { prefs } = useTimestampFormatter();
const rootEvent = thread.rootEvent;
const rootSender = rootEvent?.getSender() ?? '';
@@ -136,64 +151,80 @@ function ThreadRow({ room, thread, unread, highlight, participants, onOpen }: Th
}`;
return (
<Box
as="button"
direction="Column"
gap="100"
className={css.ThreadRow}
onClick={() => onOpen(thread.id)}
aria-label={ariaLabel}
>
<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 && (
<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',
}}
<Box direction="Column" style={{ position: 'relative' }}>
<Box
as="button"
direction="Column"
gap="100"
className={css.ThreadRow}
onClick={() => onOpen(thread.id)}
aria-label={ariaLabel}
>
{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>
{/* 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>
);
}
@@ -204,6 +235,16 @@ export type ThreadsListPanelProps = {
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);
@@ -403,6 +444,8 @@ export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPan
highlight={highlightById.get(snap.id) ?? 0}
participants={participantsById.get(snap.id) ?? []}
onOpen={onOpenThread}
onJump={handleJump}
touch={screenSize === ScreenSize.Mobile}
/>
</VirtualTile>
);