From 15e92411d3bd76c193467ae9cc21ebb1f20b827d Mon Sep 17 00:00:00 2001 From: Jade Ellis Date: Thu, 2 Jan 2025 02:47:53 +0000 Subject: [PATCH] web/timeline: use tanstack virtual to virtualise timeline --- web/package-lock.json | 28 ++++ web/package.json | 1 + web/src/api/client.ts | 3 +- web/src/ui/timeline/TimelineEvent.tsx | 18 +-- web/src/ui/timeline/TimelineView.css | 5 +- web/src/ui/timeline/TimelineView.tsx | 178 ++++++++++++++++++-------- 6 files changed, 164 insertions(+), 69 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index b322427..693731a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "AGPL-3.0-or-later", "dependencies": { + "@tanstack/react-virtual": "^3.11.2", "@wailsio/runtime": "^3.0.0-alpha.29", "blurhash": "^2.0.5", "katex": "^0.16.11", @@ -1754,6 +1755,33 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.11.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.11.2.tgz", + "integrity": "sha512-OuFzMXPF4+xZgx8UzJha0AieuMihhhaWG0tCqpp6tDzlFwOmNBPYMuLOtMJ1Tr4pXLHmgjcWhG6RlknY2oNTdQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.11.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.11.2", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.11.2.tgz", + "integrity": "sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", diff --git a/web/package.json b/web/package.json index 0d135f1..6776649 100644 --- a/web/package.json +++ b/web/package.json @@ -11,6 +11,7 @@ "preview": "vite preview" }, "dependencies": { + "@tanstack/react-virtual": "^3.11.2", "@wailsio/runtime": "^3.0.0-alpha.29", "blurhash": "^2.0.5", "katex": "^0.16.11", diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 89e78a0..5c9a6ea 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -305,7 +305,7 @@ export default class Client { } } - async loadMoreHistory(roomID: RoomID): Promise { + async loadMoreHistory(roomID: RoomID): Promise { const room = this.store.rooms.get(roomID) if (!room) { throw new Error("Room not found") @@ -324,6 +324,7 @@ export default class Client { } room.hasMoreHistory = resp.has_more room.applyPagination(resp.events, resp.related_events, resp.receipts) + return resp.events.length } finally { room.paginating = false } diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index 524d84b..17d5108 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.tsx @@ -39,7 +39,9 @@ export interface TimelineEventProps { prevEvt: MemDBEvent | null disableMenu?: boolean smallReplies?: boolean - isFocused?: boolean + isFocused?: boolean, + virtualIndex?: number, + ref?: React.Ref } const fullTimeFormatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "medium" }) @@ -75,7 +77,7 @@ const EventSendStatus = ({ evt }: { evt: MemDBEvent }) => { } } -const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: TimelineEventProps) => { +const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused, virtualIndex, ref }: TimelineEventProps) => { const roomCtx = useRoomContext() const client = use(ClientContext)! const mainScreen = use(MainScreenContext) @@ -145,9 +147,10 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T eventTS.getDate() !== prevEvtDate.getDate() || eventTS.getMonth() !== prevEvtDate.getMonth() || eventTS.getFullYear() !== prevEvtDate.getFullYear())) { - dateSeparator =
+ const dateLabel = dateFormatter.format(eventTS) + dateSeparator =

- {dateFormatter.format(eventTS)} + {dateLabel}
} @@ -196,6 +199,8 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T className={wrapperClassNames.join(" ")} onContextMenu={onContextMenu} onClick={!disableMenu && isMobileDevice ? onClick : undefined} + data-index={virtualIndex} + ref={ref} > {!disableMenu && !isMobileDevice &&
} {evt.sender === client.userID && evt.transaction_id ? : null}
- return <> - {dateSeparator} - {mainEvent} - + return mainEvent } export default React.memo(TimelineEvent) diff --git a/web/src/ui/timeline/TimelineView.css b/web/src/ui/timeline/TimelineView.css index 79a575d..59415cf 100644 --- a/web/src/ui/timeline/TimelineView.css +++ b/web/src/ui/timeline/TimelineView.css @@ -1,9 +1,6 @@ div.timeline-view { overflow-y: scroll; - - display: flex; - flex-direction: column; - justify-content: space-between; + contain: strict; > div.timeline-beginning { display: flex; diff --git a/web/src/ui/timeline/TimelineView.tsx b/web/src/ui/timeline/TimelineView.tsx index 2148258..6158739 100644 --- a/web/src/ui/timeline/TimelineView.tsx +++ b/web/src/ui/timeline/TimelineView.tsx @@ -13,7 +13,8 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { use, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react" +import { Virtualizer, useVirtualizer } from "@tanstack/react-virtual" +import { use, useEffect, useLayoutEffect, useRef, useState } from "react" import { ScaleLoader } from "react-spinners" import { usePreference, useRoomTimeline } from "@/api/statestore" import { EventRowID, MemDBEvent } from "@/api/types" @@ -21,8 +22,28 @@ import useFocus from "@/util/focus.ts" import ClientContext from "../ClientContext.ts" import { useRoomContext } from "../roomview/roomcontext.ts" import TimelineEvent from "./TimelineEvent.tsx" +import { getBodyType, isSmallEvent } from "./content/index.ts" import "./TimelineView.css" +const measureElement = (element: Element, entry: ResizeObserverEntry | undefined, instance: Virtualizer) => { + const horizontal = instance.options.horizontal + const style = window.getComputedStyle(element) + if (entry == null ? void 0 : entry.borderBoxSize) { + const box = entry?.borderBoxSize[0] + if (box) { + const size = Math.round( + box[horizontal ? "inlineSize" : "blockSize"], + ) + return size + parseFloat(style[horizontal ? "marginInlineStart" : "marginBlockStart"]) + parseFloat(style[horizontal ? "marginInlineEnd" : "marginBlockEnd"]) + } + } + return Math.round( + element.getBoundingClientRect()[instance.options.horizontal ? "width" : "height"] + parseFloat(style[horizontal ? "marginLeft" : "marginTop"]) + parseFloat(style[horizontal ? "marginRight" : "marginBottom"]), + ) +} + +const estimateEventHeight = (event: MemDBEvent) => isSmallEvent(getBodyType(event)) ? (event?.reactions ? 26 : 0) + (event?.content.body ? (event?.local_content?.big_emoji ? 92 : 44) : 0) + (event?.content.info?.h || 0) : 26 + const TimelineView = () => { const roomCtx = useRoomContext() const room = roomCtx.store @@ -30,21 +51,51 @@ const TimelineView = () => { const client = use(ClientContext)! const [isLoadingHistory, setLoadingHistory] = useState(false) const [focusedEventRowID, directSetFocusedEventRowID] = useState(null) - const loadHistory = useCallback(() => { + const loadHistory = () => { setLoadingHistory(true) client.loadMoreHistory(room.roomID) .catch(err => console.error("Failed to load history", err)) - .finally(() => setLoadingHistory(false)) - }, [client, room]) + .then((loadedEventCount) => { + // Prevent scroll getting stuck loading more history + if (loadedEventCount && timelineViewRef.current && timelineViewRef.current.scrollTop <= virtualListOffsetRef.current) { + virtualizer.scrollToIndex(loadedEventCount, { align: "end" }) + } + }) + .finally(() => { + setLoadingHistory(false) + }) + } const bottomRef = roomCtx.timelineBottomRef - const topRef = useRef(null) const timelineViewRef = useRef(null) - const prevOldestTimelineRow = useRef(0) - const oldestTimelineRow = timeline[0]?.timeline_rowid - const oldScrollHeight = useRef(0) const focused = useFocus() const smallReplies = usePreference(client.store, room, "small_replies") + const virtualListRef = useRef(null) + + const virtualListOffsetRef = useRef(0) + + useLayoutEffect(() => { + virtualListOffsetRef.current = virtualListRef.current?.offsetTop ?? 0 + }, []) + + const virtualizer = useVirtualizer({ + count: timeline.length, + getScrollElement: () => timelineViewRef.current, + estimateSize: (index) => timeline[index] ? estimateEventHeight(timeline[index]) : 0, + getItemKey: (index) => timeline[index]?.rowid || index, + overscan: 6, + measureElement, + }) + + const items = virtualizer.getVirtualItems() + + useLayoutEffect(() => { + if (roomCtx.scrolledToBottom) { + // timelineViewRef.current && (timelineViewRef.current.scrollTop = timelineViewRef.current.scrollHeight) + bottomRef.current?.scrollIntoView() + } + }, [roomCtx, timeline, virtualizer.getTotalSize()]) + // When the user scrolls the timeline manually, remember if they were at the bottom, // so that we can keep them at the bottom when new events are added. const handleScroll = () => { @@ -54,24 +105,11 @@ const TimelineView = () => { const timelineView = timelineViewRef.current roomCtx.scrolledToBottom = timelineView.scrollTop + timelineView.clientHeight + 1 >= timelineView.scrollHeight } - // Save the scroll height prior to updating the timeline, so that we can adjust the scroll position if needed. - if (timelineViewRef.current) { - oldScrollHeight.current = timelineViewRef.current.scrollHeight - } - useLayoutEffect(() => { - const bottomRef = roomCtx.timelineBottomRef - if (bottomRef.current && roomCtx.scrolledToBottom) { - // For any timeline changes, if we were at the bottom, scroll to the new bottom - bottomRef.current.scrollIntoView() - } else if (timelineViewRef.current && prevOldestTimelineRow.current > (timeline[0]?.timeline_rowid ?? 0)) { - // When new entries are added to the top of the timeline, scroll down to keep the same position - timelineViewRef.current.scrollTop += timelineViewRef.current.scrollHeight - oldScrollHeight.current - } - prevOldestTimelineRow.current = timeline[0]?.timeline_rowid ?? 0 - }, [client.userID, roomCtx, timeline]) + useEffect(() => { roomCtx.directSetFocusedEventRowID = directSetFocusedEventRowID }, [roomCtx]) + useEffect(() => { const newestEvent = timeline[timeline.length - 1] if ( @@ -95,26 +133,31 @@ const TimelineView = () => { ) } }, [focused, client, roomCtx, room, timeline]) + useEffect(() => { - const topElem = topRef.current - if (!topElem || !room.hasMoreHistory) { + if (!room.hasMoreHistory || room.paginating) { return } - const observer = new IntersectionObserver(entries => { - if (entries[0]?.isIntersecting && room.paginationRequestedForRow !== prevOldestTimelineRow.current) { - room.paginationRequestedForRow = prevOldestTimelineRow.current - loadHistory() - } - }, { - root: topElem.parentElement!.parentElement, - rootMargin: "0px", - threshold: 1.0, - }) - observer.observe(topElem) - return () => observer.unobserve(topElem) - }, [room, room.hasMoreHistory, loadHistory, oldestTimelineRow]) - let prevEvt: MemDBEvent | null = null + const firstItem = virtualizer.getVirtualItems()[0] + + // Load history if there is none + if (!firstItem) { + loadHistory() + return + } + + // Load more history when the virtualiser loads the last item + if (firstItem.index == 0) { + console.log("Loading more history...") + loadHistory() + return + } + }, [ + room.hasMoreHistory, loadHistory, + virtualizer.getVirtualItems(), + ]) + return
{room.hasMoreHistory ? : "No more history available in this room"}
-
-
- {timeline.map(entry => { - if (!entry) { - return null - } - const thisEvt = - prevEvt = entry - return thisEvt - })} -
+
+
+ + {items.map((virtualRow) => { + const entry = timeline[virtualRow.index] + if (!entry) { + return null + } + const thisEvt = + + return thisEvt + })} +
+
}