From 7da91bd803fd04f81f5ba9dd3a6c895fa2e568ba Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 19 Sep 2026 23:13:29 -0400 Subject: [PATCH] feat(timeline): 'Back to where you were' after a jump (#127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/features/room/RoomTimeline.tsx | 77 ++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 11 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index eb3f1b919..b89ab3846 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -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('[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 = useCallback( @@ -2382,17 +2422,32 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli - {!atBottom && ( + {(!atBottom || returnPoint) && ( - } - onClick={handleJumpToLatest} - > - Jump to Latest - + + {returnPoint && ( + } + onClick={handleReturn} + > + Back to where you were + + )} + {!atBottom && ( + } + onClick={handleJumpToLatest} + > + Jump to Latest + + )} + )}