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:
2026-09-19 23:13:29 -04:00
co-authored by Claude Opus 5
parent 2a0c409180
commit 7da91bd803
+66 -11
View File
@@ -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>