Add virtualization to room chat

This commit is contained in:
Jon Staab 2026-08-31 08:07:09 -07:00
parent 251e73663f
commit 9f3521779c
2 changed files with 162 additions and 33 deletions

View file

@ -21,6 +21,8 @@
import Divider from "@lib/components/Divider.svelte"
import Icon from "@lib/components/Icon.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import VirtualList from "@lib/components/VirtualList.svelte"
import type {VirtualListController} from "@lib/components/VirtualList.svelte"
import RoomCompose from "@app/components/RoomCompose.svelte"
import RoomComposeEdit from "@app/components/RoomComposeEdit.svelte"
import RoomComposeParent from "@app/components/RoomComposeParent.svelte"
@ -232,6 +234,8 @@
}
}
const getElementKey = (element: {id: string}) => element.id
const manageScrollPosition = () => {
// Only treat an `at` jump as "scrolled up" when it targets an event below the
// newest one; jumping to the most recent message already lands us at the bottom.
@ -259,6 +263,10 @@
const targetEvent = $events.find(event => event.created_at >= at)
if (targetEvent) {
// The list renders from the newest message outward, so a jump target deep in history
// may not be on the page yet
virtualList?.reveal(targetEvent.id)
const target = element?.querySelector(`[data-event="${targetEvent.id}"]`)
if (target instanceof HTMLElement) {
@ -280,8 +288,16 @@
isProgrammaticScroll = false
}
const scrollToNewMessages = () =>
document.getElementById("new-messages")?.scrollIntoView({behavior: "smooth", block: "center"})
const scrollToNewMessages = () => {
virtualList?.reveal("new-messages")
// Revealing it renders it on the next flush, so the scroll has to wait for that
requestAnimationFrame(() =>
document
.getElementById("new-messages")
?.scrollIntoView({behavior: "smooth", block: "center"}),
)
}
const scrollToBottom = () => {
if (!isNaN(at)) {
@ -308,6 +324,7 @@
let userHasScrolled = $state(false)
let isProgrammaticScroll = $state(false)
let isUserScrolling = $state(false)
let virtualList: Maybe<VirtualListController> = $state()
let older: Maybe<ReturnType<typeof makeScrollLoader>> = $state()
let newer: Maybe<ReturnType<typeof makeScrollLoader>> = $state()
let share: Maybe<Share> = $state()
@ -568,39 +585,45 @@
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p>
{/if}
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "new-messages"}
<div
{id}
class={cx("flex items-center py-2 text-xs transition-colors", {
"opacity-0": showFixedNewMessages,
})}>
<div class="h-px grow bg-primary text-primary-content"></div>
<p
class="rounded-full bg-primary text-primary-content px-2 py-1"
style="color: var(--primary-content)">
New Messages
</p>
<div class="h-px grow bg-primary text-primary-content"></div>
</div>
{:else if type === "date"}
<Divider>{value}</Divider>
{:else}
{@const event = value as TrustedEvent}
{#if event.kind === addMemberKind}
<RoomItemAddMember {url} {event} />
<VirtualList
items={elements}
getKey={getElementKey}
container={element}
bind:controller={virtualList}>
{#snippet row({type, id, value, showPubkey})}
{#if type === "new-messages"}
<div
{id}
class={cx("flex items-center py-2 text-xs transition-colors", {
"opacity-0": showFixedNewMessages,
})}>
<div class="h-px grow bg-primary text-primary-content"></div>
<p
class="rounded-full bg-primary text-primary-content px-2 py-1"
style="color: var(--primary-content)">
New Messages
</p>
<div class="h-px grow bg-primary text-primary-content"></div>
</div>
{:else if type === "date"}
<Divider>{value}</Divider>
{:else}
<RoomItem
{url}
{event}
{replyTo}
{showPubkey}
{context}
canEdit={canEditEvent}
onEdit={onEditEvent} />
{@const event = value as TrustedEvent}
{#if event.kind === addMemberKind}
<RoomItemAddMember {url} {event} />
{:else}
<RoomItem
{url}
{event}
{replyTo}
{showPubkey}
{context}
canEdit={canEditEvent}
onEdit={onEditEvent} />
{/if}
{/if}
{/if}
{/each}
{/snippet}
</VirtualList>
<p class="flex h-10 items-center justify-center py-20">
{#if loadingBackward}
<Spinner loading>Looking for messages...</Spinner>

View file

@ -0,0 +1,106 @@
<script lang="ts" module>
export type VirtualListController = {
// Render a row that isn't on screen yet, so the caller can then find it in the DOM
reveal: (key: string) => void
}
</script>
<script lang="ts" generics="T">
import type {Snippet} from "svelte"
import type {Maybe} from "@welshman/lib"
type Props = {
items: T[]
getKey: (item: T) => string
// The scrolling ancestor these rows are laid out in
container?: HTMLElement
// Rows to add each time the end of what's rendered comes into reach
chunk?: number
// How close to that end the viewport has to get before more are added
threshold?: number
controller?: VirtualListController
row: Snippet<[T]>
}
let {
items,
getKey,
container,
chunk = 40,
threshold = 2000,
controller = $bindable(),
row,
}: Props = $props()
// Rows are only ever added — never removed, never stood in for by a spacer, never assigned a
// guessed height. Everything on screen is real, so the scrollbar is honest and nothing the
// reader is looking at can shift under them. Guessing at the height of rows that have never
// been mounted is what makes a virtualised list lurch, and a list that only grows never has to.
//
// The cost is that a long scrollback ends up holding every row it passed, which is what the
// list did all the time before. What this buys is the opening render.
let edgeKey: Maybe<string> = $state()
// Held by key rather than index: messages arriving at the origin shift every index along, and
// a window pinned to a number would slide off the rows already on screen.
const mounted = $derived.by(() => {
const index = edgeKey ? items.findIndex(item => getKey(item) === edgeKey) : -1
return index > -1 ? index + 1 : Math.min(chunk, items.length)
})
const visible = $derived(items.slice(0, mounted))
const grow = () => {
const next = Math.min(items.length, mounted + chunk)
if (next > mounted) {
edgeKey = getKey(items[next - 1])
}
}
// Distance left between the viewport and the end of what's rendered. Measured off the
// container rather than modelled, so anything else the caller puts in there — a spinner, an
// end-of-history notice — is simply part of it.
const fill = () => {
if (container && mounted < items.length) {
const scrolled = Math.abs(container.scrollTop)
const remaining = container.scrollHeight - container.clientHeight - scrolled
if (remaining < threshold) {
grow()
}
}
}
controller = {
reveal: (key: string) => {
const index = items.findIndex(item => getKey(item) === key)
if (index >= mounted) {
edgeKey = getKey(items[index])
}
},
}
// One growth step may still not reach the threshold, so check again once it has rendered
$effect(() => {
if (items.length > mounted) {
const frame = requestAnimationFrame(fill)
return () => cancelAnimationFrame(frame)
}
})
$effect(() => {
if (container) {
container.addEventListener("scroll", fill, {passive: true})
return () => container.removeEventListener("scroll", fill)
}
})
</script>
{#each visible as item (getKey(item))}
{@render row(item)}
{/each}