From a25b70660206385d1314b317c0afefda59583025 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 14 May 2026 19:24:50 -0400 Subject: [PATCH 01/18] fix(thread-browser): fix layout, overflow, and oversized preview - Remove overflow:hidden from ThreadBrowserItem so hover actions (Jump chip, unread badge) are no longer clipped - Increase horizontal padding from S100 to S400 for consistent spacing - Replace the tall scrollable 200px message preview box with a 2-line text clamp (webkit-line-clamp), eliminating the nested scroll artifact and greatly reducing item height - Remove all now-unused imports and hooks (RenderMessageContent, EncryptedContent, linkifyOpts, htmlReactParserOptions, mention/ spoiler handlers, settingsLinkBaseUrl, mediaAutoLoad, urlPreview) --- src/app/features/room/ThreadBrowser.tsx | 109 +++++----------------- src/app/features/room/ThreadDrawer.css.ts | 3 +- 2 files changed, 22 insertions(+), 90 deletions(-) diff --git a/src/app/features/room/ThreadBrowser.tsx b/src/app/features/room/ThreadBrowser.tsx index 766889032..152739feb 100644 --- a/src/app/features/room/ThreadBrowser.tsx +++ b/src/app/features/room/ThreadBrowser.tsx @@ -1,5 +1,5 @@ import type { ChangeEventHandler, MouseEventHandler } from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Box, Header, @@ -18,11 +18,8 @@ import { import type { EventTimelineSet, MatrixEvent, Room, Thread } from '$types/matrix-sdk'; import { NotificationCountType, RoomEvent, ThreadEvent } from '$types/matrix-sdk'; import { useAtomValue } from 'jotai'; -import type { HTMLReactParserOptions } from 'html-react-parser'; -import type { Opts as LinkifyOpts } from 'linkifyjs'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; -import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { useRoomNavigate } from '$hooks/useRoomNavigate'; import { nicknamesAtom } from '$state/nicknames'; import { getMemberAvatarMxc, getMemberDisplayName, reactionOrEditEvent } from '$utils/room'; @@ -37,21 +34,9 @@ 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'; -import { useMentionClickHandler } from '$hooks/useMentionClickHandler'; -import { useSpoilerClickHandler } from '$hooks/useSpoilerClickHandler'; -import { - factoryRenderLinkifyWithMention, - getReactCustomHtmlParser, - LINKIFY_OPTS, - makeMentionCustomProps, - renderMatrixMention, -} from '$plugins/react-custom-html-parser'; import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; -import { EncryptedContent } from './message'; import * as css from './ThreadDrawer.css'; import { SidebarResizer } from '$pages/client/sidebar/SidebarResizer'; import { mobileOrTablet } from '$utils/user-agent'; @@ -68,54 +53,8 @@ function ThreadPreview({ room, thread, onClick, onJump }: ThreadPreviewProps) { const useAuthentication = useMediaAuthentication(); const { navigateRoom } = useRoomNavigate(); const nicknames = useAtomValue(nicknamesAtom); - const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); - const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); - const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); - const mentionClickHandler = useMentionClickHandler(room.roomId); - const spoilerClickHandler = useSpoilerClickHandler(); - - const linkifyOpts = useMemo( - () => ({ - ...LINKIFY_OPTS, - render: factoryRenderLinkifyWithMention( - settingsLinkBaseUrl, - (href: string) => - renderMatrixMention( - mx, - room.roomId, - href, - makeMentionCustomProps(mentionClickHandler), - nicknames - ), - mentionClickHandler - ), - }), - [mx, room.roomId, nicknames, mentionClickHandler, settingsLinkBaseUrl] - ); - - const htmlReactParserOptions = useMemo( - () => - getReactCustomHtmlParser(mx, room.roomId, { - settingsLinkBaseUrl, - linkifyOpts, - handleSpoilerClick: spoilerClickHandler, - handleMentionClick: mentionClickHandler, - useAuthentication, - nicknames, - }), - [ - mx, - room, - linkifyOpts, - mentionClickHandler, - spoilerClickHandler, - useAuthentication, - nicknames, - settingsLinkBaseUrl, - ] - ); const handleJumpClick: MouseEventHandler = useCallback( (evt) => { @@ -149,8 +88,6 @@ function ThreadPreview({ room, thread, onClick, onJump }: ThreadPreviewProps) { const displayName = getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId; const senderAvatarMxc = getMemberAvatarMxc(room, senderId); - const getContent = (() => rootEvent.getContent()) as GetContentCallback; - const localReplyCount = thread.events.filter( (ev: MatrixEvent) => ev.getId() !== thread.id && !reactionOrEditEvent(ev) ).length; @@ -228,30 +165,26 @@ function ThreadPreview({ room, thread, onClick, onJump }: ThreadPreviewProps) { onClick={handleJumpClick} /> )} - - - {() => { - if (rootEvent.isRedacted()) { - return ; - } - - return ( - - ); - }} - - + {(() => { + if (rootEvent.isRedacted()) return ; + const content = rootEvent.getContent(); + const body = typeof content?.body === 'string' ? content.body : ''; + if (!body) return null; + return ( + + {body} + + ); + })()} {replyCount > 0 && ( diff --git a/src/app/features/room/ThreadDrawer.css.ts b/src/app/features/room/ThreadDrawer.css.ts index 607f2ac06..936af765d 100644 --- a/src/app/features/room/ThreadDrawer.css.ts +++ b/src/app/features/room/ThreadDrawer.css.ts @@ -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', From e7bc9a0c8587d2730487b80a344112eafd9b8378 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 14 May 2026 19:30:44 -0400 Subject: [PATCH 02/18] fix(thread-browser): restore rich preview, fix sizing and overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore full RenderMessageContent preview with proper linkify/ mention/spoiler context — reverts the over-simplified text clamp - Remove flexShrink:0 from the preview Box so short messages use their natural height instead of always reserving 200px - Keep maxHeight:200px + overflow:auto so long messages still scroll - Restore overflow:hidden on ThreadBrowserItem so content is clipped at the bottom (below the replies row) as intended - Keep S400 horizontal padding (vs previous S100) so the Jump chip and unread badge are no longer clipped on the right --- src/app/features/room/ThreadBrowser.tsx | 107 +++++++++++++++++----- src/app/features/room/ThreadDrawer.css.ts | 1 + 2 files changed, 87 insertions(+), 21 deletions(-) diff --git a/src/app/features/room/ThreadBrowser.tsx b/src/app/features/room/ThreadBrowser.tsx index 152739feb..1fe1f3175 100644 --- a/src/app/features/room/ThreadBrowser.tsx +++ b/src/app/features/room/ThreadBrowser.tsx @@ -1,5 +1,5 @@ import type { ChangeEventHandler, MouseEventHandler } from 'react'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Box, Header, @@ -20,6 +20,7 @@ import { NotificationCountType, RoomEvent, ThreadEvent } from '$types/matrix-sdk import { useAtomValue } from 'jotai'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { useRoomNavigate } from '$hooks/useRoomNavigate'; import { nicknamesAtom } from '$state/nicknames'; import { getMemberAvatarMxc, getMemberDisplayName, reactionOrEditEvent } from '$utils/room'; @@ -36,8 +37,20 @@ import { } from '$components/message'; import { settingsAtom } from '$state/settings'; import { useSetting } from '$state/hooks/settings'; +import type { GetContentCallback } from '$types/matrix/room'; +import { useMentionClickHandler } from '$hooks/useMentionClickHandler'; +import { useSpoilerClickHandler } from '$hooks/useSpoilerClickHandler'; +import { + factoryRenderLinkifyWithMention, + getReactCustomHtmlParser, + LINKIFY_OPTS, + makeMentionCustomProps, + renderMatrixMention, +} from '$plugins/react-custom-html-parser'; 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'; @@ -53,8 +66,54 @@ function ThreadPreview({ room, thread, onClick, onJump }: ThreadPreviewProps) { const useAuthentication = useMediaAuthentication(); const { navigateRoom } = useRoomNavigate(); const nicknames = useAtomValue(nicknamesAtom); + const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); + const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); + const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); + const mentionClickHandler = useMentionClickHandler(room.roomId); + const spoilerClickHandler = useSpoilerClickHandler(); + + const linkifyOpts = useMemo( + () => ({ + ...LINKIFY_OPTS, + render: factoryRenderLinkifyWithMention( + settingsLinkBaseUrl, + (href: string) => + renderMatrixMention( + mx, + room.roomId, + href, + makeMentionCustomProps(mentionClickHandler), + nicknames + ), + mentionClickHandler + ), + }), + [mx, room.roomId, nicknames, mentionClickHandler, settingsLinkBaseUrl] + ); + + const htmlReactParserOptions = useMemo( + () => + getReactCustomHtmlParser(mx, room.roomId, { + settingsLinkBaseUrl, + linkifyOpts, + handleSpoilerClick: spoilerClickHandler, + handleMentionClick: mentionClickHandler, + useAuthentication, + nicknames, + }), + [ + mx, + room, + linkifyOpts, + mentionClickHandler, + spoilerClickHandler, + useAuthentication, + nicknames, + settingsLinkBaseUrl, + ] + ); const handleJumpClick: MouseEventHandler = useCallback( (evt) => { @@ -88,6 +147,8 @@ function ThreadPreview({ room, thread, onClick, onJump }: ThreadPreviewProps) { const displayName = getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId; const senderAvatarMxc = getMemberAvatarMxc(room, senderId); + const getContent = (() => rootEvent.getContent()) as GetContentCallback; + const localReplyCount = thread.events.filter( (ev: MatrixEvent) => ev.getId() !== thread.id && !reactionOrEditEvent(ev) ).length; @@ -165,26 +226,30 @@ function ThreadPreview({ room, thread, onClick, onJump }: ThreadPreviewProps) { onClick={handleJumpClick} /> )} - {(() => { - if (rootEvent.isRedacted()) return ; - const content = rootEvent.getContent(); - const body = typeof content?.body === 'string' ? content.body : ''; - if (!body) return null; - return ( - - {body} - - ); - })()} + + + {() => { + if (rootEvent.isRedacted()) { + return ; + } + + return ( + + ); + }} + + {replyCount > 0 && ( diff --git a/src/app/features/room/ThreadDrawer.css.ts b/src/app/features/room/ThreadDrawer.css.ts index 936af765d..b3a35f54b 100644 --- a/src/app/features/room/ThreadDrawer.css.ts +++ b/src/app/features/room/ThreadDrawer.css.ts @@ -76,6 +76,7 @@ export const ThreadBrowserItem = style({ background: 'none', border: 'none', color: 'inherit', + overflow: 'hidden', ':hover': { backgroundColor: color.SurfaceVariant.Container, transform: 'none', From 4587f07732a286fa91b5f8ca83b1643de0dc9da1 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 14 May 2026 19:43:18 -0400 Subject: [PATCH 03/18] fix(thread-browser): remove overflow:hidden from items, fix preview flex - Remove overflow:hidden from ThreadBrowserItem so hover overlays from RenderMessageContent are no longer clipped/hidden behind the button - Add direction="Column" + minHeight:0 to the preview Box so content stacks vertically and the maxHeight:200px scroll cap works correctly in the flex column context --- src/app/features/room/ThreadBrowser.tsx | 2 +- src/app/features/room/ThreadDrawer.css.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/features/room/ThreadBrowser.tsx b/src/app/features/room/ThreadBrowser.tsx index 1fe1f3175..c8c3a0c46 100644 --- a/src/app/features/room/ThreadBrowser.tsx +++ b/src/app/features/room/ThreadBrowser.tsx @@ -226,7 +226,7 @@ function ThreadPreview({ room, thread, onClick, onJump }: ThreadPreviewProps) { onClick={handleJumpClick} /> )} - + {() => { if (rootEvent.isRedacted()) { diff --git a/src/app/features/room/ThreadDrawer.css.ts b/src/app/features/room/ThreadDrawer.css.ts index b3a35f54b..936af765d 100644 --- a/src/app/features/room/ThreadDrawer.css.ts +++ b/src/app/features/room/ThreadDrawer.css.ts @@ -76,7 +76,6 @@ export const ThreadBrowserItem = style({ background: 'none', border: 'none', color: 'inherit', - overflow: 'hidden', ':hover': { backgroundColor: color.SurfaceVariant.Container, transform: 'none', From 3a161aad62110c673ae2132745c8ae570b9096cf Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 15 May 2026 08:26:06 -0400 Subject: [PATCH 04/18] fix(timeline): show loading placeholders during history jump + prevent concurrent load races Two fixes for janky bookmark / pin / thread history jumps: 1. Clear the timeline before calling loadEventTimeline from the eventId useEffect so loading placeholders are shown while the event-context API call is in flight. Previously setIsReady(false) was called but the live timeline state (eventsLength > 0) kept showLoadingPlaceholders false, causing the entire message area to go invisible (opacity:0) with no feedback for however long the network round-trip took. 2. Add a monotonically-increasing loadId counter inside useEventTimelineLoader. Only the most-recently-started call commits its result; stale concurrent calls (e.g. one from the eventId useEffect and one triggered by useLiveTimelineRefresh on TimelineRefresh) are discarded so they cannot clobber the winner with a duplicate setFocusItem / double scroll animation. --- src/app/features/room/RoomTimeline.tsx | 6 ++++++ src/app/hooks/timeline/useTimelineSync.ts | 21 +++++++++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 92f82061b..d6f96964a 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -74,6 +74,7 @@ import { getEventTimeline, getFirstLinkedTimeline, getInitialTimeline, + getEmptyTimeline, getEventIdAbsoluteIndex, } from '$utils/timeline'; import { useTimelineSync } from '$hooks/timeline/useTimelineSync'; @@ -420,6 +421,11 @@ export function RoomTimeline({ useEffect(() => { if (!eventId) return; setIsReady(false); + // 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 + // for however long the network round-trip takes. + timelineSyncRef.current.setTimeline(getEmptyTimeline()); void timelineSyncRef.current.loadEventTimeline(eventId); }, [eventId, room.roomId]); diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index c10b762b8..3b2e427cb 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -57,10 +57,18 @@ const useEventTimelineLoader = ( room: Room, onLoad: (eventId: string, linkedTimelines: EventTimeline[], evtAbsIndex: number) => void, onError: (err: Error | null) => void -) => - useCallback( +) => { + // Monotonically-increasing counter so that only the most-recently-started + // loadEventTimeline call can commit its result. Concurrent calls (e.g. one + // from the eventId useEffect and one triggered by useLiveTimelineRefresh) + // would otherwise both fire setTimeline + setFocusItem, producing a double + // scroll animation and potentially landing on the wrong event. + const loadIdRef = useRef(0); + + return useCallback( async (eventId: string) => Sentry.startSpan({ name: 'timeline.jump_load', op: 'matrix.timeline' }, async () => { + const loadId = ++loadIdRef.current; const jumpLoadStart = performance.now(); if (!room.getUnfilteredTimelineSet().getTimelineForEvent(eventId)) { @@ -80,17 +88,21 @@ const useEventTimelineLoader = ( ) ); if (!replyEvtTimeline) { - onError(err ?? null); + if (loadId === loadIdRef.current) onError(err ?? null); return; } const linkedTimelines = getLinkedTimelines(replyEvtTimeline); const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId); if (absIndex === undefined) { - onError(err ?? null); + if (loadId === loadIdRef.current) onError(err ?? null); return; } + // A newer loadEventTimeline call is already in flight; discard this result + // so we do not clobber the more-recent load's timeline or focus scroll. + if (loadId !== loadIdRef.current) return; + Sentry.metrics.distribution( 'sable.timeline.jump_load_ms', performance.now() - jumpLoadStart @@ -99,6 +111,7 @@ const useEventTimelineLoader = ( }), [mx, room, onLoad, onError] ); +}; const useTimelinePagination = ( mx: MatrixClient, From 4e82c4849eb5f7f5d89e7fa3ada878f3f1bcb897 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 15 May 2026 08:57:18 -0400 Subject: [PATCH 05/18] fix(timeline): replace blank flash on room open with skeleton placeholders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When opening a room, the RoomTimeline mounts with isReady=false while VList performs its initial scroll-settle (80 ms timer). Because the live timeline is already in SDK memory (eventsLength > 0), showLoadingPlaceholders was false, so the message area had opacity:0 — a hard invisible flash with no loading feedback. Fix: overlay three absolutely-positioned skeleton placeholder rows during the initial-scroll settle window (!isReady && !showLoadingPlaceholders). The overlay is pointerEvents:none so it doesn't block interaction, and sits at the bottom of the Box matching where the timeline will appear. Once setIsReady(true) fires the overlay disappears and the timeline fades in via a 100ms ease-in CSS transition on the message area container. --- src/app/features/room/RoomTimeline.tsx | 32 ++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 92f82061b..4123d3c76 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -896,6 +896,10 @@ export function RoomTimeline({ overflow: 'hidden', position: 'relative', opacity: isReady || showLoadingPlaceholders ? 1 : 0, + // Fade the timeline in once the initial scroll has settled so the + // reveal feels intentional rather than a hard flash. We never + // animate the transition TO opacity:0 so the hide is instant. + transition: isReady || showLoadingPlaceholders ? 'opacity 100ms ease-in' : 'none', }} > @@ -1011,6 +1015,34 @@ export function RoomTimeline({ {frontPaginationJSX} + {/* While the real VList is invisible (opacity:0) during the initial + scroll-settle window, show skeleton placeholders so the user sees + loading feedback instead of a blank area. */} + {!isReady && !showLoadingPlaceholders && ( +
+ + {messageLayout === MessageLayout.Compact ? : } + + + {messageLayout === MessageLayout.Compact ? : } + + + {messageLayout === MessageLayout.Compact ? : } + +
+ )} + {!atBottomState && isReady && ( Date: Fri, 15 May 2026 09:16:00 -0400 Subject: [PATCH 06/18] fix(timeline): stop infinite-loop hang on history jump, show jump button in history Three issues in the history-jump flow: 1. Infinite loop / page hang getEventTimeline() fires RoomEvent.TimelineRefresh as a side-effect. The old useLiveTimelineRefresh handler treated TimelineRefresh and TimelineReset identically, calling loadEventTimeline() on every refresh including the one triggered by our own getEventTimeline() call. With the loadIdRef guard this looped forever and never called onLoad. Fix: give useLiveTimelineRefresh a separate optional onReset callback. TimelineRefresh (triggered by us) calls onRefresh which returns early when eventId is set. TimelineReset (external sync gap) calls onReset, reloading only when the event is already displayed (eventsLength>0). 2. Jump-to-latest button missing in history view Button was gated on atBottomState being false. After scrollToIndex the focused event sits at centre; events below it can make atBottomState true, hiding the button. Now also shown when liveTimelineLinked is false so the button always appears when viewing history. 3. eventId useEffect improvements - Clear the stale timeline immediately via setTimeline(getEmptyTimeline()) so skeleton placeholders show during the API round-trip. - Re-arm hasInitialScrolledRef so the useLayoutEffect fallback path works if the jump fails and the live timeline is restored. Also adds the opacity fade-in transition (100ms ease-in) on the message list, smoothing the placeholder-to-content switch. Removes the earlier absolute-positioned overlay that was covering the scrollbar gutter. --- src/app/features/room/RoomTimeline.tsx | 12 ++++- src/app/hooks/timeline/useTimelineSync.ts | 65 +++++++++++++++++------ 2 files changed, 58 insertions(+), 19 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 92f82061b..c8331da2a 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -74,6 +74,7 @@ import { getEventTimeline, getFirstLinkedTimeline, getInitialTimeline, + getEmptyTimeline, getEventIdAbsoluteIndex, } from '$utils/timeline'; import { useTimelineSync } from '$hooks/timeline/useTimelineSync'; @@ -420,7 +421,13 @@ export function RoomTimeline({ useEffect(() => { if (!eventId) return; setIsReady(false); - void timelineSyncRef.current.loadEventTimeline(eventId); + // 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; + // Clear stale content immediately so loading placeholders are shown while + // the event-context API call is in flight rather than leaving a blank area. + timelineSyncRef.current.setTimeline(getEmptyTimeline()); + timelineSyncRef.current.loadEventTimeline(eventId); }, [eventId, room.roomId]); useEffect(() => { @@ -896,6 +903,7 @@ export function RoomTimeline({ overflow: 'hidden', position: 'relative', opacity: isReady || showLoadingPlaceholders ? 1 : 0, + transition: isReady || showLoadingPlaceholders ? 'opacity 100ms ease-in' : 'none', }} > @@ -1011,7 +1019,7 @@ export function RoomTimeline({ {frontPaginationJSX} - {!atBottomState && isReady && ( + {(!atBottomState || !timelineSync.liveTimelineLinked) && isReady && ( void, onError: (err: Error | null) => void -) => - useCallback( +) => { + // Monotonically-increasing counter so that only the most-recently-started + // loadEventTimeline call can commit its result. Concurrent calls (e.g. from + // rapid navigation) would otherwise both call setFocusItem({scrollTo:true}), + // causing a double scroll that lands on the wrong event. + const loadIdRef = useRef(0); + + return useCallback( async (eventId: string) => Sentry.startSpan({ name: 'timeline.jump_load', op: 'matrix.timeline' }, async () => { + const loadId = ++loadIdRef.current; const jumpLoadStart = performance.now(); - if (!room.getUnfilteredTimelineSet().getTimelineForEvent(eventId)) { - await withTimeout( - mx.roomInitialSync(room.roomId, PAGINATION_LIMIT), - EVENT_TIMELINE_LOAD_TIMEOUT_MS - ); - await withTimeout( - mx.getLatestTimeline(room.getUnfilteredTimelineSet()), - EVENT_TIMELINE_LOAD_TIMEOUT_MS - ); - } const [err, replyEvtTimeline] = await to( withTimeout( mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId), @@ -80,17 +77,21 @@ const useEventTimelineLoader = ( ) ); if (!replyEvtTimeline) { - onError(err ?? null); + if (loadId === loadIdRef.current) onError(err ?? null); return; } const linkedTimelines = getLinkedTimelines(replyEvtTimeline); const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId); if (absIndex === undefined) { - onError(err ?? null); + if (loadId === loadIdRef.current) onError(err ?? null); return; } + // A newer loadEventTimeline call is already in flight; discard this + // result so we do not clobber the more-recent load's scroll target. + if (loadId !== loadIdRef.current) return; + Sentry.metrics.distribution( 'sable.timeline.jump_load_ms', performance.now() - jumpLoadStart @@ -99,6 +100,7 @@ const useEventTimelineLoader = ( }), [mx, room, onLoad, onError] ); +}; const useTimelinePagination = ( mx: MatrixClient, @@ -306,17 +308,24 @@ const useRelationUpdate = (room: Room, onRelation: () => void) => { }, [room]); }; -const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => { +const useLiveTimelineRefresh = (room: Room, onRefresh: () => void, onReset?: () => void) => { const onRefreshRef = useRef(onRefresh); onRefreshRef.current = onRefresh; + const onResetRef = useRef(onReset); + onResetRef.current = onReset; useEffect(() => { + // TimelineRefresh fires when getEventTimeline() creates a new timeline + // context (e.g. for a history jump). This is triggered by our own call, + // so it has a separate handler from TimelineReset. const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r: Room) => { if (r.roomId !== room.roomId) return; onRefreshRef.current(); }; + // TimelineReset fires on an external sync gap and requires different + // handling: if we are viewing history we need to reload the event context. const handleTimelineReset: EventTimelineSetHandlerMap[RoomEvent.TimelineReset] = () => { - onRefreshRef.current(); + (onResetRef.current ?? onRefreshRef.current)(); }; const unfilteredTimelineSet = room.getUnfilteredTimelineSet(); @@ -522,14 +531,36 @@ export function useTimelineSync({ useLiveTimelineRefresh( room, + // TimelineRefresh fires when getEventTimeline() creates a new context — + // i.e. it was triggered by our own history load. If eventId is set we + // must NOT restart the load here: doing so would cause an infinite loop + // (getEventTimeline → TimelineRefresh → loadEventTimeline → getEventTimeline…). useCallback(() => { + if (eventId) return; + const wasAtBottom = isAtBottomRef.current; + resetAutoScrollPendingRef.current = wasAtBottom; + setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); + if (wasAtBottom) { + scrollToBottom('instant'); + } + }, [eventId, room, isAtBottomRef, scrollToBottom]), + // TimelineReset fires on an external sync gap. If we are viewing a + // history event and already have it loaded (eventsLength > 0), reload so + // the event stays visible after the gap. If eventsLength === 0 we are + // still loading — let the in-flight load complete instead of stacking + // another one on top. + useCallback(() => { + if (eventId) { + if (eventsLengthRef.current > 0) void loadEventTimeline(eventId); + return; + } const wasAtBottom = isAtBottomRef.current; resetAutoScrollPendingRef.current = wasAtBottom; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); if (wasAtBottom) { scrollToBottom('instant'); } - }, [room, isAtBottomRef, scrollToBottom]) + }, [eventId, eventsLengthRef, loadEventTimeline, room, isAtBottomRef, scrollToBottom]) ); useRelationUpdate( From c22dc40cfc0455ca58279765de6d1b53901c77d3 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 18 May 2026 10:38:36 -0400 Subject: [PATCH 07/18] docs: document timeline issues needing fixes Tracks Issues #3, #4, #7, #8 with root cause analysis and proposed fixes. Implementation will follow after proper investigation of each issue. --- docs/TIMELINE_FIXES.md | 58 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 docs/TIMELINE_FIXES.md diff --git a/docs/TIMELINE_FIXES.md b/docs/TIMELINE_FIXES.md new file mode 100644 index 000000000..ef6f926c8 --- /dev/null +++ b/docs/TIMELINE_FIXES.md @@ -0,0 +1,58 @@ +# Timeline Fixes Needed + +This document tracks timeline-related issues that need to be addressed in `feat/timeline`. + +## Issue #3: Multiple jumps when navigating to messages in history + +**Problem**: Jumping to specific messages, especially in history, jumps multiple times (probably as part of loading history), settles, then refreshes, and the message highlighting ends after that reload. + +**Root Cause**: +- Timeline pagination loading more messages causes the scroll position to recalculate +- Multiple render cycles as history loads +- Event highlighting is lost during re-renders + +**Proposed Fix**: +- Implement stable scroll anchoring during history pagination +- Preserve highlight state across re-renders +- Debounce scroll adjustments during history load + +## Issue #4: Visual reload when opening rooms + +**Problem**: Opening rooms results in a very obvious visual reload of the content. + +**Root Cause**: +- Timeline fully re-renders when switching rooms +- Initial render before data is ready causes flash + +**Proposed Fix**: +- Implement skeleton/loading state for timeline +- Preload timeline data before transition +- Use React.memo and stable keys to prevent unnecessary re-renders + +## Issue #7: DM list room icons reload every time you open the DM list + +**Problem**: In the DM list, the room icons reload every time you open the DM list - very jarring. + +**Root Cause**: +- Avatar URLs being recomputed on every render +- No caching of avatar blobs +- Component remounts instead of staying mounted + +**Proposed Fix**: +- Implement stable avatar URL memoization +- Keep DM list mounted but hidden when not visible +- Cache avatar data in blob cache + +## Issue #8: Timeline content doesn't load until interaction + +**Problem**: Sometimes content in the timeline doesn't load until interacting with it. + +**Root Cause**: +- Lazy loading not triggered properly +- Virtual scrolling viewport detection issues +- Timeline subscription not activating + +**Proposed Fix**: +- Review intersection observer setup +- Ensure timeline subscription activates on mount +- Add fallback eager loading for visible viewport From 124bb496c94d910cda08fbacac9b014f0188f8fb Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 14 May 2026 22:20:46 -0400 Subject: [PATCH 08/18] fix(timeline): restore scroll position when thread drawer opens/closes When the thread drawer opens/closes on desktop, the main timeline column changes width and Virtua remeasures all item heights. Without saving and restoring the scroll offset, the VList ends up at an unexpected position after the reflow. Restores scrollOffsetBeforeThreadRef and the useEffect that: - saves the current scrollOffset when openThreadId becomes truthy - restores it via double requestAnimationFrame when it becomes falsy (two RAFs let Virtua finish its resize cycle before the scroll) --- src/app/features/room/RoomTimeline.tsx | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 007e752ef..ceeda5567 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -213,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(undefined); + const vListRef = useRef(null); const [atBottomState, setAtBottomState] = useState(true); const atBottomRef = useRef(atBottomState); @@ -490,6 +496,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, From c2a7a0ca53d99b5e7c9c900a5e77dd49e01bfcff Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 14 May 2026 20:35:21 -0400 Subject: [PATCH 09/18] fix(thread-drawer): auto-size thread root, unclip hover actions Replace fixed-height Scroll (220 px) + SidebarResizer wrapping the thread root event with a plain Box that sizes to content. The overflow container was both wasting space on short messages and clipping the message-action toolbar that appears on hover. Also removes the now-dead threadRootHeight wiring from Themes.tsx and usePanelSizeItems (the setting key is kept in settings.ts for backwards-compat with stored user data). --- src/app/features/room/ThreadDrawer.tsx | 3 ++- src/app/hooks/usePanelSizes.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index 070cdaf58..1b7d725dd 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -752,6 +752,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra useEffect(() => { setCurHeight(threadRootHeight); }, [threadRootHeight]); + return ( diff --git a/src/app/hooks/usePanelSizes.ts b/src/app/hooks/usePanelSizes.ts index 3ff64d785..a2c1b21b9 100644 --- a/src/app/hooks/usePanelSizes.ts +++ b/src/app/hooks/usePanelSizes.ts @@ -22,7 +22,7 @@ export const usePanelSizeItems = (): PanelSizetItem[] => }, { layout: 'threadRootHeight', - name: 'Thread Root Height', + name: 'Thread Root Max Height', }, { layout: 'vcmsgSidebarWidth', From beac7bf46cc38275693653082f28b593d5337f34 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 15 May 2026 10:07:48 -0400 Subject: [PATCH 10/18] fix(timeline): route ArrowUp-to-edit through handleEditCallback in room and thread; remove room-open opacity transition --- src/app/features/room/RoomTimeline.tsx | 6 +----- src/app/features/room/ThreadDrawer.tsx | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index ceeda5567..29821f0ad 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -533,7 +533,7 @@ export function RoomTimeline({ setReplyDraft: setReplyDraft as unknown as (draft: unknown) => void, openThreadId, setOpenThread: setOpenThread as unknown as (threadId: string | undefined) => void, - handleEdit, + handleEdit: handleEditCallback, handleOpenEvent: (id) => { const anchorId = unwrapRelationJumpTarget(room, id); let evtTimeline = getEventTimeline(room, anchorId); @@ -927,10 +927,6 @@ export function RoomTimeline({ overflow: 'hidden', position: 'relative', opacity: isReady || showLoadingPlaceholders ? 1 : 0, - // Fade the timeline in once the initial scroll has settled so the - // reveal feels intentional rather than a hard flash. We never - // animate the transition TO opacity:0 so the hide is instant. - transition: isReady || showLoadingPlaceholders ? 'opacity 100ms ease-in' : 'none', }} > diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index 1b7d725dd..9b16d9719 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -618,7 +618,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra ); const ownId = ownReply?.id; if (ownId) { - handleEdit(ownId); + handleEditCallback(ownId); const el = drawerRef.current; if (el) { el.querySelector(`[data-message-id="${ownId}"]`)?.scrollIntoView({ @@ -627,7 +627,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra }); } } - }, [mx, threadRootId, handleEdit]); + }, [mx, threadRootId, handleEditCallback]); const handleResend = useCallback( (event: MatrixEvent) => { From 57cb433de04a58462065133a2ab83665e5969d55 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 15 May 2026 12:02:09 -0400 Subject: [PATCH 11/18] fix(timeline): fix loading spinner position, stop autopag loop, fix notification blank room - Move frontPaginationJSX inside VList as the last rendered item so the forward-pagination spinner appears centred at the bottom of the message list instead of outside the flex container (appeared at screen edge). - Add autopagAttemptsRef (cap: 20) to the auto-pagination fill effect so a sparse timeline that never reaches viewportSize+300px cannot loop forever. Reset the counter on every timeline clear (eventId jump or TimelineReset). - Merge setIsReady(true) into the focusItem scroll effect so scroll + reveal land in one commit; eliminates the intermediate frame where events are rendered but still opacity-0. - Add a fallback useEffect that sets isReady(true) when loadEventTimeline's onError callback has restored the live timeline (eventsLength > 0, liveTimelineLinked, no focusItem). Previously the room was stuck at opacity-0 indefinitely after a failed notification jump. - Update usePresenceAutoIdle test: replace mousemove with keydown for the basic activity-reset assertion (mousemove is intentionally filtered when document lacks focus on desktop) and add a dedicated test that asserts the focus-guard behaviour." --- src/app/features/room/RoomTimeline.tsx | 47 ++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 29821f0ad..7b8b42c05 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -297,6 +297,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); @@ -358,6 +362,7 @@ export function RoomTimeline({ if (timelineSync.eventsLength > 0) return; setIsReady(false); hasInitialScrolledRef.current = false; + autopagAttemptsRef.current = 0; }, [isReady, timelineSync.eventsLength]); const recalcTopSpacer = useCallback(() => { @@ -402,6 +407,10 @@ export function RoomTimeline({ useEffect(() => { let timeoutId: ReturnType | 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) { @@ -418,18 +427,14 @@ 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; // 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. @@ -437,6 +442,26 @@ export function RoomTimeline({ void timelineSyncRef.current.loadEventTimeline(eventId); }, [eventId, room.roomId]); + // Recovery: loadEventTimeline's onError callback restores the live timeline + // (setTimeline + scrollToBottom) but never calls setIsReady(true) — only + // focusItem does. Detect the "eventId load failed, fell back to live" state + // (eventsLength > 0, liveTimelineLinked, no focusItem) and reveal the timeline + // so the room is usable rather than stuck on opacity-0 until a restart. + useEffect(() => { + if (!eventId) return; + if (isReady) return; + if (timelineSync.eventsLength === 0) return; + if (timelineSync.focusItem) return; + if (!timelineSync.liveTimelineLinked) return; + setIsReady(true); + }, [ + 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 @@ -886,7 +911,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); + } } }; @@ -1011,6 +1039,8 @@ export function RoomTimeline({ ) : null; + const isLastItem = index === processedEvents.length - 1; + if (index === 0) { return ( @@ -1026,6 +1056,7 @@ export function RoomTimeline({ {backPaginationJSX} {dividers} {renderedEvent} + {isLastItem && frontPaginationJSX} ); } @@ -1034,6 +1065,7 @@ export function RoomTimeline({ {dividers} {renderedEvent} + {isLastItem && frontPaginationJSX} ); }} @@ -1086,6 +1118,7 @@ export function RoomTimeline({ )} + {(!atBottomState || !timelineSync.liveTimelineLinked) && isReady && ( Date: Fri, 15 May 2026 13:54:21 -0400 Subject: [PATCH 12/18] fix(timeline): scroll to bottom in error-recovery fallback before reveal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When loadEventTimeline fails, onError restores the live timeline via setTimeline and calls scrollToBottom('instant'). At that point the VList is still empty (processedEventsRef.current.length = 0), so scrollToBottom returns early and no scroll happens. The subsequent fallback useEffect then calls setIsReady(true) and the timeline is revealed at VList position 0 — the very start of history — instead of at the most-recent messages the user expected to see after tapping a notification. Fix: call scrollToIndex(last, { align: 'end' }) immediately before setIsReady(true) in the fallback effect, where processedEventsRef is already populated with the live timeline events. This ensures the room is revealed at the bottom rather than stranded at the top. --- src/app/features/room/RoomTimeline.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 7b8b42c05..c42723961 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -442,17 +442,23 @@ export function RoomTimeline({ void timelineSyncRef.current.loadEventTimeline(eventId); }, [eventId, room.roomId]); - // Recovery: loadEventTimeline's onError callback restores the live timeline - // (setTimeline + scrollToBottom) but never calls setIsReady(true) — only - // focusItem does. Detect the "eventId load failed, fell back to live" state - // (eventsLength > 0, liveTimelineLinked, no focusItem) and reveal the timeline - // so the room is usable rather than stuck on opacity-0 until a restart. + // 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; + // Scroll to the last rendered event before revealing so the VList isn't + // shown at position 0 (the start of history) when the user expected to see + // recent messages. + const lastIdx = processedEventsRef.current.length - 1; + if (lastIdx >= 0) vListRef.current?.scrollToIndex(lastIdx, { align: 'end' }); setIsReady(true); }, [ eventId, From 939e322654b41167d8dd287dfffeb51032941cc1 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 16 May 2026 10:52:59 -0400 Subject: [PATCH 13/18] fix(timeline): use 80ms double-scroll in error-recovery fallback before reveal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When loadEventTimeline fails and onError restores the live timeline, the previous fix called scrollToIndex once and immediately revealed the VList. Virtua had no measured item heights at that point (data just transitioned from 0 → N), so it used estimated heights of 0, placing the scroll at position 0 — the very top of history. Apply the same double-scroll pattern as the initial-scroll useLayoutEffect: 1. Call scrollToIndex immediately to kick off virtua's layout pass. 2. Wait 80 ms so item heights are measured. 3. Call scrollToIndex again (accurate position) + setIsReady(true). Also cancel any pending timer when a new eventId load starts (prevents a stale timer from revealing the timeline mid-flight of a new load), and add guards inside the timer callback to bail out if the state has changed. --- src/app/features/room/RoomTimeline.tsx | 32 ++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index c42723961..2d2bcf7e2 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -435,6 +435,12 @@ export function RoomTimeline({ 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. @@ -454,12 +460,30 @@ export function RoomTimeline({ if (timelineSync.eventsLength === 0) return; if (timelineSync.focusItem) return; if (!timelineSync.liveTimelineLinked) return; - // Scroll to the last rendered event before revealing so the VList isn't - // shown at position 0 (the start of history) when the user expected to see - // recent messages. + // 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' }); - setIsReady(true); + + 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, From 5365f80da4579e9569d0775cdce6179a4adcbb76 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 14:25:12 -0400 Subject: [PATCH 14/18] chore: add changeset --- .changeset/timeline.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/timeline.md diff --git a/.changeset/timeline.md b/.changeset/timeline.md new file mode 100644 index 000000000..88969644e --- /dev/null +++ b/.changeset/timeline.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix timeline scroll recovery, loading spinner position, autopag loop, blank notification room, and ArrowUp-to-edit routing. From 0f800bca18dbff56edaf4ae72acbcb224d71b048 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 18:21:02 -0400 Subject: [PATCH 15/18] fix(timeline): add isReadyRef, fix handleEdit refs, fix inline import() types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RoomTimeline.tsx: define isReadyRef to safely read isReady inside setTimeout closures; rename handleEditCallback → handleEdit (shorthand) - ThreadDrawer.tsx: rename handleEditCallback → handleEdit - ThreadBrowser.tsx: replace forbidden inline import() type annotations with top-level HTMLReactParserOptions and LinkifyOpts imports; html-react- parser v4 exports HTMLReactParserOptions (not Options) --- docs/TIMELINE_FIXES.md | 58 ------------------------- src/app/features/room/RoomTimeline.tsx | 23 +++++++--- src/app/features/room/ThreadBrowser.tsx | 6 ++- src/app/features/room/ThreadDrawer.tsx | 4 +- 4 files changed, 24 insertions(+), 67 deletions(-) delete mode 100644 docs/TIMELINE_FIXES.md diff --git a/docs/TIMELINE_FIXES.md b/docs/TIMELINE_FIXES.md deleted file mode 100644 index ef6f926c8..000000000 --- a/docs/TIMELINE_FIXES.md +++ /dev/null @@ -1,58 +0,0 @@ -# Timeline Fixes Needed - -This document tracks timeline-related issues that need to be addressed in `feat/timeline`. - -## Issue #3: Multiple jumps when navigating to messages in history - -**Problem**: Jumping to specific messages, especially in history, jumps multiple times (probably as part of loading history), settles, then refreshes, and the message highlighting ends after that reload. - -**Root Cause**: -- Timeline pagination loading more messages causes the scroll position to recalculate -- Multiple render cycles as history loads -- Event highlighting is lost during re-renders - -**Proposed Fix**: -- Implement stable scroll anchoring during history pagination -- Preserve highlight state across re-renders -- Debounce scroll adjustments during history load - -## Issue #4: Visual reload when opening rooms - -**Problem**: Opening rooms results in a very obvious visual reload of the content. - -**Root Cause**: -- Timeline fully re-renders when switching rooms -- Initial render before data is ready causes flash - -**Proposed Fix**: -- Implement skeleton/loading state for timeline -- Preload timeline data before transition -- Use React.memo and stable keys to prevent unnecessary re-renders - -## Issue #7: DM list room icons reload every time you open the DM list - -**Problem**: In the DM list, the room icons reload every time you open the DM list - very jarring. - -**Root Cause**: -- Avatar URLs being recomputed on every render -- No caching of avatar blobs -- Component remounts instead of staying mounted - -**Proposed Fix**: -- Implement stable avatar URL memoization -- Keep DM list mounted but hidden when not visible -- Cache avatar data in blob cache - -## Issue #8: Timeline content doesn't load until interaction - -**Problem**: Sometimes content in the timeline doesn't load until interacting with it. - -**Root Cause**: -- Lazy loading not triggered properly -- Virtual scrolling viewport detection issues -- Timeline subscription not activating - -**Proposed Fix**: -- Review intersection observer setup -- Ensure timeline subscription activates on mount -- Add fallback eager loading for visible viewport diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 2d2bcf7e2..c94638afe 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -244,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; @@ -588,7 +590,7 @@ export function RoomTimeline({ setReplyDraft: setReplyDraft as unknown as (draft: unknown) => void, openThreadId, setOpenThread: setOpenThread as unknown as (threadId: string | undefined) => void, - handleEdit: handleEditCallback, + handleEdit, handleOpenEvent: (id) => { const anchorId = unwrapRelationJumpTarget(room, id); let evtTimeline = getEventTimeline(room, anchorId); @@ -1137,18 +1139,29 @@ export function RoomTimeline({ }} > - {messageLayout === MessageLayout.Compact ? : } + {messageLayout === MessageLayout.Compact ? ( + + ) : ( + + )} - {messageLayout === MessageLayout.Compact ? : } + {messageLayout === MessageLayout.Compact ? ( + + ) : ( + + )} - {messageLayout === MessageLayout.Compact ? : } + {messageLayout === MessageLayout.Compact ? ( + + ) : ( + + )} )} - {(!atBottomState || !timelineSync.liveTimelineLinked) && isReady && ( ( + const linkifyOpts = useMemo( () => ({ ...LINKIFY_OPTS, render: factoryRenderLinkifyWithMention( @@ -93,7 +95,7 @@ function ThreadPreview({ room, thread, onClick, onJump }: ThreadPreviewProps) { [mx, room.roomId, nicknames, mentionClickHandler, settingsLinkBaseUrl] ); - const htmlReactParserOptions = useMemo( + const htmlReactParserOptions = useMemo( () => getReactCustomHtmlParser(mx, room.roomId, { settingsLinkBaseUrl, diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index 9b16d9719..1b7d725dd 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -618,7 +618,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra ); const ownId = ownReply?.id; if (ownId) { - handleEditCallback(ownId); + handleEdit(ownId); const el = drawerRef.current; if (el) { el.querySelector(`[data-message-id="${ownId}"]`)?.scrollIntoView({ @@ -627,7 +627,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra }); } } - }, [mx, threadRootId, handleEditCallback]); + }, [mx, threadRootId, handleEdit]); const handleResend = useCallback( (event: MatrixEvent) => { From 15d712b89c18c6dd8e64014da2ec84703134b189 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 20:08:10 -0400 Subject: [PATCH 16/18] fix(timeline): address Copilot review feedback - Remove duplicate frontPaginationJSX from inside VList item fragments (outer TimelineFloat is the canonical render position) - Merge frontPaginationJSX and Jump-to-Latest into a single bottom float so they no longer render at the same absolute bottom position; error retry takes priority, chip shown only when no error --- src/app/features/room/RoomTimeline.tsx | 42 +++++++++++--------------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index c94638afe..0def7934d 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -1088,7 +1088,6 @@ export function RoomTimeline({ {backPaginationJSX} {dividers} {renderedEvent} - {isLastItem && frontPaginationJSX} ); } @@ -1097,7 +1096,6 @@ export function RoomTimeline({ {dividers} {renderedEvent} - {isLastItem && frontPaginationJSX} ); }} @@ -1116,15 +1114,26 @@ export function RoomTimeline({ )} - {frontPaginationJSX && ( - + {(!atBottomState || !timelineSync.liveTimelineLinked) && isReady && ( + {frontPaginationJSX} + {!frontPaginationJSX && ( + } + onClick={() => { + if (eventId) navigateRoom(room.roomId, undefined, { replace: true }); + timelineSync.setTimeline(getInitialTimeline(room)); + scrollToBottom(); + }} + > + Jump to Latest + + )} )} - - {/* While the real VList is invisible (opacity:0) during the initial - scroll-settle window, show skeleton placeholders so the user sees - loading feedback instead of a blank area. */} {!isReady && !showLoadingPlaceholders && (
)} - {(!atBottomState || !timelineSync.liveTimelineLinked) && isReady && ( - - } - onClick={() => { - if (eventId) navigateRoom(room.roomId, undefined, { replace: true }); - timelineSync.setTimeline(getInitialTimeline(room)); - scrollToBottom(); - }} - > - Jump to Latest - - - )} ); } From e7abebf653831a1b4ac138537fb5d8786508bb55 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 20 May 2026 08:36:20 -0400 Subject: [PATCH 17/18] fix(timeline): suppress day divider for out-of-order bridged messages --- src/app/features/room/RoomTimeline.tsx | 2 -- src/app/hooks/timeline/useProcessedTimeline.ts | 9 ++++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 0def7934d..1a8b72d46 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -1071,8 +1071,6 @@ export function RoomTimeline({ ) : null; - const isLastItem = index === processedEvents.length - 1; - if (index === 0) { return ( diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index 9609dafc0..28b1d5154 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -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); From 6961cb4faad1a513dee16226b436863b08976605 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 20 May 2026 13:27:19 -0400 Subject: [PATCH 18/18] style: fix formatting --- src/app/features/room/RoomTimeline.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 1a8b72d46..2c9e8efd9 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -1168,7 +1168,6 @@ export function RoomTimeline({
)} -
); }