diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index 8dd194e..996169b 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -785,9 +785,16 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
atLiveEndRef.current = liveTimelineLinked && rangeAtEnd;
// Historical / non-live windows are never "at bottom" of the room.
+ // When we return to the live end, re-check scroll — IntersectionObserver may
+ // not re-fire if the anchor stayed intersecting across the transition.
useEffect(() => {
if (!liveTimelineLinked || !rangeAtEnd) {
setAtBottom(false);
+ return;
+ }
+ const scrollEl = scrollRef.current;
+ if (scrollEl && isScrolledToBottom(scrollEl, 100)) {
+ setAtBottom(true);
}
}, [liveTimelineLinked, rangeAtEnd]);
@@ -1034,11 +1041,16 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
if (!targetEntry) return;
// Leave the live bottom immediately so Jump to Latest stays available.
- if (!targetEntry.isIntersecting || !atLiveEndRef.current) {
+ // Only intersection clears atBottom here; !atLiveEnd is handled by the
+ // liveTimelineLinked/rangeAtEnd effect so we don't stick false when the
+ // live end reconnects without a new intersection event.
+ if (!targetEntry.isIntersecting) {
setAtBottom(false);
return;
}
+ if (!atLiveEndRef.current) return;
+
setAtBottom(true);
setLatestUnreadMessage(null);
if (document.hasFocus()) {
@@ -1145,7 +1157,16 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const onScroll = () => {
if (restoringScrollRef.current) return;
window.cancelAnimationFrame(raf);
- raf = window.requestAnimationFrame(() => persistTimelineScroll(roomId));
+ raf = window.requestAnimationFrame(() => {
+ persistTimelineScroll(roomId);
+ // Keep Jump to Latest in sync with real scroll position when on the live end.
+ // IntersectionObserver alone can miss transitions and leave atBottom stuck.
+ if (atLiveEndRef.current) {
+ const at = isScrolledToBottom(scrollEl, 100);
+ setAtBottom(at);
+ if (at) setLatestUnreadMessage(null);
+ }
+ });
};
scrollEl.addEventListener('scroll', onScroll, { passive: true });
@@ -1250,6 +1271,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
scrollToBottomRef.current.smooth,
scrollToBottomRef.current.force
);
+ if (atLiveEndRef.current) {
+ setAtBottom(true);
+ }
}
}
}, [scrollToBottomCount]);
@@ -2588,7 +2612,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
>
))}
-
+ {/* 1×1 so IntersectionObserver reliably detects the live bottom */}
+
{(showUnreadTop || returnToEventId) && (