mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-19 18:13:41 -05:00
web/timeline: experiment with virtua
This commit is contained in:
parent
43f25727e6
commit
c902c941e7
5 changed files with 122 additions and 67 deletions
33
web/package-lock.json
generated
33
web/package-lock.json
generated
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -5,6 +5,10 @@ div.timeline-view {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
||||||
|
> div.timeline-list {
|
||||||
|
flex: 1;
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
|
||||||
> div.timeline-beginning {
|
> div.timeline-beginning {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
|
@ -16,8 +20,5 @@ div.timeline-view {
|
||||||
gap: .5rem;
|
gap: .5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
> div.timeline-list {
|
|
||||||
padding-bottom: 2rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
const onScroll = (offset: number) => {
|
||||||
|
const list = roomCtx.listRef.current
|
||||||
|
if (!list) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Magic incantation stolen from https://inokawa.github.io/virtua/?path=/story/advanced-chat--default
|
||||||
|
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.
|
// Save the scroll height prior to updating the timeline, so that we can adjust the scroll position if needed.
|
||||||
if (timelineViewRef.current) {
|
// if (timelineViewRef.current) {
|
||||||
oldScrollHeight.current = timelineViewRef.current.scrollHeight
|
// oldScrollHeight.current = timelineViewRef.current.scrollHeight
|
||||||
}
|
// }
|
||||||
useLayoutEffect(() => {
|
// useLayoutEffect(() => {
|
||||||
const bottomRef = roomCtx.timelineBottomRef
|
// const bottomRef = roomCtx.timelineBottomRef
|
||||||
if (bottomRef.current && roomCtx.scrolledToBottom) {
|
// if (bottomRef.current && roomCtx.scrolledToBottom) {
|
||||||
// For any timeline changes, if we were at the bottom, scroll to the new bottom
|
// // For any timeline changes, if we were at the bottom, scroll to the new bottom
|
||||||
bottomRef.current.scrollIntoView()
|
// bottomRef.current.scrollIntoView()
|
||||||
} else if (timelineViewRef.current && prevOldestTimelineRow.current > (timeline[0]?.timeline_rowid ?? 0)) {
|
// } 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
|
// // 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
|
// timelineViewRef.current.scrollTop += timelineViewRef.current.scrollHeight - oldScrollHeight.current
|
||||||
}
|
// }
|
||||||
prevOldestTimelineRow.current = timeline[0]?.timeline_rowid ?? 0
|
// prevOldestTimelineRow.current = timeline[0]?.timeline_rowid ?? 0
|
||||||
}, [client.userID, roomCtx, timeline])
|
// }, [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,27 +118,28 @@ 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}>
|
||||||
|
<VList ref={roomCtx.listRef} reverse shift={prepending.current} className="timeline-list" onScroll={onScroll}>
|
||||||
<div className="timeline-beginning">
|
<div className="timeline-beginning">
|
||||||
{room.hasMoreHistory ? <button onClick={loadHistory} disabled={isLoadingHistory}>
|
{room.hasMoreHistory ? <button onClick={loadHistory} disabled={isLoadingHistory}>
|
||||||
{isLoadingHistory
|
{isLoadingHistory
|
||||||
|
@ -123,8 +147,6 @@ const TimelineView = () => {
|
||||||
: "Load more history"}
|
: "Load more history"}
|
||||||
</button> : "No more history available in this room"}
|
</button> : "No more history available in this room"}
|
||||||
</div>
|
</div>
|
||||||
<div className="timeline-list">
|
|
||||||
<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>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue