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(() => {
|
useEffect(() => {
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
setTimeline(getEmptyTimeline());
|
setTimeline(getEmptyTimeline());
|
||||||
@@ -1010,9 +1049,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
async (evt) => {
|
async (evt) => {
|
||||||
const targetId = evt.currentTarget.getAttribute('data-event-id');
|
const targetId = evt.currentTarget.getAttribute('data-event-id');
|
||||||
if (!targetId) return;
|
if (!targetId) return;
|
||||||
|
captureReturnPoint();
|
||||||
handleOpenEvent(targetId);
|
handleOpenEvent(targetId);
|
||||||
},
|
},
|
||||||
[handleOpenEvent],
|
[handleOpenEvent, captureReturnPoint],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||||
@@ -2382,17 +2422,32 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
<span ref={atBottomAnchorRef} />
|
<span ref={atBottomAnchorRef} />
|
||||||
</Box>
|
</Box>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
{!atBottom && (
|
{(!atBottom || returnPoint) && (
|
||||||
<TimelineFloat position="Bottom">
|
<TimelineFloat position="Bottom">
|
||||||
<Chip
|
<Box gap="200" alignItems="Center">
|
||||||
variant="SurfaceVariant"
|
{returnPoint && (
|
||||||
radii="Pill"
|
<Chip
|
||||||
outlined
|
variant="Primary"
|
||||||
before={<Icon size="50" src={Icons.ArrowBottom} />}
|
radii="Pill"
|
||||||
onClick={handleJumpToLatest}
|
outlined
|
||||||
>
|
before={<Icon size="50" src={Icons.ArrowBottom} />}
|
||||||
<Text size="L400">Jump to Latest</Text>
|
onClick={handleReturn}
|
||||||
</Chip>
|
>
|
||||||
|
<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>
|
</TimelineFloat>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
Reference in New Issue
Block a user