Add virtualization to room chat
This commit is contained in:
parent
251e73663f
commit
9f3521779c
2 changed files with 162 additions and 33 deletions
|
|
@ -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>
|
||||
|
|
|
|||
106
src/lib/components/VirtualList.svelte
Normal file
106
src/lib/components/VirtualList.svelte
Normal 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}
|
||||
Loading…
Reference in a new issue