diff --git a/web/package-lock.json b/web/package-lock.json index b322427..ce40d93 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 0d135f1..e6ce6b4 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/ui/roomview/roomcontext.ts b/web/src/ui/roomview/roomcontext.ts index 0758612..dc2ef70 100644 --- a/web/src/ui/roomview/roomcontext.ts +++ b/web/src/ui/roomview/roomcontext.ts @@ -14,6 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . 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 = createRef() + public readonly listRef: RefObject = 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) => { diff --git a/web/src/ui/timeline/TimelineView.css b/web/src/ui/timeline/TimelineView.css index 79a575d..d589b9a 100644 --- a/web/src/ui/timeline/TimelineView.css +++ b/web/src/ui/timeline/TimelineView.css @@ -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; - } } diff --git a/web/src/ui/timeline/TimelineView.tsx b/web/src/ui/timeline/TimelineView.tsx index 2148258..a704cfd 100644 --- a/web/src/ui/timeline/TimelineView.tsx +++ b/web/src/ui/timeline/TimelineView.tsx @@ -15,6 +15,7 @@ // along with this program. If not, see . 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(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(null) + } const timelineViewRef = useRef(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
-
- {room.hasMoreHistory ? : "No more history available in this room"} -
-
-
+ +
+ {room.hasMoreHistory ? : "No more history available in this room"} +
{timeline.map(entry => { if (!entry) { return null @@ -139,8 +161,7 @@ const TimelineView = () => { prevEvt = entry return thisEvt })} -
-
+
}