This commit is contained in:
Jade Ellis 2025-02-20 22:31:48 +01:00 committed by GitHub
commit ffb3210858
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 192 additions and 71 deletions

28
web/package-lock.json generated
View file

@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@tanstack/react-virtual": "^3.11.2",
"@wailsio/runtime": "^3.0.0-alpha.29", "@wailsio/runtime": "^3.0.0-alpha.29",
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"katex": "^0.16.11", "katex": "^0.16.11",
@ -1789,6 +1790,33 @@
"@swc/counter": "^0.1.3" "@swc/counter": "^0.1.3"
} }
}, },
"node_modules/@tanstack/react-virtual": {
"version": "3.11.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.11.2.tgz",
"integrity": "sha512-OuFzMXPF4+xZgx8UzJha0AieuMihhhaWG0tCqpp6tDzlFwOmNBPYMuLOtMJ1Tr4pXLHmgjcWhG6RlknY2oNTdQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.11.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.11.2",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.11.2.tgz",
"integrity": "sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",

View file

@ -11,6 +11,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-virtual": "^3.11.2",
"@wailsio/runtime": "^3.0.0-alpha.29", "@wailsio/runtime": "^3.0.0-alpha.29",
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"katex": "^0.16.11", "katex": "^0.16.11",

View file

@ -378,7 +378,7 @@ export default class Client {
} }
} }
async loadMoreHistory(roomID: RoomID): Promise<void> { async loadMoreHistory(roomID: RoomID): Promise<number> {
const room = this.store.rooms.get(roomID) const room = this.store.rooms.get(roomID)
if (!room) { if (!room) {
throw new Error("Room not found") throw new Error("Room not found")
@ -397,6 +397,7 @@ export default class Client {
} }
room.hasMoreHistory = resp.has_more room.hasMoreHistory = resp.has_more
room.applyPagination(resp.events, resp.related_events, resp.receipts) room.applyPagination(resp.events, resp.related_events, resp.receipts)
return resp.events.length
} finally { } finally {
room.paginating = false room.paginating = false
} }

View file

@ -39,7 +39,9 @@ export interface TimelineEventProps {
prevEvt: MemDBEvent | null prevEvt: MemDBEvent | null
disableMenu?: boolean disableMenu?: boolean
smallReplies?: boolean smallReplies?: boolean
isFocused?: boolean isFocused?: boolean,
virtualIndex?: number,
ref?: React.Ref<HTMLDivElement>
} }
const fullTimeFormatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "medium" }) const fullTimeFormatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "medium" })
@ -75,7 +77,9 @@ const EventSendStatus = ({ evt }: { evt: MemDBEvent }) => {
} }
} }
const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: TimelineEventProps) => { const TimelineEvent = ({
evt, prevEvt, disableMenu, smallReplies, isFocused, virtualIndex, ref,
}: TimelineEventProps) => {
const roomCtx = useRoomContext() const roomCtx = useRoomContext()
const client = use(ClientContext)! const client = use(ClientContext)!
const mainScreen = use(MainScreenContext) const mainScreen = use(MainScreenContext)
@ -148,9 +152,10 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
eventTS.getDate() !== prevEvtDate.getDate() || eventTS.getDate() !== prevEvtDate.getDate() ||
eventTS.getMonth() !== prevEvtDate.getMonth() || eventTS.getMonth() !== prevEvtDate.getMonth() ||
eventTS.getFullYear() !== prevEvtDate.getFullYear())) { eventTS.getFullYear() !== prevEvtDate.getFullYear())) {
dateSeparator = <div className="date-separator"> const dateLabel = dateFormatter.format(eventTS)
dateSeparator = <div className="date-separator" role="separator" aria-label={dateLabel}>
<hr role="none"/> <hr role="none"/>
{dateFormatter.format(eventTS)} {dateLabel}
<hr role="none"/> <hr role="none"/>
</div> </div>
} }
@ -212,6 +217,8 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
className={wrapperClassNames.join(" ")} className={wrapperClassNames.join(" ")}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
onClick={!disableMenu && isMobileDevice ? onClick : undefined} onClick={!disableMenu && isMobileDevice ? onClick : undefined}
data-index={virtualIndex}
ref={ref}
> >
{!disableMenu && !isMobileDevice && <div {!disableMenu && !isMobileDevice && <div
className={`context-menu-container ${forceContextMenuOpen ? "force-open" : ""}`} className={`context-menu-container ${forceContextMenuOpen ? "force-open" : ""}`}
@ -276,10 +283,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
<ReadReceipts room={roomCtx.store} eventID={evt.event_id} />} <ReadReceipts room={roomCtx.store} eventID={evt.event_id} />}
{evt.sender === client.userID && evt.transaction_id ? <EventSendStatus evt={evt}/> : null} {evt.sender === client.userID && evt.transaction_id ? <EventSendStatus evt={evt}/> : null}
</div> </div>
return <> return mainEvent
{dateSeparator}
{mainEvent}
</>
} }
export default React.memo(TimelineEvent) export default React.memo(TimelineEvent)

