From 15e92411d3bd76c193467ae9cc21ebb1f20b827d Mon Sep 17 00:00:00 2001 From: Jade Ellis Date: Thu, 2 Jan 2025 02:47:53 +0000 Subject: [PATCH 1/3] 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 | 180 ++++++++++++++++++-------- 6 files changed, 165 insertions(+), 70 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index b3224277..693731a8 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 0d135f15..67766492 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 89e78a0c..5c9a6eab 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 524d84be..17d51082 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 79a575dc..59415cf5 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 2148258a..61587390 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 firstItem = virtualizer.getVirtualItems()[0] + + // Load history if there is none + if (!firstItem) { + loadHistory() 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 + + // 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 + })} +
+
} From 1e339d1fbde29e12030c186f25edd81309233d73 Mon Sep 17 00:00:00 2001 From: Jade Ellis Date: Thu, 2 Jan 2025 15:33:17 +0000 Subject: [PATCH 2/3] web/timeline: review suggestions --- web/src/ui/timeline/TimelineEvent.tsx | 4 +- web/src/ui/timeline/TimelineView.css | 10 ++++ web/src/ui/timeline/TimelineView.tsx | 68 ++++++++++++++++----------- 3 files changed, 54 insertions(+), 28 deletions(-) diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index 17d51082..187456f7 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.tsx @@ -77,7 +77,9 @@ const EventSendStatus = ({ evt }: { evt: MemDBEvent }) => { } } -const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused, virtualIndex, ref }: TimelineEventProps) => { +const TimelineEvent = ({ + evt, prevEvt, disableMenu, smallReplies, isFocused, virtualIndex, ref, +}: TimelineEventProps) => { const roomCtx = useRoomContext() const client = use(ClientContext)! const mainScreen = use(MainScreenContext) diff --git a/web/src/ui/timeline/TimelineView.css b/web/src/ui/timeline/TimelineView.css index 59415cf5..13454753 100644 --- a/web/src/ui/timeline/TimelineView.css +++ b/web/src/ui/timeline/TimelineView.css @@ -16,5 +16,15 @@ div.timeline-view { > div.timeline-list { padding-bottom: 2rem; + + width: 100%; + position: relative; + + > div.timeline-virtual-items { + position: absolute; + top: 0; + left: 0; + width: 100% + } } } diff --git a/web/src/ui/timeline/TimelineView.tsx b/web/src/ui/timeline/TimelineView.tsx index 61587390..2e385003 100644 --- a/web/src/ui/timeline/TimelineView.tsx +++ b/web/src/ui/timeline/TimelineView.tsx @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { Virtualizer, useVirtualizer } from "@tanstack/react-virtual" -import { use, useEffect, useLayoutEffect, useRef, useState } from "react" +import { use, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react" import { ScaleLoader } from "react-spinners" import { usePreference, useRoomTimeline } from "@/api/statestore" import { EventRowID, MemDBEvent } from "@/api/types" @@ -25,7 +25,11 @@ import TimelineEvent from "./TimelineEvent.tsx" import { getBodyType, isSmallEvent } from "./content/index.ts" import "./TimelineView.css" -const measureElement = (element: Element, entry: ResizeObserverEntry | undefined, instance: Virtualizer) => { +// This is necessary to take into account margin, which the default measurement +// (using getBoundingClientRect) doesn't by default +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) { @@ -34,15 +38,23 @@ const measureElement = (element: Element, entry: ResizeObserverEntry | undefined const size = Math.round( box[horizontal ? "inlineSize" : "blockSize"], ) - return size + parseFloat(style[horizontal ? "marginInlineStart" : "marginBlockStart"]) + parseFloat(style[horizontal ? "marginInlineEnd" : "marginBlockEnd"]) + 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"]), + 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 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() @@ -51,20 +63,6 @@ const TimelineView = () => { const client = use(ClientContext)! const [isLoadingHistory, setLoadingHistory] = useState(false) const [focusedEventRowID, directSetFocusedEventRowID] = useState(null) - const loadHistory = () => { - setLoadingHistory(true) - client.loadMoreHistory(room.roomID) - .catch(err => console.error("Failed to load history", err)) - .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 timelineViewRef = useRef(null) const focused = useFocus() @@ -72,12 +70,6 @@ const TimelineView = () => { const virtualListRef = useRef(null) - const virtualListOffsetRef = useRef(0) - - useLayoutEffect(() => { - virtualListOffsetRef.current = virtualListRef.current?.offsetTop ?? 0 - }, []) - const virtualizer = useVirtualizer({ count: timeline.length, getScrollElement: () => timelineViewRef.current, @@ -89,12 +81,32 @@ const TimelineView = () => { const items = virtualizer.getVirtualItems() + const loadHistory = useCallback(() => { + setLoadingHistory(true) + client.loadMoreHistory(room.roomID) + .catch(err => console.error("Failed to load history", err)) + .then((loadedEventCount) => { + // Prevent scroll getting stuck loading more history + if (loadedEventCount && + timelineViewRef.current && + timelineViewRef.current.scrollTop <= (virtualListRef.current?.offsetTop ?? 0)) { + // FIXME: This seems to run before the events are measured, + // resulting in a jump in the timeline of the difference in + // height when scrolling very fast + virtualizer.scrollToIndex(loadedEventCount, { align: "end" }) + } + }) + .finally(() => { + setLoadingHistory(false) + }) + }, [client, room, virtualizer]) + useLayoutEffect(() => { if (roomCtx.scrolledToBottom) { // timelineViewRef.current && (timelineViewRef.current.scrollTop = timelineViewRef.current.scrollHeight) bottomRef.current?.scrollIntoView() } - }, [roomCtx, timeline, virtualizer.getTotalSize()]) + }, [roomCtx, timeline, virtualizer.getTotalSize(), bottomRef]) // 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. @@ -147,7 +159,7 @@ const TimelineView = () => { return } - // Load more history when the virtualiser loads the last item + // Load more history when the virtualizer loads the last item if (firstItem.index == 0) { console.log("Loading more history...") loadHistory() @@ -156,6 +168,8 @@ const TimelineView = () => { }, [ room.hasMoreHistory, loadHistory, virtualizer.getVirtualItems(), + room.paginating, + virtualizer, ]) return
From 62883ee14e52b9c1c5125063ea2f5b99844c2df5 Mon Sep 17 00:00:00 2001 From: Jade Ellis Date: Fri, 3 Jan 2025 16:27:55 +0000 Subject: [PATCH 3/3] web/timeline: improve performance --- web/src/ui/timeline/TimelineView.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/ui/timeline/TimelineView.tsx b/web/src/ui/timeline/TimelineView.tsx index 2e385003..061ed0a2 100644 --- a/web/src/ui/timeline/TimelineView.tsx +++ b/web/src/ui/timeline/TimelineView.tsx @@ -146,12 +146,13 @@ const TimelineView = () => { } }, [focused, client, roomCtx, room, timeline]) + const firstItem = items[0] + useEffect(() => { if (!room.hasMoreHistory || room.paginating) { return } - const firstItem = virtualizer.getVirtualItems()[0] // Load history if there is none if (!firstItem) { @@ -167,9 +168,8 @@ const TimelineView = () => { } }, [ room.hasMoreHistory, loadHistory, - virtualizer.getVirtualItems(), room.paginating, - virtualizer, + firstItem, ]) return