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, Scroll,
Text, Text,
config, config,
toRem,
} from 'folds'; } from 'folds';
import classNames from 'classnames'; import classNames from 'classnames';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
@@ -45,6 +46,8 @@ import {
import { useRoomThreads } from '../../../hooks/useRoomThreads'; import { useRoomThreads } from '../../../hooks/useRoomThreads';
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter'; import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
import { formatRelativeAge } from '../../../utils/formatTimestamp'; 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 // Persisted across panel opens (the panel unmounts on close). getOnInit reads
// localStorage synchronously so the chosen filter/sort apply on first render. // localStorage synchronously so the chosen filter/sort apply on first render.
@@ -118,8 +121,20 @@ type ThreadRowProps = {
highlight: number; highlight: number;
participants: string[]; participants: string[];
onOpen: (threadId: string) => void; 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 { prefs } = useTimestampFormatter();
const rootEvent = thread.rootEvent; const rootEvent = thread.rootEvent;
const rootSender = rootEvent?.getSender() ?? ''; const rootSender = rootEvent?.getSender() ?? '';
@@ -136,6 +151,7 @@ function ThreadRow({ room, thread, unread, highlight, participants, onOpen }: Th
}`; }`;
return ( return (
<Box direction="Column" style={{ position: 'relative' }}>
<Box <Box
as="button" as="button"
direction="Column" direction="Column"
@@ -144,7 +160,8 @@ function ThreadRow({ room, thread, unread, highlight, participants, onOpen }: Th
onClick={() => onOpen(thread.id)} onClick={() => onOpen(thread.id)}
aria-label={ariaLabel} aria-label={ariaLabel}
> >
<Box alignItems="Center" gap="200"> {/* 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"> <Avatar size="200" radii="300">
<UserAvatar <UserAvatar
userId={rootSender} userId={rootSender}
@@ -195,6 +212,20 @@ function ThreadRow({ room, thread, unread, highlight, participants, onOpen }: Th
</Box> </Box>
</Box> </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; onOpenThread: (threadId: string) => void;
}; };
export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPanelProps) { 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 threads = useRoomThreads(room);
const threadNotifications = useAtomValue(threadNotificationsAtom); const threadNotifications = useAtomValue(threadNotificationsAtom);
const [storedFilter, setFilter] = useAtom(threadFilterAtom); const [storedFilter, setFilter] = useAtom(threadFilterAtom);
@@ -403,6 +444,8 @@ export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPan
highlight={highlightById.get(snap.id) ?? 0} highlight={highlightById.get(snap.id) ?? 0}
participants={participantsById.get(snap.id) ?? []} participants={participantsById.get(snap.id) ?? []}
onOpen={onOpenThread} onOpen={onOpenThread}
onJump={handleJump}
touch={screenSize === ScreenSize.Mobile}
/> />
</VirtualTile> </VirtualTile>
); );