Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
396 changes: 340 additions & 56 deletions src/app/features/room/RoomTimeline.test.tsx

Large diffs are not rendered by default.

201 changes: 118 additions & 83 deletions src/app/features/room/RoomTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
getFirstLinkedTimeline,
getInitialTimeline,
getEventIdAbsoluteIndex,
isNewestLiveEvent,
} from '$utils/timeline';
import { useTimelineSync } from '$hooks/timeline/useTimelineSync';
import { useTimelineActions } from '$hooks/timeline/useTimelineActions';
Expand Down Expand Up @@ -112,9 +113,8 @@ const getDayDividerText = (ts: number) => {

const focusItemAffectsEvent = (focusItem: unknown, eventData: ProcessedEvent | undefined) => {
if (!focusItem || typeof focusItem !== 'object' || !eventData) return false;
const index = 'index' in focusItem ? focusItem.index : undefined;
// itemIndex -1 marks a merged relation row, which is never a focus target.
return typeof index === 'number' && index >= 0 && index === eventData.itemIndex;
const focusEventId = 'eventId' in focusItem ? focusItem.eventId : undefined;
return typeof focusEventId === 'string' && focusEventId === eventData.id;
};

const eventIdAffectsEvent = (eventId: string | null | undefined, eventData?: ProcessedEvent) =>
Expand Down Expand Up @@ -304,7 +304,8 @@ const MemoizedTimelineItem = memo(
prev.eventData.eventSender === next.eventData.eventSender &&
prev.eventData.editId === next.eventData.editId &&
prev.eventData.reactionsKey === next.eventData.reactionsKey &&
prev.eventData.content === next.eventData.content
prev.eventData.content === next.eventData.content &&
prev.eventData.sendStatus === next.eventData.sendStatus
);
}
);
Expand Down Expand Up @@ -366,6 +367,8 @@ export function RoomTimeline({
const handleEdit = propsOnEditId ?? internalEdit.handleEdit;
const { navigateRoom } = useRoomNavigate();
const isInactivePanel = useIsInactivePanel();
const isInactivePanelRef = useRef(isInactivePanel);
isInactivePanelRef.current = isInactivePanel;

// Shared renderer context — replaces 17+ inline useSetting calls, linkifyOpts,
// htmlReactParserOptions, and the permissions block that were duplicated with
Expand Down Expand Up @@ -545,6 +548,11 @@ export function RoomTimeline({
},
[setAtBottom]
);
const handleJumpError = useCallback(() => setAtBottom(true), [setAtBottom]);
const handleReturnToLive = useCallback(() => {
if (eventId) navigateRoom(room.roomId, undefined, { replace: true });
setAtBottom(true);
}, [eventId, navigateRoom, room.roomId, setAtBottom]);

const timelineSync = useTimelineSync({
room,
Expand All @@ -557,8 +565,14 @@ export function RoomTimeline({
setUnreadInfo,
hideReadsRef,
readUptoEventIdRef,
isInactivePanelRef,
onJumpError: handleJumpError,
onReturnToLive: handleReturnToLive,
isEventVisible: useCallback(
(mEvent: MatrixEvent, timelineSet: EventTimelineSet) => {
const sender = mEvent.getSender();
if (sender && ignoredUsersSet.has(sender)) return false;

const type = mEvent.getType();
const isEdit = isEditEvent(mEvent);
const isReaction = isReactionEvent(mEvent);
Expand Down Expand Up @@ -639,12 +653,25 @@ export function RoomTimeline({

return true;
},
[hiddenEvents, hideMemberInReadOnly, isReadOnly, hideMembershipEvents, hideNickAvatarEvents]
[
hiddenEvents,
hideMemberInReadOnly,
isReadOnly,
hideMembershipEvents,
hideNickAvatarEvents,
ignoredUsersSet,
]
),
});

timelineSyncRef.current = timelineSync;

const previousPrependVersionRef = useRef(timelineSync.prependVersion);
const shiftForPrepend = previousPrependVersionRef.current !== timelineSync.prependVersion;
useLayoutEffect(() => {
previousPrependVersionRef.current = timelineSync.prependVersion;
}, [timelineSync.prependVersion]);

const eventsLengthRef = useRef(timelineSync.eventsLength);
eventsLengthRef.current = timelineSync.eventsLength;

Expand All @@ -661,12 +688,26 @@ export function RoomTimeline({
const forwardStatusRef = useRef(timelineSync.forwardStatus);
forwardStatusRef.current = timelineSync.forwardStatus;

const getRawIndexToProcessedIndex = useCallback((rawIndex: number): number | undefined => {
const events = processedEventsRef.current;
const match = events.find((e) => e.itemIndex === rawIndex);
if (!match) return undefined;
return events.indexOf(match);
}, []);
const resolveFocusRowIndex = useCallback(
(focusEventId: string): number | undefined => {
const events = processedEventsRef.current;
const rowIndex = events.findIndex((e) => e.id === focusEventId);
if (rowIndex >= 0) return rowIndex;

// Targets with no rendered row (thread replies, hidden membership/name
// events): land on the nearest visible row.
const evtTimeline = getEventTimeline(room, focusEventId);
if (!evtTimeline) return undefined;
const rawIndex = getEventIdAbsoluteIndex(
timelineSyncRef.current.timeline.linkedTimelines,
evtTimeline,
focusEventId
);
if (rawIndex === undefined) return undefined;
return getProcessedRowIndexForRawTimelineIndex(events, rawIndex)?.rowIndex;
},
[room]
);

useLayoutEffect(() => {
if (
Expand Down Expand Up @@ -772,50 +813,62 @@ export function RoomTimeline({
}, [timelineSync.backwardStatus, scrollToBottom]);

useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
if (timelineSync.focusItem) {
if (timelineSync.focusItem.scrollTo && vListRef.current) {
let processedIndex = getRawIndexToProcessedIndex(timelineSync.focusItem.index);
let focusRawIndex = timelineSync.focusItem.index;
if (processedIndex === undefined) {
// Jump targets with no rendered row (thread replies, hidden
// membership/name events): land on the nearest visible row.
const nearest = getProcessedRowIndexForRawTimelineIndex(
processedEventsRef.current,
timelineSync.focusItem.index
);
if (nearest) {
processedIndex = nearest.rowIndex;
focusRawIndex = nearest.focusRawIndex;
}
}
if (processedIndex !== undefined) {
vListRef.current.scrollToIndex(processedIndex, { align: 'center' });
timelineSync.setFocusItem((prev) =>
prev ? { ...prev, index: focusRawIndex, scrollTo: false } : undefined
);
}
if (!timelineSync.focusItem?.scrollTo || !vListRef.current) return;
const processedIndex = resolveFocusRowIndex(timelineSync.focusItem.eventId);
if (processedIndex === undefined) return;

const landedId = processedEventsRef.current[processedIndex]?.id;
// Being the last processed row is not enough: a partly loaded window makes any
// row the last one. Only the room's newest event is the live end.
const isLiveEnd =
landedId === timelineSync.focusItem.eventId &&
isNewestLiveEvent(room, timelineSync.focusItem.eventId) &&
getEventTimeline(room, timelineSync.focusItem.eventId) === room.getLiveTimeline() &&
timelineSync.liveTimelineLinked &&
processedIndex === processedEventsRef.current.length - 1;
if (isLiveEnd) {
setAtBottom(true);
scrollToBottom();
if (eventId === timelineSync.focusItem.eventId) {
navigateRoom(room.roomId, undefined, { replace: true });
}
timeoutId = setTimeout(() => {
timelineSync.setFocusItem(undefined);
}, 2000);
} else {
vListRef.current.scrollToIndex(processedIndex, { align: 'center' });
}
return () => {
if (timeoutId !== undefined) clearTimeout(timeoutId);
};
}, [timelineSync.focusItem, timelineSync, reducedMotion, getRawIndexToProcessedIndex]);
timelineSyncRef.current.setFocusItem((prev) =>
prev ? { ...prev, eventId: landedId ?? prev.eventId, scrollTo: false } : undefined
);
}, [
timelineSync.focusItem,
timelineSync.eventsLength,
timelineSync.liveTimelineLinked,
eventId,
navigateRoom,
resolveFocusRowIndex,
room,
scrollToBottom,
setAtBottom,
]);

useEffect(() => {
if (!timelineSync.focusItem) return undefined;
const timeoutId = setTimeout(() => {
timelineSyncRef.current.setFocusItem(undefined);
}, 2000);
return () => clearTimeout(timeoutId);
}, [timelineSync.focusItem]);

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

useEffect(() => {
if (!eventId) return;
setIsReady(false);
if (!timelineSyncRef.current.jumpFailed) setIsReady(false);
jumpToEvent(eventId);
}, [eventId, room.roomId, jumpToEvent]);
}, [eventId, room, jumpToEvent]);

useEffect(() => {
if (eventId) return;
Expand All @@ -836,7 +889,12 @@ export function RoomTimeline({
: undefined;

if (absoluteIndex !== undefined) {
const processedIndex = getRawIndexToProcessedIndex(absoluteIndex);
const rows = processedEventsRef.current;
const exactRow = rows.findIndex((e) => e.id === readUptoEventId);
const processedIndex =
exactRow >= 0
? exactRow
: getProcessedRowIndexForRawTimelineIndex(rows, absoluteIndex)?.rowIndex;
if (processedIndex !== undefined && vListRef.current) {
vListRef.current.scrollToIndex(processedIndex, { align: 'start' });
}
Expand All @@ -847,14 +905,7 @@ export function RoomTimeline({
setUnreadInfo((prev) => (prev ? { ...prev, scrollTo: false } : prev));
}
}
}, [
room,
unreadInfo,
timelineSync.timeline.linkedTimelines,
eventId,
isReady,
getRawIndexToProcessedIndex,
]);
}, [room, unreadInfo, timelineSync.timeline.linkedTimelines, eventId, isReady]);

useEffect(() => {
const el = messageListRef.current;
Expand Down Expand Up @@ -937,37 +988,20 @@ export function RoomTimeline({
handleEdit,
handleOpenEvent: (id) => {
const anchorId = unwrapRelationJumpTarget(room, id);
let evtTimeline = getEventTimeline(room, anchorId);
let resolvedForIndex = anchorId;
if (!evtTimeline && anchorId !== id) {
evtTimeline = getEventTimeline(room, id);
resolvedForIndex = id;
let resolvedId = anchorId;
let processedIndex = resolveFocusRowIndex(anchorId);
if (processedIndex === undefined && anchorId !== id) {
resolvedId = id;
processedIndex = resolveFocusRowIndex(id);
}
const absoluteIndex = evtTimeline
? getEventIdAbsoluteIndex(
timelineSync.timeline.linkedTimelines,
evtTimeline,
resolvedForIndex
)
: undefined;

if (typeof absoluteIndex === 'number') {
let processedIndex = getRawIndexToProcessedIndex(absoluteIndex);
let focusRawIndex = absoluteIndex;
if (processedIndex === undefined) {
const nearest = getProcessedRowIndexForRawTimelineIndex(
processedEventsRef.current,
absoluteIndex
);
if (nearest) {
processedIndex = nearest.rowIndex;
focusRawIndex = nearest.focusRawIndex;
}
}
if (vListRef.current && processedIndex !== undefined) {
if (processedIndex !== undefined) {
timelineSync.cancelEventTimelineLoad();
if (vListRef.current) {
vListRef.current.scrollToIndex(processedIndex, { align: 'center' });
}
timelineSync.setFocusItem({ index: focusRawIndex, scrollTo: false, highlight: true });
const landedId = processedEventsRef.current[processedIndex]?.id ?? resolvedId;
timelineSync.setFocusItem({ eventId: landedId, scrollTo: false, highlight: true });
} else {
jumpToEvent(anchorId);
}
Expand Down Expand Up @@ -1040,7 +1074,7 @@ export function RoomTimeline({
useCallback(
(inFocus) => {
if (inFocus) {
if (atBottomState) tryAutoMarkAsRead();
if (atBottomState && timelineSync.liveTimelineLinked) tryAutoMarkAsRead();
return;
}
// Re-anchor the divider at the last read when tabbing out while caught up.
Expand Down Expand Up @@ -1330,7 +1364,7 @@ export function RoomTimeline({
<VList<ProcessedEvent>
ref={vListRef}
data={processedEvents}
shift={shift}
shift={shift || shiftForPrepend}
className={css.messageList}
style={{
flex: 1,
Expand Down Expand Up @@ -1404,6 +1438,7 @@ export function RoomTimeline({
outlined
before={chipIcon(ArrowDown)}
onClick={() => {
timelineSync.cancelEventTimelineLoad();
if (eventId) navigateRoom(room.roomId, undefined, { replace: true });
timelineSync.setTimeline(getInitialTimeline(room));
setAtBottom(true);
Expand Down
14 changes: 4 additions & 10 deletions src/app/features/room/ThreadDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra
?.map((r) => `${r[0]}:${r[1].size}`)
.join(',') ?? '',
content: ev.getContent(),
sendStatus: ev.getAssociatedStatus(),
}));
// forceUpdateCounter makes this recompute whenever events arrive
}, [room, threadRootId, thread, processedEvents, forceUpdateCounter]);
Expand Down Expand Up @@ -534,16 +535,9 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra
}
}, [mx, threadRootId, handleEdit]);

// Map jumpToEventId to a focusItem index for useTimelineEventRenderer highlighting
const jumpIndex = jumpToEventId ? processedEvents.findIndex((e) => e.id === jumpToEventId) : -1;
const focusItem =
jumpIndex >= 0 && processedEvents[jumpIndex]
? {
index: processedEvents[jumpIndex].itemIndex,
highlight: true,
scrollTo: false as const,
}
: undefined;
const focusItem = jumpToEventId
? { eventId: jumpToEventId, highlight: true, scrollTo: false as const }
: undefined;

const renderMatrixEvent = useTimelineEventRenderer({
room,
Expand Down
Loading
Loading