Skip to content

Commit 5decae0

Browse files
committed
web/timeline: use tanstack virtual to virtualise timeline
1 parent c3899d0 commit 5decae0

File tree

6 files changed

+165
-70
lines changed

6 files changed

+165
-70
lines changed

web/package-lock.json

Lines changed: 28 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"preview": "vite preview"
1212
},
1313
"dependencies": {
14+
"@tanstack/react-virtual": "^3.11.2",
1415
"@wailsio/runtime": "^3.0.0-alpha.29",
1516
"blurhash": "^2.0.5",
1617
"katex": "^0.16.11",

web/src/api/client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ export default class Client {
305305
}
306306
}
307307

308-
async loadMoreHistory(roomID: RoomID): Promise<void> {
308+
async loadMoreHistory(roomID: RoomID): Promise<number> {
309309
const room = this.store.rooms.get(roomID)
310310
if (!room) {
311311
throw new Error("Room not found")
@@ -324,6 +324,7 @@ export default class Client {
324324
}
325325
room.hasMoreHistory = resp.has_more
326326
room.applyPagination(resp.events, resp.related_events, resp.receipts)
327+
return resp.events.length
327328
} finally {
328329
room.paginating = false
329330
}

web/src/ui/timeline/TimelineEvent.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ export interface TimelineEventProps {
3939
prevEvt: MemDBEvent | null
4040
disableMenu?: boolean
4141
smallReplies?: boolean
42-
isFocused?: boolean
42+
isFocused?: boolean,
43+
virtualIndex?: number,
44+
ref?: React.Ref<HTMLDivElement>
4345
}
4446

4547
const fullTimeFormatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "medium" })
@@ -75,7 +77,7 @@ const EventSendStatus = ({ evt }: { evt: MemDBEvent }) => {
7577
}
7678
}
7779

78-
const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: TimelineEventProps) => {
80+
const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused, virtualIndex, ref }: TimelineEventProps) => {
7981
const roomCtx = useRoomContext()
8082
const client = use(ClientContext)!
8183
const mainScreen = use(MainScreenContext)
@@ -145,9 +147,10 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
145147
eventTS.getDate() !== prevEvtDate.getDate() ||
146148
eventTS.getMonth() !== prevEvtDate.getMonth() ||
147149
eventTS.getFullYear() !== prevEvtDate.getFullYear())) {
148-
dateSeparator = <div className="date-separator">
150+
const dateLabel = dateFormatter.format(eventTS)
151+
dateSeparator = <div className="date-separator" role="separator" aria-label={dateLabel}>
149152
<hr role="none"/>
150-
{dateFormatter.format(eventTS)}
153+
{dateLabel}
151154
<hr role="none"/>
152155
</div>
153156
}
@@ -196,6 +199,8 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
196199
className={wrapperClassNames.join(" ")}
197200
onContextMenu={onContextMenu}
198201
onClick={!disableMenu && isMobileDevice ? onClick : undefined}
202+
data-index={virtualIndex}
203+
ref={ref}
199204
>
200205
{!disableMenu && !isMobileDevice && <div
201206
className={`context-menu-container ${forceContextMenuOpen ? "force-open" : ""}`}
@@ -248,10 +253,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
248253
<ReadReceipts room={roomCtx.store} eventID={evt.event_id} />}
249254
{evt.sender === client.userID && evt.transaction_id ? <EventSendStatus evt={evt}/> : null}
250255
</div>
251-
return <>
252-
{dateSeparator}
253-
{mainEvent}
254-
</>
256+
return mainEvent
255257
}
256258

257259
export default React.memo(TimelineEvent)

