web/timeline: experiment with virtua

This commit is contained in:
Tulir Asokan 2024-12-31 16:13:08 +02:00
parent 43f25727e6
commit c902c941e7
5 changed files with 122 additions and 67 deletions

33
web/package-lock.json generated
View file

@ -18,7 +18,8 @@
"react-blurhash": "^0.3.0", "react-blurhash": "^0.3.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-spinners": "^0.15.0", "react-spinners": "^0.15.0",
"unhomoglyph": "^1.0.6" "unhomoglyph": "^1.0.6",
"virtua": "^0.39.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.11.1", "@eslint/js": "^9.11.1",
@ -5499,6 +5500,36 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/virtua": {
"version": "0.39.2",
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.39.2.tgz",
"integrity": "sha512-KhDYmfDe36L1W5ir1b5+jeV40+u4bb5bRiZggWwGinreGXKnxxcLGdk+yVZlO5dNdBq/nxj4v4w6yxuwgLXSBg==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.14.0",
"react-dom": ">=16.14.0",
"solid-js": ">=1.0",
"svelte": ">=5.0",
"vue": ">=3.2"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"solid-js": {
"optional": true
},
"svelte": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.11", "version": "5.4.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz",

View file

@ -20,7 +20,8 @@
"react-blurhash": "^0.3.0", "react-blurhash": "^0.3.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-spinners": "^0.15.0", "react-spinners": "^0.15.0",
"unhomoglyph": "^1.0.6" "unhomoglyph": "^1.0.6",
"virtua": "^0.39.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.11.1", "@eslint/js": "^9.11.1",

View file