View file

@ -1,9 +1,6 @@
div.timeline-view { div.timeline-view {
overflow-y: scroll; overflow-y: scroll;
contain: strict;
display: flex;
flex-direction: column;
justify-content: space-between;
> div.timeline-beginning { > div.timeline-beginning {
display: flex; display: flex;
@ -19,5 +16,15 @@ div.timeline-view {
> div.timeline-list { > div.timeline-list {
padding-bottom: 2rem; padding-bottom: 2rem;
width: 100%;
position: relative;
> div.timeline-virtual-items {
position: absolute;
top: 0;
left: 0;
width: 100%
}
} }
} }

View file

@ -13,6 +13,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 { Virtualizer, useVirtualizer } from "@tanstack/react-virtual"
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 { usePreference, useRoomTimeline } from "@/api/statestore" import { usePreference, useRoomTimeline } from "@/api/statestore"
@ -21,8 +22,40 @@ import useFocus from "@/util/focus.ts"
import ClientContext from "../ClientContext.ts" import ClientContext from "../ClientContext.ts"
import { useRoomContext } from "../roomview/roomcontext.ts" import { useRoomContext } from "../roomview/roomcontext.ts"
import TimelineEvent from "./TimelineEvent.tsx" import TimelineEvent from "./TimelineEvent.tsx"
import { getBodyType, isSmallEvent } from "./content/index.ts"
import "./TimelineView.css" import "./TimelineView.css"
// This is necessary to take into account margin, which the default measurement
// (using getBoundingClientRect) doesn't by default
const measureElement = (
element: Element, entry: ResizeObserverEntry | undefined, instance: Virtualizer<HTMLDivElement, Element>,
) => {
const horizontal = instance.options.horizontal
const style = window.getComputedStyle(element)
if (entry == null ? void 0 : entry.borderBoxSize) {
const box = entry?.borderBoxSize[0]
if (box) {
const size = Math.round(
box[horizontal ? "inlineSize" : "blockSize"],
)
return size
+ parseFloat(style[horizontal ? "marginInlineStart" : "marginBlockStart"])
+ parseFloat(style[horizontal ? "marginInlineEnd" : "marginBlockEnd"])
}
}
return Math.round(
element.getBoundingClientRect()[instance.options.horizontal ? "width" : "height"]
+ parseFloat(style[horizontal ? "marginLeft" : "marginTop"])
+ parseFloat(style[horizontal ? "marginRight" : "marginBottom"]),
)
}
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
const TimelineView = () => { const TimelineView = () => {
const roomCtx = useRoomContext() const roomCtx = useRoomContext()
const room = roomCtx.store const room = roomCtx.store
@ -30,20 +63,50 @@ const TimelineView = () => {
const client = use(ClientContext)! const client = use(ClientContext)!
const [isLoadingHistory, setLoadingHistory] = useState(false) const [isLoadingHistory, setLoadingHistory] = useState(false)
const [focusedEventRowID, directSetFocusedEventRowID] = useState<EventRowID | null>(null) const [focusedEventRowID, directSetFocusedEventRowID] = useState<EventRowID | null>(null)
const bottomRef = roomCtx.timelineBottomRef
const timelineViewRef = useRef<HTMLDivElement>(null)
const focused = useFocus()
const smallReplies = usePreference(client.store, room, "small_replies")
const virtualListRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: timeline.length,
getScrollElement: () => timelineViewRef.current,
estimateSize: (index) => timeline[index] ? estimateEventHeight(timeline[index]) : 0,
getItemKey: (index) => timeline[index]?.rowid || index,
overscan: 6,
measureElement,
})
const items = virtualizer.getVirtualItems()
const loadHistory = useCallback(() => { const loadHistory = useCallback(() => {
setLoadingHistory(true) setLoadingHistory(true)
client.loadMoreHistory(room.roomID) client.loadMoreHistory(room.roomID)
.catch(err => console.error("Failed to load history", err)) .catch(err => console.error("Failed to load history", err))
.finally(() => setLoadingHistory(false)) .then((loadedEventCount) => {
}, [client, room]) // Prevent scroll getting stuck loading more history
const bottomRef = roomCtx.timelineBottomRef if (loadedEventCount &&
const topRef = useRef<HTMLDivElement>(null) timelineViewRef.current &&
const timelineViewRef = useRef<HTMLDivElement>(null) timelineViewRef.current.scrollTop <= (virtualListRef.current?.offsetTop ?? 0)) {
const prevOldestTimelineRow = useRef(0) // FIXME: This seems to run before the events are measured,
const oldestTimelineRow = timeline[0]?.timeline_rowid // resulting in a jump in the timeline of the difference in
const oldScrollHeight = useRef(0) // height when scrolling very fast
const focused = useFocus() virtualizer.scrollToIndex(loadedEventCount, { align: "end" })
const smallReplies = usePreference(client.store, room, "small_replies") }
})
.finally(() => {
setLoadingHistory(false)
})
}, [client, room, virtualizer])
useLayoutEffect(() => {
if (roomCtx.scrolledToBottom) {
// timelineViewRef.current && (timelineViewRef.current.scrollTop = timelineViewRef.current.scrollHeight)
bottomRef.current?.scrollIntoView()
}
}, [roomCtx, timeline, virtualizer.getTotalSize(), bottomRef])
// When the user scrolls the timeline manually, remember if they were at the bottom, // When the user scrolls the timeline manually, remember if they were at the bottom,
// so that we can keep them at the bottom when new events are added. // so that we can keep them at the bottom when new events are added.
@ -54,24 +117,11 @@ 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.
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(() => { useEffect(() => {
const newestEvent = timeline[timeline.length - 1] const newestEvent = timeline[timeline.length - 1]
if ( if (
@ -95,26 +145,33 @@ const TimelineView = () => {
) )
} }
}, [focused, client, roomCtx, room, timeline]) }, [focused, client, roomCtx, room, timeline])
const firstItem = items[0]
useEffect(() => { useEffect(() => {
const topElem = topRef.current if (!room.hasMoreHistory || room.paginating) {
if (!topElem || !room.hasMoreHistory) {
return 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
// Load history if there is none
if (!firstItem) {
loadHistory()
return
}
// Load more history when the virtualizer loads the last item
if (firstItem.index == 0) {
console.log("Loading more history...")
loadHistory()
return
}
}, [
room.hasMoreHistory, loadHistory,
room.paginating,
firstItem,
])
return <div className="timeline-view" onScroll={handleScroll} ref={timelineViewRef}> return <div className="timeline-view" onScroll={handleScroll} ref={timelineViewRef}>
<div className="timeline-beginning"> <div className="timeline-beginning">
{room.hasMoreHistory ? <button onClick={loadHistory} disabled={isLoadingHistory}> {room.hasMoreHistory ? <button onClick={loadHistory} disabled={isLoadingHistory}>
@ -123,24 +180,47 @@ 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
<div className="timeline-top-ref" ref={topRef}/> style={{
{timeline.map(entry => { height: virtualizer.getTotalSize(),
if (!entry) { width: "100%",
return null position: "relative",
} }}
const thisEvt = <TimelineEvent className="timeline-list"
key={entry.rowid} ref={virtualListRef}
evt={entry} >
prevEvt={prevEvt} <div
smallReplies={smallReplies} style={{
isFocused={focusedEventRowID === entry.rowid} position: "absolute",
/> top: 0,
prevEvt = entry left: 0,
return thisEvt width: "100%",
})} transform: `translateY(${items[0]?.start ?? 0}px)`,
<div className="timeline-bottom-ref" ref={bottomRef}/> }}
className="timeline-virtual-items"
>
{items.map((virtualRow) => {
const entry = timeline[virtualRow.index]
if (!entry) {
return null
}
const thisEvt = <TimelineEvent
evt={entry}
prevEvt={timeline[virtualRow.index - 1] ?? null}
smallReplies={smallReplies}
isFocused={focusedEventRowID === entry.rowid}
key={virtualRow.key}
virtualIndex={virtualRow.index}
ref={virtualizer.measureElement}
/>
return thisEvt
})}
</div>
</div> </div>
<div className="timeline-bottom-ref" ref={bottomRef}/>
</div> </div>
} }