1
0
Fork 0
forked from Mirrors/gomuks

Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
Tulir Asokan
c902c941e7 web/timeline: experiment with virtua 2024-12-31 16:13:08 +02:00
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-dom": "^19.0.0",
"react-spinners": "^0.15.0",
"unhomoglyph": "^1.0.6"
"unhomoglyph": "^1.0.6",
"virtua": "^0.39.2"
},
"devDependencies": {
"@eslint/js": "^9.11.1",
@ -5499,6 +5500,36 @@
"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": {
"version": "5.4.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz",

View file

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

View file

@ -14,6 +14,7 @@
// 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/>.
import { RefObject, createContext, createRef, use } from "react"
import type { VListHandle } from "virtua"
import { RoomStateStore } from "@/api/statestore"
import { EventID, EventRowID, MemDBEvent } from "@/api/types"
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
@ -24,7 +25,7 @@ const noop = (name: string) => () => {
}
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 setEditing: (evt: MemDBEvent | null) => void = noop("setEditing")
public insertText: (text: string) => void = noop("insertText")
@ -36,9 +37,9 @@ export class RoomContextData {
constructor(public store: RoomStateStore) {}
scrollToBottom = () => {
if (this.scrolledToBottom) {
this.timelineBottomRef.current?.scrollIntoView()
}
// if (this.scrolledToBottom) {
// this.timelineBottomRef.current?.scrollIntoView()
// }
}
setFocusedEventRowID = (eventRowID: number | null) => {

View file

@ -5,19 +5,20 @@ div.timeline-view {
flex-direction: column;
justify-content: space-between;
> div.timeline-beginning {
display: flex;
justify-content: space-around;
margin-top: 1rem;
> div.timeline-list {
flex: 1;
padding-bottom: 2rem;
> button {
> div.timeline-beginning {
display: flex;
padding: .5rem 1rem;
gap: .5rem;
justify-content: space-around;
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/>.
import { use, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"
import { ScaleLoader } from "react-spinners"
import { VList } from "virtua"
import { usePreference, useRoomTimeline } from "@/api/statestore"
import { EventRowID, MemDBEvent } from "@/api/types"
import useFocus from "@/util/focus.ts"
@ -29,19 +30,19 @@ const TimelineView = () => {
const timeline = useRoomTimeline(room)
const client = use(ClientContext)!
const [isLoadingHistory, setLoadingHistory] = useState(false)
const prepending = useRef(false)
const [focusedEventRowID, directSetFocusedEventRowID] = useState<EventRowID | null>(null)
const loadHistory = useCallback(() => {
const loadHistory = () => {
setLoadingHistory(true)
client.loadMoreHistory(room.roomID)
.then(() => prepending.current = false)
.catch(err => console.error("Failed to load history", err))
.finally(() => setLoadingHistory(false))
}, [client, room])
const bottomRef = roomCtx.timelineBottomRef
const topRef = useRef<HTMLDivElement>(null)
}
const timelineViewRef = useRef<HTMLDivElement>(null)
const prevOldestTimelineRow = useRef(0)
const oldestTimelineRow = timeline[0]?.timeline_rowid
const oldScrollHeight = useRef(0)
// 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")
@ -54,24 +55,46 @@ 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
const onScroll = (offset: number) => {
const list = roomCtx.listRef.current
if (!list) {
return
}
prevOldestTimelineRow.current = timeline[0]?.timeline_rowid ?? 0
}, [client.userID, roomCtx, timeline])
// 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.
// 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(() => {
if (roomCtx.scrolledToBottom) {
roomCtx.listRef.current?.scrollToIndex(timeline.length - 1, { align: "end" })
}
}, [roomCtx, timeline])
useLayoutEffect(() => {
prepending.current = false
})
useEffect(() => {
const newestEvent = timeline[timeline.length - 1]
if (
@ -95,36 +118,35 @@ const TimelineView = () => {
)
}
}, [focused, client, roomCtx, room, timeline])
useEffect(() => {
const topElem = topRef.current
if (!topElem || !room.hasMoreHistory) {
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])
// useEffect(() => {
// const topElem = topRef.current
// if (!topElem || !room.hasMoreHistory) {
// 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
return <div className="timeline-view" onScroll={handleScroll} ref={timelineViewRef}>
<div className="timeline-beginning">
{room.hasMoreHistory ? <button onClick={loadHistory} disabled={isLoadingHistory}>
{isLoadingHistory
? <><ScaleLoader color="var(--primary-color)"/> Loading history...</>
: "Load more history"}
</button> : "No more history available in this room"}
</div>
<div className="timeline-list">
<div className="timeline-top-ref" ref={topRef}/>
<VList ref={roomCtx.listRef} reverse shift={prepending.current} className="timeline-list" onScroll={onScroll}>
<div className="timeline-beginning">
{room.hasMoreHistory ? <button onClick={loadHistory} disabled={isLoadingHistory}>
{isLoadingHistory
? <><ScaleLoader color="var(--primary-color)"/> Loading history...</>
: "Load more history"}
</button> : "No more history available in this room"}
</div>
{timeline.map(entry => {
if (!entry) {
return null
@ -139,8 +161,7 @@ const TimelineView = () => {
prevEvt = entry
return thisEvt
})}
<div className="timeline-bottom-ref" ref={bottomRef}/>
</div>
</VList>
</div>
}