diff --git a/src/app/features/room/RoomTimeline.css.ts b/src/app/features/room/RoomTimeline.css.ts index 29f18f3a..fd60630f 100644 --- a/src/app/features/room/RoomTimeline.css.ts +++ b/src/app/features/room/RoomTimeline.css.ts @@ -32,14 +32,26 @@ export type TimelineFloatVariants = RecipeVariants; export const messageList = style({ display: 'flex', - flexDirection: 'column', + flexDirection: 'column-reverse', width: '100%', + overflowAnchor: 'none', +}); + +globalStyle(`body ${messageList} > *`, { + overflowAnchor: 'auto', }); globalStyle(`body ${messageList} [data-message-id]`, { transition: 'background-color 0.1s ease-in-out !important', + position: 'relative', + zIndex: 1, }); globalStyle(`body ${messageList} [data-message-id]:hover`, { backgroundColor: 'var(--sable-surface-container-hover) !important', + zIndex: 2, +}); + +globalStyle(`body ${messageList} [data-message-id]:focus-within`, { + zIndex: 10, }); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 668b33f7..dadd2f24 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -345,9 +345,10 @@ const useTimelinePagination = ( const [backwardStatus, setBackwardStatus] = useState('idle'); const [forwardStatus, setForwardStatus] = useState('idle'); - const paginate = useMemo(() => { - let fetching = false; + // Strict lock so timeline no do shift shift + const fetchingRef = useRef({ backward: false, forward: false }); + const paginate = useMemo(() => { const recalibratePagination = ( linkedTimelines: EventTimeline[], timelinesEventsCount: number[], @@ -377,7 +378,11 @@ const useTimelinePagination = ( }; return async (backwards: boolean) => { - if (fetching) return; + const directionKey = backwards ? 'backward' : 'forward'; + + // Enforce the lock + if (fetchingRef.current[directionKey]) return; + const { linkedTimelines: lTimelines } = timelineRef.current; const timelinesEventsCount = lTimelines.map(timelineToEventsCount); @@ -396,7 +401,8 @@ const useTimelinePagination = ( return; } - fetching = true; + // Engage the lock + fetchingRef.current[directionKey] = true; if (alive()) { (backwards ? setBackwardStatus : setForwardStatus)('loading'); } @@ -430,7 +436,8 @@ const useTimelinePagination = ( (backwards ? setBackwardStatus : setForwardStatus)('idle'); } } finally { - fetching = false; + // Release the lock + fetchingRef.current[directionKey] = false; } }; }, [mx, alive, setTimeline, limit, setBackwardStatus, setForwardStatus]); @@ -620,6 +627,12 @@ export function RoomTimeline({ readUptoEventIdRef.current = unreadInfo.readUptoEventId; } + const hideReadsRef = useRef(hideReads); + hideReadsRef.current = hideReads; + + const unreadInfoRef = useRef(unreadInfo); + unreadInfoRef.current = unreadInfo; + const atBottomAnchorRef = useRef(null); const [atBottom, setAtBottomState] = useState(true); @@ -772,14 +785,15 @@ export function RoomTimeline({ // otherwise we update timeline without paginating // so timeline can be updated with evt like: edits, reactions etc if (atBottomRef.current && atLiveEndRef.current) { - if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) { - // Check if the document is in focus (user is actively viewing the app), - // and either there are no unread messages or the latest message is from the current user. - // If either condition is met, trigger the markAsRead function to send a read receipt. - requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!, hideReads)); + if ( + document.hasFocus() && + (!unreadInfoRef.current || mEvt.getSender() === mx.getUserId()) + ) { + // Check if the document is in focus and trigger markAsRead + requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!, hideReadsRef.current)); } - if (!document.hasFocus() && !unreadInfo) { + if (!document.hasFocus() && !unreadInfoRef.current) { setUnreadInfo(getRoomUnreadInfo(room)); } @@ -798,11 +812,11 @@ export function RoomTimeline({ return; } setTimeline((ct) => ({ ...ct })); - if (!unreadInfo) { + if (!unreadInfoRef.current) { setUnreadInfo(getRoomUnreadInfo(room)); } }, - [mx, room, unreadInfo, hideReads] + [mx, room, setUnreadInfo] ) ); @@ -980,7 +994,7 @@ export function RoomTimeline({ useCallback( () => ({ root: getScrollElement(), - rootMargin: '100px', + rootMargin: '150px 0px 150px 0px', }), [getScrollElement] ), @@ -1095,13 +1109,15 @@ export function RoomTimeline({ }); } - setTimeout(() => { + const timeoutId = setTimeout(() => { if (!alive()) return; setFocusItem((currentItem) => { if (currentItem === focusItem) return undefined; return currentItem; }); }, 2000); + + return () => clearTimeout(timeoutId); }, [alive, focusItem, scrollToItem]); // scroll to bottom of timeline @@ -2039,101 +2055,74 @@ export function RoomTimeline({ } ); - let prevEvent: MatrixEvent | undefined; - let isPrevRendered = false; - let newDivider = false; - let dayDivider = false; - const timelineItems = getItems(); - const eventRenderer = (item: number) => { - const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item); - if (!eventTimeline) return null; - const timelineSet = eventTimeline?.getTimelineSet(); - const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex)); - const mEventId = mEvent?.getId(); - - if (!mEvent || !mEventId) return null; - - const eventSender = mEvent.getSender(); - if (eventSender && ignoredUsersSet.has(eventSender)) { - return null; - } - if (mEvent.isRedacted() && !showHiddenEvents) { - return null; - } + const processedEvents = useMemo(() => { + const items = getItems(); + let prevEvent: MatrixEvent | undefined; + let isPrevRendered = false; + let newDivider = false; + let dayDivider = false; - if (!newDivider && readUptoEventIdRef.current) { - newDivider = prevEvent?.getId() === readUptoEventIdRef.current; - } - if (!dayDivider) { - dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false; - } + const chronologicallyProcessed = items + .map((item) => { + const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item); + if (!eventTimeline) return null; - const collapsed = - isPrevRendered && - !dayDivider && - (!newDivider || eventSender === mx.getUserId()) && - prevEvent !== undefined && - prevEvent.getSender() === eventSender && - prevEvent.getType() === mEvent.getType() && - minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2; - - const eventJSX = reactionOrEditEvent(mEvent) - ? null - : renderMatrixEvent( - mEvent.getType(), - typeof mEvent.getStateKey() === 'string', - mEventId, - mEvent, - item, - timelineSet, - collapsed - ); - prevEvent = mEvent; - isPrevRendered = !!eventJSX; - - const newDividerJSX = - newDivider && eventJSX && eventSender !== mx.getUserId() ? ( - - - - New Messages - - - - ) : null; - - const dayDividerJSX = - dayDivider && eventJSX ? ( - - - - - {(() => { - if (today(mEvent.getTs())) return 'Today'; - if (yesterday(mEvent.getTs())) return 'Yesterday'; - return timeDayMonthYear(mEvent.getTs()); - })()} - - - - - ) : null; - - if (eventJSX && (newDividerJSX || dayDividerJSX)) { - if (newDividerJSX) newDivider = false; - if (dayDividerJSX) dayDivider = false; + const timelineSet = eventTimeline.getTimelineSet(); + const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex)); + const mEventId = mEvent?.getId(); - return ( - - {newDividerJSX} - {dayDividerJSX} - {eventJSX} - - ); - } + if (!mEvent || !mEventId) return null; - return eventJSX; - }; + const eventSender = mEvent.getSender(); + if (eventSender && ignoredUsersSet.has(eventSender)) return null; + if (mEvent.isRedacted() && !showHiddenEvents) return null; + + if (!newDivider && readUptoEventIdRef.current) { + newDivider = prevEvent?.getId() === readUptoEventIdRef.current; + } + if (!dayDivider) { + dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false; + } + + const isReactionOrEdit = reactionOrEditEvent(mEvent); + const willBeRendered = !isReactionOrEdit; + + const collapsed = + isPrevRendered && + !dayDivider && + (!newDivider || eventSender === mx.getUserId()) && + prevEvent !== undefined && + prevEvent.getSender() === eventSender && + prevEvent.getType() === mEvent.getType() && + minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2; + + const willRenderNewDivider = newDivider && willBeRendered && eventSender !== mx.getUserId(); + const willRenderDayDivider = dayDivider && willBeRendered; + + prevEvent = mEvent; + isPrevRendered = willBeRendered; + + if (willRenderNewDivider) newDivider = false; + if (willRenderDayDivider) dayDivider = false; + + if (!willBeRendered) return null; + + return { + id: mEventId, + itemIndex: item, + mEvent, + timelineSet, + eventSender, + collapsed, + willRenderNewDivider, + willRenderDayDivider, + }; + }) + .filter((e): e is NonNullable => e !== null); + + // Reverse for column-reverse rendering + return chronologicallyProcessed.reverse(); + }, [timeline.linkedTimelines, getItems, ignoredUsersSet, showHiddenEvents, mx]); let backPaginationJSX: ReactNode | undefined; if (canPaginateBack || !rangeAtStart || backwardStatus !== 'idle') { @@ -2158,24 +2147,7 @@ export function RoomTimeline({ ); - } else if (backwardStatus === 'loading' && timelineItems.length > 0) { - backPaginationJSX = ( - - - - ); - } else if (timelineItems.length === 0) { - // When eventsLength===0 AND liveTimelineLinked the live EventTimeline was - // just reset by a sliding sync TimelineRefresh and new events haven't - // arrived yet. Attaching the IntersectionObserver anchor here would - // immediately fire a server-side /messages request before current events - // land — potentially causing a "/messages hangs → spinner stuck" scenario. - // Suppressing the anchor for this transient state is safe: the rangeAtEnd - // self-heal useEffect will call getInitialTimeline once events arrive, and - // at that point the correct anchor (below) will be re-observed. - // eventsLength>0 covers the range={K,K} case from recalibratePagination - // where items=0 but events exist — that needs the anchor for local range - // extension (no server call since start>0). + } else if (getItems().length === 0) { const placeholderBackAnchor = eventsLength > 0 || !liveTimelineLinked ? observeBackAnchor : undefined; backPaginationJSX = @@ -2211,7 +2183,25 @@ export function RoomTimeline({ ); } else { - backPaginationJSX =
; + backPaginationJSX = ( + +
+ {backwardStatus === 'loading' && ( + + + + )} + + ); } } @@ -2238,13 +2228,7 @@ export function RoomTimeline({ ); - } else if (forwardStatus === 'loading' && timelineItems.length > 0) { - frontPaginationJSX = ( - - - - ); - } else if (timelineItems.length === 0) { + } else if (getItems().length === 0) { frontPaginationJSX = messageLayout === MessageLayout.Compact ? ( <> @@ -2278,7 +2262,25 @@ export function RoomTimeline({ ); } else { - frontPaginationJSX =
; + frontPaginationJSX = ( + +
+ {forwardStatus === 'loading' && ( + + + + )} + + ); } } @@ -2310,11 +2312,70 @@ export function RoomTimeline({ - {!canPaginateBack && rangeAtStart && getItems().length > 0 && ( + + {frontPaginationJSX} + + {processedEvents.map((eventData) => { + const { + id, + itemIndex, + mEvent, + timelineSet, + willRenderNewDivider, + willRenderDayDivider, + collapsed, + } = eventData; + + const eventJSX = renderMatrixEvent( + mEvent.getType(), + typeof mEvent.getStateKey() === 'string', + id, + mEvent, + itemIndex, + timelineSet, + collapsed + ); + + const newDividerJSX = willRenderNewDivider ? ( + + + + New Messages + + + + ) : null; + + const dayDividerJSX = willRenderDayDivider ? ( + + + + + {(() => { + if (today(mEvent.getTs())) return 'Today'; + if (yesterday(mEvent.getTs())) return 'Yesterday'; + return timeDayMonthYear(mEvent.getTs()); + })()} + + + + + ) : null; + + return ( + + {eventJSX} + {dayDividerJSX} + {newDividerJSX} + + ); + })} + + {backPaginationJSX} + + {!canPaginateBack && rangeAtStart && processedEvents.length > 0 && (
)} - {backPaginationJSX} - - {timelineItems.map(eventRenderer)} - - {frontPaginationJSX} -
{(!atBottom || !(liveTimelineLinked && rangeAtEnd)) && (