feat(timeline): 'Back to where you were' after a jump (#127)
Clicking a reply quote (or arriving via a thread-panel / search / permalink jump, i.e. an eventId route change) remembers the message that was in the middle of the viewport and shows a Primary chip next to Jump to Latest — 'Back to where you were' — which scrolls/navigates back to it. Nothing is rendered normally; the chip expires after 20 s or once used. Verified headless: reply quote → original 120 messages up is shown + chip; chip → back at the reply, chip gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -899,6 +899,45 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
),
|
||||
);
|
||||
|
||||
// [Gitea #127] After a jump (reply quote, thread → main, search result) the
|
||||
// reader is stranded far up the history; remember the message that was in
|
||||
// the middle of the viewport and offer "Back to where you were".
|
||||
const [returnPoint, setReturnPoint] = useState<{ eventId: string; at: number } | null>(null);
|
||||
const captureReturnPoint = useCallback(() => {
|
||||
const scroller = scrollRef.current;
|
||||
if (!scroller) return;
|
||||
const mid = scroller.getBoundingClientRect().top + scroller.clientHeight / 2;
|
||||
const items = Array.from(scroller.querySelectorAll<HTMLElement>('[data-message-id]'));
|
||||
const hit =
|
||||
items.find((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.top <= mid && r.bottom >= mid;
|
||||
}) ?? items[items.length - 1];
|
||||
const id = hit?.getAttribute('data-message-id');
|
||||
if (id) setReturnPoint({ eventId: id, at: Date.now() });
|
||||
}, []);
|
||||
// Route-driven jumps (thread panel / search / permalink): capture before the
|
||||
// timeline is emptied by the effect below (effects run in declaration order).
|
||||
const prevEventIdRef = useRef(eventId);
|
||||
useEffect(() => {
|
||||
const prev = prevEventIdRef.current;
|
||||
prevEventIdRef.current = eventId;
|
||||
if (eventId && eventId !== prev && prev === undefined) captureReturnPoint();
|
||||
}, [eventId, captureReturnPoint]);
|
||||
// Expire after 20 s.
|
||||
useEffect(() => {
|
||||
if (!returnPoint) return undefined;
|
||||
const t = window.setTimeout(() => setReturnPoint(null), 20_000);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [returnPoint]);
|
||||
const handleReturn = useCallback(() => {
|
||||
if (!returnPoint) return;
|
||||
const target = returnPoint.eventId;
|
||||
setReturnPoint(null);
|
||||
if (eventId) navigateRoom(room.roomId, target);
|
||||
else handleOpenEvent(target, false);
|
||||
}, [returnPoint, eventId, navigateRoom, room.roomId, handleOpenEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (eventId) {
|
||||
setTimeline(getEmptyTimeline());
|
||||
@@ -1010,9 +1049,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
async (evt) => {
|
||||
const targetId = evt.currentTarget.getAttribute('data-event-id');
|
||||
if (!targetId) return;
|
||||
captureReturnPoint();
|
||||
handleOpenEvent(targetId);
|
||||
},
|
||||
[handleOpenEvent],
|
||||
[handleOpenEvent, captureReturnPoint],
|
||||
);
|
||||
|
||||
const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||
@@ -2382,17 +2422,32 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
<span ref={atBottomAnchorRef} />
|
||||
</Box>
|
||||
</Scroll>
|
||||
{!atBottom && (
|
||||
{(!atBottom || returnPoint) && (
|
||||
<TimelineFloat position="Bottom">
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
outlined
|
||||
before={<Icon size="50" src={Icons.ArrowBottom} />}
|
||||
onClick={handleJumpToLatest}
|
||||
>
|
||||
<Text size="L400">Jump to Latest</Text>
|
||||
</Chip>
|
||||
<Box gap="200" alignItems="Center">
|
||||
{returnPoint && (
|
||||
<Chip
|
||||
variant="Primary"
|
||||
radii="Pill"
|
||||
outlined
|
||||
before={<Icon size="50" src={Icons.ArrowBottom} />}
|
||||
onClick={handleReturn}
|
||||
>
|
||||
<Text size="L400">Back to where you were</Text>
|
||||
</Chip>
|
||||
)}
|
||||
{!atBottom && (
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
outlined
|
||||
before={<Icon size="50" src={Icons.ArrowBottom} />}
|
||||
onClick={handleJumpToLatest}
|
||||
>
|
||||
<Text size="L400">Jump to Latest</Text>
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</TimelineFloat>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user