Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a25b706
fix(thread-browser): fix layout, overflow, and oversized preview
Just-Insane May 14, 2026
e7bc9a0
fix(thread-browser): restore rich preview, fix sizing and overflow
Just-Insane May 14, 2026
4587f07
fix(thread-browser): remove overflow:hidden from items, fix preview flex
Just-Insane May 14, 2026
3a161aa
fix(timeline): show loading placeholders during history jump + preven…
Just-Insane May 15, 2026
4e82c48
fix(timeline): replace blank flash on room open with skeleton placeho…
Just-Insane May 15, 2026
b09454a
fix(timeline): stop infinite-loop hang on history jump, show jump but…
Just-Insane May 15, 2026
c22dc40
docs: document timeline issues needing fixes
Just-Insane May 18, 2026
b7b654b
merge: fix/timeline-room-open-flash - skeleton placeholders on room open
Just-Insane May 18, 2026
5349b28
merge: fix/history-jump-race - loading placeholders during history jump
Just-Insane May 18, 2026
d6ea9ae
merge: fix/timeline-history-reload - prevent infinite-loop hang on hi…
Just-Insane May 18, 2026
124bb49
fix(timeline): restore scroll position when thread drawer opens/closes
Just-Insane May 15, 2026
c2a7a0c
fix(thread-drawer): auto-size thread root, unclip hover actions
Just-Insane May 15, 2026
1ea0d7f
merge: fix/thread-browser-layout - thread browser overflow and previe…
Just-Insane May 18, 2026
beac7bf
fix(timeline): route ArrowUp-to-edit through handleEditCallback in ro…
Just-Insane May 15, 2026
57cb433
fix(timeline): fix loading spinner position, stop autopag loop, fix n…
Just-Insane May 15, 2026
3a1072c
fix(timeline): scroll to bottom in error-recovery fallback before reveal
Just-Insane May 15, 2026
939e322
fix(timeline): use 80ms double-scroll in error-recovery fallback befo…
Just-Insane May 16, 2026
5365f80
chore: add changeset
Just-Insane May 19, 2026
0f800bc
fix(timeline): add isReadyRef, fix handleEdit refs, fix inline import…
Just-Insane May 19, 2026
15d712b
fix(timeline): address Copilot review feedback
Just-Insane May 20, 2026
e7abebf
fix(timeline): suppress day divider for out-of-order bridged messages
Just-Insane May 20, 2026
6961cb4
style: fix formatting
Just-Insane May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/timeline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fix timeline scroll recovery, loading spinner position, autopag loop, blank notification room, and ArrowUp-to-edit routing.
177 changes: 151 additions & 26 deletions src/app/features/room/RoomTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import {
getEventTimeline,
getFirstLinkedTimeline,
getInitialTimeline,
getEmptyTimeline,
getEventIdAbsoluteIndex,
} from '$utils/timeline';
import { useTimelineSync } from '$hooks/timeline/useTimelineSync';
Expand Down Expand Up @@ -212,6 +213,12 @@ export function RoomTimeline({
const openThreadId = useAtomValue(openThreadAtom);
const setOpenThread = useSetAtom(openThreadAtom);

// Preserved scroll offset from just before the thread drawer was opened, so
// we can restore position when the drawer closes and the main column reflows
// to a wider width (remeasured items would otherwise leave the VList at an
// unexpected position).
const scrollOffsetBeforeThreadRef = useRef<number | undefined>(undefined);

const vListRef = useRef<VListHandle>(null);
const [atBottomState, setAtBottomState] = useState(true);
const atBottomRef = useRef(atBottomState);
Expand All @@ -237,6 +244,8 @@ export function RoomTimeline({
const currentRoomIdRef = useRef(room.roomId);

const [isReady, setIsReady] = useState(false);
const isReadyRef = useRef(isReady);
isReadyRef.current = isReady;

if (currentRoomIdRef.current !== room.roomId) {
hasInitialScrolledRef.current = false;
Expand Down Expand Up @@ -290,6 +299,10 @@ export function RoomTimeline({
const forwardStatusRef = useRef(timelineSync.forwardStatus);
forwardStatusRef.current = timelineSync.forwardStatus;

// Caps consecutive auto-pagination calls so a sparse timeline that never fills
// the viewport cannot loop indefinitely. Reset on every timeline clear/room jump.
const autopagAttemptsRef = useRef(0);

const getRawIndexToProcessedIndex = useCallback((rawIndex: number): number | undefined => {
const events = processedEventsRef.current;
const match = events.find((e) => e.itemIndex === rawIndex);
Expand Down Expand Up @@ -351,6 +364,7 @@ export function RoomTimeline({
if (timelineSync.eventsLength > 0) return;
setIsReady(false);
hasInitialScrolledRef.current = false;
autopagAttemptsRef.current = 0;
}, [isReady, timelineSync.eventsLength]);

const recalcTopSpacer = useCallback(() => {
Expand Down Expand Up @@ -395,6 +409,10 @@ export function RoomTimeline({
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
if (timelineSync.focusItem) {
// Reveal the timeline in the same effect that scrolls to the focus event so
// both the scroll and opacity-1 land in a single commit — no intermediate
// frame where events are rendered but still opacity-0.
setIsReady(true);
if (timelineSync.focusItem.scrollTo && vListRef.current) {
const processedIndex = getRawIndexToProcessedIndex(timelineSync.focusItem.index);
if (processedIndex !== undefined) {
Expand All @@ -411,18 +429,71 @@ export function RoomTimeline({
};
}, [timelineSync.focusItem, timelineSync, reducedMotion, getRawIndexToProcessedIndex]);

useEffect(() => {
if (timelineSync.focusItem) {
setIsReady(true);
}
}, [timelineSync.focusItem]);

useEffect(() => {
if (!eventId) return;
setIsReady(false);
// Re-arm the initial-scroll guard so that if the jump fails and falls back
// to the live timeline, the useLayoutEffect can fire via the normal path.
hasInitialScrolledRef.current = false;
// Reset auto-pagination cap so the new timeline can fill the viewport.
autopagAttemptsRef.current = 0;
// Cancel any pending error-recovery scroll timer from a previous eventId load
// so it cannot reveal the timeline mid-flight of a new load.
if (initialScrollTimerRef.current !== undefined) {
clearTimeout(initialScrollTimerRef.current);
initialScrollTimerRef.current = undefined;
}
// Clear the stale live-timeline content immediately so loading placeholders
// are shown while the event-context API call is in flight, rather than
// having the entire message area go invisible (opacity:0) with no feedback.
timelineSyncRef.current.setTimeline(getEmptyTimeline());
void timelineSyncRef.current.loadEventTimeline(eventId);
}, [eventId, room.roomId]);

// Recovery: loadEventTimeline's onError callback restores the live timeline but
// scrollToBottom fires before the VList has rendered the new events (the list is
// still empty at that point), so it returns early and no scroll happens.
// Detect the "eventId load failed, fell back to live" state and reveal the
// timeline scrolled to the bottom so the room is usable rather than stuck at
// opacity-0 or stranded at the top of history.
useEffect(() => {
if (!eventId) return;
if (isReady) return;
if (timelineSync.eventsLength === 0) return;
if (timelineSync.focusItem) return;
if (!timelineSync.liveTimelineLinked) return;
// Guard: don't start a second timer if one is already in flight.
if (initialScrollTimerRef.current !== undefined) return;

// Virtua has no measured item heights yet when data first populates
// (transition from 0 → N items). A single scrollToIndex call lands at the
// estimated position (often 0) because every item is still at its default
// height. Mirror the double-scroll pattern from the initial-scroll
// useLayoutEffect: scroll once immediately to warm up virtua's layout pass,
// then scroll again after 80 ms when heights are measured, then reveal.
const lastIdx = processedEventsRef.current.length - 1;
if (lastIdx >= 0) vListRef.current?.scrollToIndex(lastIdx, { align: 'end' });

initialScrollTimerRef.current = setTimeout(() => {
initialScrollTimerRef.current = undefined;
// Bail out if the timeline was already revealed by another code path
// (e.g. loadEventTimeline succeeded and set focusItem in the meantime).
if (isReadyRef.current) return;
if (timelineSyncRef.current.focusItem) return;
if (timelineSyncRef.current.eventsLength === 0) return;
if (!timelineSyncRef.current.liveTimelineLinked) return;
const idx = processedEventsRef.current.length - 1;
if (idx >= 0) vListRef.current?.scrollToIndex(idx, { align: 'end' });
setIsReady(true);
}, 80);
}, [
eventId,
isReady,
timelineSync.eventsLength,
timelineSync.focusItem,
timelineSync.liveTimelineLinked,
]);

useEffect(() => {
if (eventId) return;
// Guard: once the timeline is visible to the user, do not override their
Expand Down Expand Up @@ -482,6 +553,24 @@ export function RoomTimeline({
return () => observer.disconnect();
}, []);

// When the thread drawer opens/closes on desktop, the main timeline column
// changes width and Virtua remeasures all item heights. Save the scroll
// offset just before the open so we can restore it after the close once
// layout has settled (two RAFs to let Virtua finish its resize cycle).
useEffect(() => {
if (openThreadId) {
scrollOffsetBeforeThreadRef.current = vListRef.current?.scrollOffset;
} else if (scrollOffsetBeforeThreadRef.current !== undefined) {
const savedOffset = scrollOffsetBeforeThreadRef.current;
scrollOffsetBeforeThreadRef.current = undefined;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
vListRef.current?.scrollTo(savedOffset);
});
});
}
}, [openThreadId]);

const actions = useTimelineActions({
room,
mx,
Expand Down Expand Up @@ -854,7 +943,10 @@ export function RoomTimeline({
const hasRealScrollRoom = v.scrollSize > v.viewportSize + 300;

if (!hasRealScrollRoom || (atTop && noVisibleGrowth)) {
void timelineSyncRef.current.handleTimelinePagination(true);
if (autopagAttemptsRef.current < 20) {
autopagAttemptsRef.current += 1;
void timelineSyncRef.current.handleTimelinePagination(true);
}
}
};

Expand Down Expand Up @@ -1020,28 +1112,61 @@ export function RoomTimeline({
</TimelineFloat>
)}

{frontPaginationJSX && (
<TimelineFloat position="Bottom" style={timelineBottomFloatLift}>
{(!atBottomState || !timelineSync.liveTimelineLinked) && isReady && (
<TimelineFloat position="Bottom">
{frontPaginationJSX}
{!frontPaginationJSX && (
<Chip
variant="SurfaceVariant"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.ArrowBottom} />}
onClick={() => {
if (eventId) navigateRoom(room.roomId, undefined, { replace: true });
timelineSync.setTimeline(getInitialTimeline(room));
scrollToBottom();
}}
>
<Text size="L400">Jump to Latest</Text>
</Chip>
)}
</TimelineFloat>
)}

{!atBottomState && isReady && (
<TimelineFloat position="Bottom">
<Chip
variant="SurfaceVariant"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.ArrowBottom} />}
onClick={() => {
if (eventId) navigateRoom(room.roomId, undefined, { replace: true });
timelineSync.setTimeline(getInitialTimeline(room));
scrollToBottom();
}}
>
<Text size="L400">Jump to Latest</Text>
</Chip>
</TimelineFloat>
{!isReady && !showLoadingPlaceholders && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
padding: `0 0 ${config.space.S600} 0`,
overflow: 'hidden',
pointerEvents: 'none',
}}
>
<MessageBase space={messageSpacing}>
{messageLayout === MessageLayout.Compact ? (
<CompactPlaceholder />
) : (
<DefaultPlaceholder />
)}
</MessageBase>
<MessageBase space={messageSpacing}>
{messageLayout === MessageLayout.Compact ? (
<CompactPlaceholder />
) : (
<DefaultPlaceholder />
)}
</MessageBase>
<MessageBase space={messageSpacing}>
{messageLayout === MessageLayout.Compact ? (
<CompactPlaceholder />
) : (
<DefaultPlaceholder />
)}
</MessageBase>
</div>
)}
</Box>
);
Expand Down
4 changes: 2 additions & 2 deletions src/app/features/room/ThreadBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ import {
UsernameBold,
Reply,
} from '$components/message';
import { RenderMessageContent } from '$components/RenderMessageContent';
import { settingsAtom } from '$state/settings';
import { useSetting } from '$state/hooks/settings';
import type { GetContentCallback } from '$types/matrix/room';
Expand All @@ -53,6 +52,7 @@ import {
import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge';
import { EncryptedContent } from './message';
import * as css from './ThreadDrawer.css';
import { RenderMessageContent } from '$components/RenderMessageContent';
import { SidebarResizer } from '$pages/client/sidebar/SidebarResizer';
import { mobileOrTablet } from '$utils/user-agent';

Expand Down Expand Up @@ -228,7 +228,7 @@ function ThreadPreview({ room, thread, onClick, onJump }: ThreadPreviewProps) {
onClick={handleJumpClick}
/>
)}
<Box style={{ maxHeight: '200px', overflow: 'auto', flexShrink: 0 }}>
<Box direction="Column" style={{ maxHeight: '200px', overflow: 'auto', minHeight: 0 }}>
<EncryptedContent mEvent={rootEvent}>
{() => {
if (rootEvent.isRedacted()) {
Expand Down
3 changes: 1 addition & 2 deletions src/app/features/room/ThreadDrawer.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,13 @@ export const ThreadDrawerOverlay = style({

export const ThreadBrowserItem = style({
width: '100%',
padding: `${config.space.S200} ${config.space.S100}`,
padding: `${config.space.S200} ${config.space.S400}`,
borderRadius: config.radii.R300,
textAlign: 'left',
cursor: 'pointer',
background: 'none',
border: 'none',
color: 'inherit',
overflow: 'hidden',
':hover': {
backgroundColor: color.SurfaceVariant.Container,
transform: 'none',
Expand Down
3 changes: 2 additions & 1 deletion src/app/features/room/ThreadDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra
useEffect(() => {
setCurHeight(threadRootHeight);
}, [threadRootHeight]);

return (
<Box
className={overlay ? css.ThreadDrawerOverlay : css.ThreadDrawer}
Expand Down Expand Up @@ -804,7 +805,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra
size="300"
hideTrack
style={{
height: toRem(curHeight),
maxHeight: toRem(curHeight),
flexShrink: 0,
}}
>
Expand Down
9 changes: 8 additions & 1 deletion src/app/hooks/timeline/useProcessedTimeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,14 @@ export function useProcessedTimeline({
}

if (!dayDivider) {
dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false;
// Only insert a day divider when moving *forward* to a new calendar day.
// Bridged messages (Discord, Signal, …) arrive with an origin_server_ts from
// an earlier day but are inserted at the end of the timeline by the SDK.
// Showing a backward day divider ("Yesterday" after "Today" messages) breaks
// the visual ordering, so we suppress dividers for out-of-order events.
dayDivider = prevEvent
? !inSameDay(prevEvent.getTs(), mEvent.getTs()) && mEvent.getTs() > prevEvent.getTs()
: false;
}

const isMessageEvent = MESSAGE_EVENT_TYPES.has(type);
Expand Down
Loading
Loading