@ -14,6 +14,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { RefObject, createContext, createRef, use } from "react" import { RefObject, createContext, createRef, use } from "react"
import type { VListHandle } from "virtua"
import { RoomStateStore } from "@/api/statestore" import { RoomStateStore } from "@/api/statestore"
import { EventID, EventRowID, MemDBEvent } from "@/api/types" import { EventID, EventRowID, MemDBEvent } from "@/api/types"
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts" import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
@ -24,7 +25,7 @@ const noop = (name: string) => () => {
} }
export class RoomContextData { export class RoomContextData {
public readonly timelineBottomRef: RefObject<HTMLDivElement | null> = createRef() public readonly listRef: RefObject<VListHandle | null> = createRef()
public setReplyTo: (eventID: EventID | null) => void = noop("setReplyTo") public setReplyTo: (eventID: EventID | null) => void = noop("setReplyTo")
public setEditing: (evt: MemDBEvent | null) => void = noop("setEditing") public setEditing: (evt: MemDBEvent | null) => void = noop("setEditing")
public insertText: (text: string) => void = noop("insertText") public insertText: (text: string) => void = noop("insertText")
@ -36,9 +37,9 @@ export class RoomContextData {
constructor(public store: RoomStateStore) {} constructor(public store: RoomStateStore) {}
scrollToBottom = () => { scrollToBottom = () => {
if (this.scrolledToBottom) { // if (this.scrolledToBottom) {
this.timelineBottomRef.current?.scrollIntoView() // this.timelineBottomRef.current?.scrollIntoView()
} // }
} }
setFocusedEventRowID = (eventRowID: number | null) => { setFocusedEventRowID = (eventRowID: number | null) => {

View file

@ -5,19 +5,20 @@ div.timeline-view {
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
> div.timeline-beginning { > div.timeline-list {
display: flex; flex: 1;
justify-content: space-around; padding-bottom: 2rem;
margin-top: 1rem;
> button { > div.timeline-beginning {
display: flex; display: flex;
padding: .5rem 1rem; justify-content: space-around;
gap: .5rem; margin-top: 1rem;
> button {
display: flex;
padding: .5rem 1rem;
gap: .5rem;
}
} }
} }
> div.timeline-list {
padding-bottom: 2rem;
}
} }

View file

@ -15,6 +15,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { use, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react" import { use, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"
import { ScaleLoader } from "react-spinners" import { ScaleLoader } from "react-spinners"
import { VList } from "virtua"
import { usePreference, useRoomTimeline } from "@/api/statestore" import { usePreference, useRoomTimeline } from "@/api/statestore"
import { EventRowID, MemDBEvent } from "@/api/types" import { EventRowID, MemDBEvent } from "@/api/types"
import useFocus from "@/util/focus.ts" import useFocus from "@/util/focus.ts"
@ -29,19 +30,19 @@ const TimelineView = () => {
const timeline = useRoomTimeline(room) const timeline = useRoomTimeline(room)
const client = use(ClientContext)! const client = use(ClientContext)!
const [isLoadingHistory, setLoadingHistory] = useState(false) const [isLoadingHistory, setLoadingHistory] = useState(false)
const prepending = useRef(false)
const [focusedEventRowID, directSetFocusedEventRowID] = useState<EventRowID | null>(null) const [focusedEventRowID, directSetFocusedEventRowID] = useState<EventRowID | null>(null)
const loadHistory = useCallback(() => { const loadHistory = () => {
setLoadingHistory(true) setLoadingHistory(true)
client.loadMoreHistory(room.roomID) client.loadMoreHistory(room.roomID)
.then(() => prepending.current = false)
.catch(err => console.error("Failed to load history", err)) .catch(err => console.error("Failed to load history", err))
.finally(() => setLoadingHistory(false)) .finally(() => setLoadingHistory(false))
}, [client, room]) }
const bottomRef = roomCtx.timelineBottomRef
const topRef = useRef<HTMLDivElement>(null)
const timelineViewRef = useRef<HTMLDivElement>(null) const timelineViewRef = useRef<HTMLDivElement>(null)
const prevOldestTimelineRow = useRef(0) // const prevOldestTimelineRow = useRef(0)
const oldestTimelineRow = timeline[0]?.timeline_rowid // const oldestTimelineRow = timeline[0]?.timeline_rowid
const oldScrollHeight = useRef(0) // const oldScrollHeight = useRef(0)
const focused = useFocus() const focused = useFocus()
const smallReplies = usePreference(client.store, room, "small_replies") const smallReplies = usePreference(client.store, room, "small_replies")
@ -54,24 +55,46 @@ const TimelineView = () => {
const timelineView = timelineViewRef.current const timelineView = timelineViewRef.current
roomCtx.scrolledToBottom = timelineView.scrollTop + timelineView.clientHeight + 1 >= timelineView.scrollHeight 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. const onScroll = (offset: number) => {
if (timelineViewRef.current) { const list = roomCtx.listRef.current
oldScrollHeight.current = timelineViewRef.current.scrollHeight if (!list) {
} return
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 // Magic incantation stolen from https://inokawa.github.io/virtua/?path=/story/advanced-chat--default
}, [client.userID, roomCtx, timeline]) roomCtx.scrolledToBottom = offset - list.scrollSize + list.viewportSize >= -1.5
const oldestRowID = timeline[0]?.timeline_rowid
if (oldestRowID && offset < 100 && room.paginationRequestedForRow !== oldestRowID) {
room.paginationRequestedForRow = oldestRowID
loadHistory()
}
}
// 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(() => { useEffect(() => {
roomCtx.directSetFocusedEventRowID = directSetFocusedEventRowID roomCtx.directSetFocusedEventRowID = directSetFocusedEventRowID
}, [roomCtx]) }, [roomCtx])
useEffect(() => {
if (roomCtx.scrolledToBottom) {
roomCtx.listRef.current?.scrollToIndex(timeline.length - 1, { align: "end" })
}
}, [roomCtx, timeline])
useLayoutEffect(() => {
prepending.current = false
})
useEffect(() => { useEffect(() => {
const newestEvent = timeline[timeline.length - 1] const newestEvent = timeline[timeline.length - 1]
if ( if (
@ -95,36 +118,35 @@ const TimelineView = () => {
) )
} }
}, [focused, client, roomCtx, room, timeline]) }, [focused, client, roomCtx, room, timeline])
useEffect(() => { // useEffect(() => {
const topElem = topRef.current // const topElem = topRef.current
if (!topElem || !room.hasMoreHistory) { // if (!topElem || !room.hasMoreHistory) {
return // return
} // }
const observer = new IntersectionObserver(entries => { // const observer = new IntersectionObserver(entries => {
if (entries[0]?.isIntersecting && room.paginationRequestedForRow !== prevOldestTimelineRow.current) { // if (entries[0]?.isIntersecting && room.paginationRequestedForRow !== prevOldestTimelineRow.current) {
room.paginationRequestedForRow = prevOldestTimelineRow.current // room.paginationRequestedForRow = prevOldestTimelineRow.current
loadHistory() // loadHistory()
} // }
}, { // }, {
root: topElem.parentElement!.parentElement, // root: topElem.parentElement!.parentElement,
rootMargin: "0px", // rootMargin: "0px",
threshold: 1.0, // threshold: 1.0,
}) // })
observer.observe(topElem) // observer.observe(topElem)
return () => observer.unobserve(topElem) // return () => observer.unobserve(topElem)
}, [room, room.hasMoreHistory, loadHistory, oldestTimelineRow]) // }, [room, room.hasMoreHistory, loadHistory, oldestTimelineRow])
let prevEvt: MemDBEvent | null = null let prevEvt: MemDBEvent | null = null
return <div className="timeline-view" onScroll={handleScroll} ref={timelineViewRef}> return <div className="timeline-view" onScroll={handleScroll} ref={timelineViewRef}>
<div className="timeline-beginning"> <VList ref={roomCtx.listRef} reverse shift={prepending.current} className="timeline-list" onScroll={onScroll}>
{room.hasMoreHistory ? <button onClick={loadHistory} disabled={isLoadingHistory}> <div className="timeline-beginning">
{isLoadingHistory {room.hasMoreHistory ? <button onClick={loadHistory} disabled={isLoadingHistory}>
? <><ScaleLoader color="var(--primary-color)"/> Loading history...</> {isLoadingHistory
: "Load more history"} ? <><ScaleLoader color="var(--primary-color)"/> Loading history...</>
</button> : "No more history available in this room"} : "Load more history"}
</div> </button> : "No more history available in this room"}
<div className="timeline-list"> </div>
<div className="timeline-top-ref" ref={topRef}/>
{timeline.map(entry => { {timeline.map(entry => {
if (!entry) { if (!entry) {
return null return null
@ -139,8 +161,7 @@ const TimelineView = () => {
prevEvt = entry prevEvt = entry
return thisEvt return thisEvt
})} })}
<div className="timeline-bottom-ref" ref={bottomRef}/> </VList>
</div>
</div> </div>
} }