web/src/ui/timeline/TimelineView.css

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
div.timeline-view {
22
overflow-y: scroll;
3-
4-
display: flex;
5-
flex-direction: column;
6-
justify-content: space-between;
3+
contain: strict;
74

85
> div.timeline-beginning {
96
display: flex;

web/src/ui/timeline/TimelineView.tsx

Lines changed: 123 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -13,38 +13,89 @@
1313
//
1414
// You should have received a copy of the GNU Affero General Public License
1515
// along with this program. If not, see <https://www.gnu.org/licenses/>.
16-
import { use, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"
16+
import { Virtualizer, useVirtualizer } from "@tanstack/react-virtual"
17+
import { use, useEffect, useLayoutEffect, useRef, useState } from "react"
1718
import { ScaleLoader } from "react-spinners"
1819
import { usePreference, useRoomTimeline } from "@/api/statestore"
1920
import { EventRowID, MemDBEvent } from "@/api/types"
2021
import useFocus from "@/util/focus.ts"
2122
import ClientContext from "../ClientContext.ts"
2223
import { useRoomContext } from "../roomview/roomcontext.ts"
2324
import TimelineEvent from "./TimelineEvent.tsx"
25+
import { getBodyType, isSmallEvent } from "./content/index.ts"
2426
import "./TimelineView.css"
2527

28+
const measureElement = (element: Element, entry: ResizeObserverEntry | undefined, instance: Virtualizer<HTMLDivElement, Element>) => {
29+
const horizontal = instance.options.horizontal
30+
const style = window.getComputedStyle(element)
31+
if (entry == null ? void 0 : entry.borderBoxSize) {
32+
const box = entry?.borderBoxSize[0]
33+
if (box) {
34+
const size = Math.round(
35+
box[horizontal ? "inlineSize" : "blockSize"],
36+
)
37+
return size + parseFloat(style[horizontal ? "marginInlineStart" : "marginBlockStart"]) + parseFloat(style[horizontal ? "marginInlineEnd" : "marginBlockEnd"])
38+
}
39+
}
40+
return Math.round(
41+
element.getBoundingClientRect()[instance.options.horizontal ? "width" : "height"] + parseFloat(style[horizontal ? "marginLeft" : "marginTop"]) + parseFloat(style[horizontal ? "marginRight" : "marginBottom"]),
42+
)
43+
}
44+
45+
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
46+
2647
const TimelineView = () => {
2748
const roomCtx = useRoomContext()
2849
const room = roomCtx.store
2950
const timeline = useRoomTimeline(room)
3051
const client = use(ClientContext)!
3152
const [isLoadingHistory, setLoadingHistory] = useState(false)
3253
const [focusedEventRowID, directSetFocusedEventRowID] = useState<EventRowID | null>(null)
33-
const loadHistory = useCallback(() => {
54+
const loadHistory = () => {
3455
setLoadingHistory(true)
3556
client.loadMoreHistory(room.roomID)
3657
.catch(err => console.error("Failed to load history", err))
37-
.finally(() => setLoadingHistory(false))
38-
}, [client, room])
58+
.then((loadedEventCount) => {
59+
// Prevent scroll getting stuck loading more history
60+
if (loadedEventCount && timelineViewRef.current && timelineViewRef.current.scrollTop <= virtualListOffsetRef.current) {
61+
virtualizer.scrollToIndex(loadedEventCount, { align: "end" })
62+
}
63+
})
64+
.finally(() => {
65+
setLoadingHistory(false)
66+
})
67+
}
3968
const bottomRef = roomCtx.timelineBottomRef
40-
const topRef = useRef<HTMLDivElement>(null)
4169
const timelineViewRef = useRef<HTMLDivElement>(null)
42-
const prevOldestTimelineRow = useRef(0)
43-
const oldestTimelineRow = timeline[0]?.timeline_rowid
44-
const oldScrollHeight = useRef(0)
4570
const focused = useFocus()
4671
const smallReplies = usePreference(client.store, room, "small_replies")
4772

73+
const virtualListRef = useRef<HTMLDivElement>(null)
74+
75+
const virtualListOffsetRef = useRef(0)
76+
77+
useLayoutEffect(() => {
78+
virtualListOffsetRef.current = virtualListRef.current?.offsetTop ?? 0
79+
}, [])
80+
81+
const virtualizer = useVirtualizer({
82+
count: timeline.length,
83+
getScrollElement: () => timelineViewRef.current,
84+
estimateSize: (index) => timeline[index] ? estimateEventHeight(timeline[index]) : 0,
85+
getItemKey: (index) => timeline[index]?.rowid || index,
86+
overscan: 6,
87+
measureElement,
88+
})
89+
90+
const items = virtualizer.getVirtualItems()
91+
92+
useLayoutEffect(() => {
93+
if (roomCtx.scrolledToBottom) {
94+
// timelineViewRef.current && (timelineViewRef.current.scrollTop = timelineViewRef.current.scrollHeight)
95+
bottomRef.current?.scrollIntoView()
96+
}
97+
}, [roomCtx, timeline, virtualizer.getTotalSize()])
98+
4899
// When the user scrolls the timeline manually, remember if they were at the bottom,
49100
// so that we can keep them at the bottom when new events are added.
50101
const handleScroll = () => {
@@ -54,24 +105,11 @@ const TimelineView = () => {
54105
const timelineView = timelineViewRef.current
55106
roomCtx.scrolledToBottom = timelineView.scrollTop + timelineView.clientHeight + 1 >= timelineView.scrollHeight
56107
}
57-
// Save the scroll height prior to updating the timeline, so that we can adjust the scroll position if needed.
58-
if (timelineViewRef.current) {
59-
oldScrollHeight.current = timelineViewRef.current.scrollHeight
60-
}
61-
useLayoutEffect(() => {
62-
const bottomRef = roomCtx.timelineBottomRef
63-
if (bottomRef.current && roomCtx.scrolledToBottom) {
64-
// For any timeline changes, if we were at the bottom, scroll to the new bottom
65-
bottomRef.current.scrollIntoView()
66-
} else if (timelineViewRef.current && prevOldestTimelineRow.current > (timeline[0]?.timeline_rowid ?? 0)) {
67-
// When new entries are added to the top of the timeline, scroll down to keep the same position
68-
timelineViewRef.current.scrollTop += timelineViewRef.current.scrollHeight - oldScrollHeight.current
69-
}
70-
prevOldestTimelineRow.current = timeline[0]?.timeline_rowid ?? 0
71-
}, [client.userID, roomCtx, timeline])
108+
72109
useEffect(() => {
73110
roomCtx.directSetFocusedEventRowID = directSetFocusedEventRowID
74111
}, [roomCtx])
112+
75113
useEffect(() => {
76114
const newestEvent = timeline[timeline.length - 1]
77115
if (
@@ -95,26 +133,31 @@ const TimelineView = () => {
95133
)
96134
}
97135
}, [focused, client, roomCtx, room, timeline])
136+
98137
useEffect(() => {
99-
const topElem = topRef.current
100-
if (!topElem || !room.hasMoreHistory) {
138+
if (!room.hasMoreHistory || room.paginating) {
139+
return
140+
}
141+
142+
const firstItem = virtualizer.getVirtualItems()[0]
143+
144+
// Load history if there is none
145+
if (!firstItem) {
146+
loadHistory()
101147
return
102148
}
103-
const observer = new IntersectionObserver(entries => {
104-
if (entries[0]?.isIntersecting && room.paginationRequestedForRow !== prevOldestTimelineRow.current) {
105-
room.paginationRequestedForRow = prevOldestTimelineRow.current
106-
loadHistory()
107-
}
108-
}, {
109-
root: topElem.parentElement!.parentElement,
110-
rootMargin: "0px",
111-
threshold: 1.0,
112-
})
113-
observer.observe(topElem)
114-
return () => observer.unobserve(topElem)
115-
}, [room, room.hasMoreHistory, loadHistory, oldestTimelineRow])
116-
117-
let prevEvt: MemDBEvent | null = null
149+
150+
// Load more history when the virtualiser loads the last item
151+
if (firstItem.index == 0) {
152+
console.log("Loading more history...")
153+
loadHistory()
154+
return
155+
}
156+
}, [
157+
room.hasMoreHistory, loadHistory,
158+
virtualizer.getVirtualItems(),
159+
])
160+
118161
return <div className="timeline-view" onScroll={handleScroll} ref={timelineViewRef}>
119162
<div className="timeline-beginning">
120163
{room.hasMoreHistory ? <button onClick={loadHistory} disabled={isLoadingHistory}>
@@ -123,24 +166,47 @@ const TimelineView = () => {
123166
: "Load more history"}
124167
</button> : "No more history available in this room"}
125168
</div>
126-
<div className="timeline-list">
127-
<div className="timeline-top-ref" ref={topRef}/>
128-
{timeline.map(entry => {
129-
if (!entry) {
130-
return null
131-
}
132-
const thisEvt = <TimelineEvent
133-
key={entry.rowid}
134-
evt={entry}
135-
prevEvt={prevEvt}
136-
smallReplies={smallReplies}
137-
isFocused={focusedEventRowID === entry.rowid}
138-
/>
139-
prevEvt = entry
140-
return thisEvt
141-
})}
142-
<div className="timeline-bottom-ref" ref={bottomRef}/>
169+
<div
170+
style={{
171+
height: virtualizer.getTotalSize(),
172+
width: "100%",
173+
position: "relative",
174+
}}
175+
className="timeline-list"
176+
ref={virtualListRef}
177+
>
178+
<div
179+
style={{
180+
position: "absolute",
181+
top: 0,
182+
left: 0,
183+
width: "100%",
184+
transform: `translateY(${items[0]?.start ?? 0}px)`,
185+
}}
186+
className="timeline-virtual-items"
187+
>
188+
189+
{items.map((virtualRow) => {
190+
const entry = timeline[virtualRow.index]
191+
if (!entry) {
192+
return null
193+
}
194+
const thisEvt = <TimelineEvent
195+
evt={entry}
196+
prevEvt={timeline[virtualRow.index - 1] ?? null}
197+
smallReplies={smallReplies}
198+
isFocused={focusedEventRowID === entry.rowid}
199+
200+
key={virtualRow.key}
201+
virtualIndex={virtualRow.index}
202+
ref={virtualizer.measureElement}
203+
/>
204+
205+
return thisEvt
206+
})}
207+
</div>
143208
</div>
209+
<div className="timeline-bottom-ref" ref={bottomRef}/>
144210
</div>
145211
}
146212

0 commit comments

Comments
 (0)