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,
|
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,64 +151,80 @@ function ThreadRow({ room, thread, unread, highlight, participants, onOpen }: Th
|
|||||||
}`;
|
}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box direction="Column" style={{ position: 'relative' }}>
|
||||||
as="button"
|
<Box
|
||||||
direction="Column"
|
as="button"
|
||||||
gap="100"
|
direction="Column"
|
||||||
className={css.ThreadRow}
|
gap="100"
|
||||||
onClick={() => onOpen(thread.id)}
|
className={css.ThreadRow}
|
||||||
aria-label={ariaLabel}
|
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',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{snippet}
|
{/* Leaves room for the "Go to message" button laid over this corner. */}
|
||||||
</Text>
|
<Box alignItems="Center" gap="200" style={{ paddingRight: toRem(touch ? 36 : 28) }}>
|
||||||
|
<Avatar size="200" radii="300">
|
||||||
<Box alignItems="Center" gap="200">
|
<UserAvatar
|
||||||
<Icon size="50" src={Icons.Thread} />
|
userId={rootSender}
|
||||||
<Text size="T200" priority="300" truncate style={{ flexGrow: 1 }}>
|
src={avatarUrl}
|
||||||
{count} {count === 1 ? 'reply' : 'replies'}
|
alt={rootName}
|
||||||
{typeof lastTs === 'number' ? ` · ${formatRelativeAge(lastTs, prefs)}` : ''}
|
renderFallback={() => <Text size="H6">{nameInitials(rootName)}</Text>}
|
||||||
</Text>
|
/>
|
||||||
<Box shrink="No" alignItems="Center">
|
</Avatar>
|
||||||
{participants.slice(0, MAX_PARTICIPANTS).map((userId) => (
|
<Text size="T200" truncate style={{ flexGrow: 1, fontWeight: config.fontWeight.W600 }}>
|
||||||
<ParticipantAvatar key={userId} room={room} userId={userId} />
|
{rootName}
|
||||||
))}
|
</Text>
|
||||||
{extra > 0 && (
|
{unread > 0 && (
|
||||||
<Text size="T200" priority="300" style={{ marginLeft: config.space.S100 }}>
|
<UnreadBadgeCenter>
|
||||||
+{extra}
|
<UnreadBadge highlight={highlight > 0} count={unread} />
|
||||||
</Text>
|
</UnreadBadgeCenter>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</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>
|
</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>
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user