Improve timeline scroll, media layout, avatars, and emoji coverage.

Remember room scroll position across switches, reserve image/video heights from Matrix dimensions, cache authenticated media blobs for avatars, restore jumbo emoji-only messages, and extend emoji font unicode-range for Unicode 15 glyphs.
This commit is contained in:
2026-07-22 20:00:14 +10:00
parent 154f4dfdb0
commit c286501be8
17 changed files with 885 additions and 213 deletions

View File

@@ -103,6 +103,11 @@ import { useInertialHorizontalScroll } from '../../hooks/useInertialHorizontalSc
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import {
clearRoomScrollState,
getRoomScrollState,
saveRoomScrollState,
} from '../../state/roomScrollCache';
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
@@ -620,6 +625,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const location = useLocation();
const scrollToLatest = (location.state as { scrollToLatest?: boolean } | null)?.scrollToLatest;
const savedScrollRef = useRef(
eventId || scrollToLatest ? undefined : getRoomScrollState(room.roomId)
);
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
const [unreadInfo, setUnreadInfo] = useState(() =>
@@ -631,7 +640,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}
const atBottomAnchorRef = useRef<HTMLElement>(null);
const [atBottom, setAtBottom] = useState<boolean>(true);
const [atBottom, setAtBottom] = useState<boolean>(
() => savedScrollRef.current?.atBottom ?? true
);
const atBottomRef = useRef(atBottom);
atBottomRef.current = atBottom;
@@ -643,6 +654,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const scrollRef = useRef<HTMLDivElement>(null);
const timelineContentRef = useRef<HTMLDivElement>(null);
const timelineContentHeightRef = useRef(0);
const restoringScrollRef = useRef(false);
const scrollToBottomRef = useRef({
count: 0,
smooth: true,
@@ -680,9 +692,40 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
);
const parseMemberEvent = useMemberEventParser();
const [timeline, setTimeline] = useState<Timeline>(() =>
eventId ? getEmptyTimeline() : getInitialTimeline(room)
const [timeline, setTimeline] = useState<Timeline>(() => {
if (eventId) return getEmptyTimeline();
const initial = getInitialTimeline(room);
const saved = savedScrollRef.current;
if (!saved?.range) return initial;
const evLength = getTimelinesEventsCount(initial.linkedTimelines);
if (evLength === 0) return initial;
const start = Math.min(saved.range.start, Math.max(0, evLength - 1));
const end = Math.min(Math.max(start + 1, saved.range.end), evLength);
return { ...initial, range: { start, end } };
});
const timelineRef = useRef(timeline);
timelineRef.current = timeline;
const persistTimelineScroll = useCallback(
(roomId: string) => {
if (eventId) return;
const scrollEl = scrollRef.current;
if (!scrollEl) return;
const distanceFromBottom =
scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight;
saveRoomScrollState(roomId, {
top: scrollEl.scrollTop,
atBottom: distanceFromBottom <= 50,
range: timelineRef.current.range,
});
},
[eventId]
);
const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
const liveTimelineLinked =
timeline.linkedTimelines[timeline.linkedTimelines.length - 1] === getLiveTimeline(room);
@@ -861,6 +904,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const scrollElement = getScrollElement();
if (!editorBaseEntry || !scrollElement) return;
if (restoringScrollRef.current) return;
if (isScrolledToBottom(scrollElement)) {
autoScrollToBottom(scrollElement, false, true);
}
@@ -882,6 +927,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const previousHeight = timelineContentHeightRef.current;
timelineContentHeightRef.current = nextHeight;
if (restoringScrollRef.current) {
const saved = savedScrollRef.current;
if (saved) {
if (saved.atBottom) {
scrollElement.scrollTop = scrollElement.scrollHeight;
} else {
scrollElement.scrollTop = saved.top;
}
}
return;
}
if (previousHeight === 0 || nextHeight <= previousHeight || !isScrolledToBottom(scrollElement)) {
return;
}
@@ -994,6 +1051,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// Scroll to latest when clicking on already-selected room
useEffect(() => {
if (scrollToLatest) {
clearRoomScrollState(room.roomId);
setLatestUnreadMessage(null);
setUnreadInfo(undefined);
setTimeline(getInitialTimeline(room));
@@ -1003,12 +1061,78 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}
}, [scrollToLatest, room]);
// Scroll to bottom on initial timeline load
// Remember scroll position when leaving a room (switch DM, nav away, etc.).
useLayoutEffect(() => {
const roomId = room.roomId;
return () => {
persistTimelineScroll(roomId);
};
}, [room.roomId, persistTimelineScroll]);
// Keep scroll cache updated while reading (useEffect cleanup runs after DOM teardown).
useEffect(() => {
const scrollEl = scrollRef.current;
if (!scrollEl || eventId) return undefined;
const roomId = room.roomId;
let raf = 0;
const onScroll = () => {
if (restoringScrollRef.current) return;
window.cancelAnimationFrame(raf);
raf = window.requestAnimationFrame(() => persistTimelineScroll(roomId));
};
scrollEl.addEventListener('scroll', onScroll, { passive: true });
return () => {
window.cancelAnimationFrame(raf);
scrollEl.removeEventListener('scroll', onScroll);
};
}, [room.roomId, eventId, persistTimelineScroll]);
// Restore saved scroll or default to bottom on first open.
useLayoutEffect(() => {
const scrollEl = scrollRef.current;
if (scrollEl) {
if (!scrollEl) return undefined;
if (scrollToLatest) {
autoScrollToBottom(scrollEl, false, true);
return undefined;
}
const saved = savedScrollRef.current;
if (!saved) {
autoScrollToBottom(scrollEl, false, true);
return undefined;
}
restoringScrollRef.current = true;
setAtBottom(saved.atBottom);
const restore = () => {
if (saved.atBottom) {
scrollEl.scrollTop = scrollEl.scrollHeight;
} else {
scrollEl.scrollTop = saved.top;
}
};
restore();
const raf1 = window.requestAnimationFrame(() => {
restore();
window.requestAnimationFrame(() => {
restore();
window.setTimeout(() => {
restore();
window.setTimeout(() => {
restoringScrollRef.current = false;
}, 150);
}, 50);
});
});
return () => {
window.cancelAnimationFrame(raf1);
restoringScrollRef.current = false;
};
}, []);
// if live timeline is linked and unreadInfo change
@@ -1052,7 +1176,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// scroll to bottom of timeline
const scrollToBottomCount = scrollToBottomRef.current.count;
useLayoutEffect(() => {
if (scrollToBottomCount > 0) {
if (scrollToBottomCount > 0 && !restoringScrollRef.current) {
const scrollEl = scrollRef.current;
if (scrollEl) {
autoScrollToBottom(
@@ -1113,6 +1237,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
if (eventId) {
navigateRoom(room.roomId, undefined, { replace: true });
}
clearRoomScrollState(room.roomId);
setLatestUnreadMessage(null);
setTimeline(getInitialTimeline(room));
scrollToBottomRef.current.count += 1;
@@ -2291,7 +2416,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</Chip>
</TimelineFloat>
)}
<Scroll ref={scrollRef} visibility="Hover">
<Scroll ref={scrollRef} visibility="Hover" data-room-timeline-scroll="">
<Box
ref={timelineContentRef}
direction="Column"

View File

@@ -59,10 +59,14 @@ export const getImageMsgContent = async (
[MATRIX_SPOILER_PROPERTY_NAME]: metadata.markedAsSpoiler,
};
if (imgEl) {
const blurHash = encodeBlurHash(imgEl, 512, scaleYDimension(imgEl.width, 512, imgEl.height));
const naturalW = imgEl.naturalWidth || imgEl.width;
const naturalH = imgEl.naturalHeight || imgEl.height;
const blurHash = encodeBlurHash(imgEl, 512, scaleYDimension(naturalW, 512, naturalH));
content.info = {
...getImageInfo(imgEl, file),
w: naturalW,
h: naturalH,
[MATRIX_BLUR_HASH_PROPERTY_NAME]: blurHash,
};
}