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:
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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user