Add forward scrolling to makeMakeFeed

This commit is contained in:
Jon Staab
2026-02-16 13:31:43 -08:00
parent ca7d126a3c
commit dde9dbfbfe
7 changed files with 150 additions and 77 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ If you're deploying a custom version of flotilla, be sure to remove the `plausib
## Development
See [./CONTRIBUTING.md](CONTRIBUTING.md).
See [CONTRIBUTING.md](AGENTS.md).
## Deployment
+80 -40
View File
@@ -9,9 +9,11 @@ import {
sortBy,
now,
on,
between,
isDefined,
filterVals,
fromPairs,
HOUR,
} from "@welshman/lib"
import {
EVENT_TIME,
@@ -23,9 +25,8 @@ import {
getRelaysFromList,
} from "@welshman/util"
import type {TrustedEvent, Filter, List} from "@welshman/util"
import {feedFromFilters, makeRelayFeed, makeIntersectionFeed} from "@welshman/feeds"
import {load, request} from "@welshman/net"
import {repository, makeFeedController, loadRelay, tracker} from "@welshman/app"
import {repository, loadRelay, tracker} from "@welshman/app"
import {createScroller} from "@lib/html"
import {daysBetween} from "@lib/util"
import {getEventsForUrl} from "@app/core/state"
@@ -36,55 +37,61 @@ export const makeFeed = ({
url,
filters,
element,
onExhausted,
onBackwardExhausted,
onForwardExhausted,
at = now(),
}: {
url: string
filters: Filter[]
element: HTMLElement
onExhausted?: () => void
onBackwardExhausted?: () => void
onForwardExhausted?: () => void
at?: number
}) => {
const seen = new Set<string>()
const interval = int(12, HOUR)
const controller = new AbortController()
const buffer = writable<TrustedEvent[]>([])
const events = writable<TrustedEvent[]>([])
let buffer: TrustedEvent[] = []
let backwardWindow = [at - interval, at]
let forwardWindow = [at, at + interval]
const insertEvent = (event: TrustedEvent) => {
let handled = false
if (seen.has(event.id)) {
return
}
if (between([backwardWindow[0], forwardWindow[1]], event.created_at)) {
const $events = get(events)
events.update($events => {
for (let i = 0; i < $events.length; i++) {
if ($events[i].id === event.id) return $events
if ($events[i].created_at < event.created_at) {
if ($events[i].created_at > event.created_at) {
events.set(insertAt(i, event, $events))
handled = true
return insertAt(i, event, $events)
break
}
}
return $events
})
if (!handled) {
buffer.update($buffer => {
for (let i = 0; i < $buffer.length; i++) {
if ($buffer[i].id === event.id) return $buffer
if ($buffer[i].created_at < event.created_at) return insertAt(i, event, $buffer)
if (!handled) {
events.set([...$events, event])
}
} else {
for (let i = 0; i < buffer.length; i++) {
if (buffer[i].created_at > event.created_at) {
buffer.splice(i, 0, event)
handled = true
break
}
}
return [...$buffer, event]
})
if (!handled) {
buffer.push(event)
}
}
seen.add(event.id)
}
const unsubscribers = [
on(repository, "update", ({added, removed}) => {
if (removed.size > 0) {
buffer.update($buffer => $buffer.filter(e => !removed.has(e.id)))
buffer = buffer.filter(e => !removed.has(e.id))
events.update($events => $events.filter(e => !removed.has(e.id)))
}
@@ -105,24 +112,56 @@ export const makeFeed = ({
}),
]
const ctrl = makeFeedController({
useWindowing: true,
signal: controller.signal,
feed: makeIntersectionFeed(makeRelayFeed(url), feedFromFilters(filters)),
onExhausted,
})
const loadTimeframe = (since: number, until: number) => {
request({
relays: [url],
autoClose: true,
signal: controller.signal,
filters: filters.map(filter => ({...filter, since, until})),
})
}
const scroller = createScroller({
const backwardScroller = createScroller({
element,
delay: 300,
threshold: 10_000,
onScroll: async () => {
const $buffer = get(buffer)
threshold: 5000,
onScroll: () => {
const [since, until] = backwardWindow
events.update($events => [...$events, ...$buffer.splice(0, 30)])
backwardWindow = [since - interval, since]
if ($buffer.length < 100) {
ctrl.load(100)
for (const event of buffer.splice(0)) {
insertEvent(event)
}
if (until > now() - int(2, YEAR)) {
loadTimeframe(since, until)
} else if (!buffer.some(e => e.created_at < at)) {
backwardScroller.stop()
onBackwardExhausted?.()
}
},
})
const forwardScroller = createScroller({
element,
reverse: true,
delay: 300,
threshold: 5000,
onScroll: () => {
const [since, until] = forwardWindow
forwardWindow = [until, until + interval]
for (const event of buffer.splice(0)) {
insertEvent(event)
}
if (until < now()) {
loadTimeframe(since, until)
} else if (!buffer.some(e => e.created_at > at)) {
forwardScroller.stop()
onForwardExhausted?.()
}
},
})
@@ -134,8 +173,9 @@ export const makeFeed = ({
return {
events,
cleanup: () => {
scroller.stop()
controller.abort()
forwardScroller.stop()
backwardScroller.stop()
unsubscribers.forEach(call)
},
}
+26 -11
View File
@@ -186,11 +186,20 @@
const scrollToNewMessages = () =>
document.getElementById("new-messages")?.scrollIntoView({behavior: "smooth", block: "center"})
const scrollToBottom = () => element?.scrollTo({top: 0, behavior: "smooth"})
const scrollToBottom = () => {
if ($page.url.searchParams.get("at")) {
at = now()
start()
} else {
element?.scrollTo({top: 0, behavior: "smooth"})
}
}
let joining = $state(false)
let leaving = $state(false)
let loadingEvents = $state(true)
let loadingBackward = $state(true)
let loadingForward = $state(true)
let at = $state(parseInt($page.url.searchParams.get("at") || String(now())))
let share = $state(popKey<TrustedEvent | undefined>("share"))
let parent: TrustedEvent | undefined = $state()
let element: HTMLElement | undefined = $state()
@@ -221,7 +230,7 @@
const adjustedLastChecked =
lastChecked && lastUserEvent ? Math.max(lastUserEvent.created_at, lastChecked) : lastChecked
for (const event of $events.toReversed()) {
for (const event of $events) {
if (seen.has(event.id)) {
continue
}
@@ -272,11 +281,15 @@
cleanup?.()
const feed = makeFeed({
at,
url,
element: element!,
filters: [{kinds: [...MESSAGE_KINDS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]}],
onExhausted: () => {
loadingEvents = false
onBackwardExhausted: () => {
loadingBackward = false
},
onForwardExhausted: () => {
loadingForward = false
},
})
@@ -319,14 +332,11 @@
start()
return () => {
cleanup()
observer.unobserve(chatCompose!)
observer.unobserve(dynamicPadding!)
}
})
onDestroy(() => {
cleanup?.()
})
</script>
<PageBar>
@@ -373,6 +383,11 @@
</div>
</div>
{:else}
{#if loadingForward}
<p class="py-20 flex justify-center">
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p>
{/if}
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "new-messages"}
<div
@@ -405,8 +420,8 @@
{/if}
{/each}
<p class="flex h-10 items-center justify-center py-20">
{#if loadingEvents}
<Spinner loading={loadingEvents}>Looking for messages...</Spinner>
{#if loadingBackward}
<Spinner loading={loadingBackward}>Looking for messages...</Spinner>
{:else}
<Spinner>End of message history</Spinner>
{/if}
+40 -22
View File
@@ -124,9 +124,18 @@
const scrollToNewMessages = () =>
document.getElementById("new-messages")?.scrollIntoView({behavior: "smooth", block: "center"})
const scrollToBottom = () => element?.scrollTo({top: 0, behavior: "smooth"})
const scrollToBottom = () => {
if ($page.url.searchParams.get("at")) {
at = now()
start()
} else {
element?.scrollTo({top: 0, behavior: "smooth"})
}
}
let loadingEvents = $state(true)
let loadingBackward = $state(true)
let loadingForward = $state(true)
let at = $state(parseInt($page.url.searchParams.get("at") || String(now())))
let share = $state(popKey<TrustedEvent | undefined>("share"))
let parent: TrustedEvent | undefined = $state()
let element: HTMLElement | undefined = $state()
@@ -157,7 +166,7 @@
const adjustedLastChecked =
lastChecked && lastUserEvent ? Math.max(lastUserEvent.created_at, lastChecked) : lastChecked
for (const event of $events.toReversed()) {
for (const event of $events) {
if (seen.has(event.id)) {
continue
}
@@ -204,6 +213,26 @@
return elements
})
const start = () => {
cleanup?.()
const feed = makeFeed({
at,
url,
element: element!,
filters: [{kinds: [...MESSAGE_KINDS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]}],
onBackwardExhausted: () => {
loadingBackward = false
},
onForwardExhausted: () => {
loadingForward = false
},
})
events = feed.events
cleanup = feed.cleanup
}
const onEscape = () => {
clearParent()
clearShare()
@@ -238,29 +267,13 @@
observer.observe(chatCompose!)
observer.observe(dynamicPadding!)
const feed = makeFeed({
url,
element: element!,
filters: [{kinds: [...MESSAGE_KINDS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]}],
onExhausted: () => {
loadingEvents = false
},
})
events = feed.events
cleanup = feed.cleanup
start()
return () => {
cleanup()
controller.abort()
observer.unobserve(chatCompose!)
observer.unobserve(dynamicPadding!)
// Sveltekit calls onDestroy at the beginning of the page load for some reason
setTimeout(() => {
setChecked($page.url.pathname)
}, 800)
}
})
</script>
@@ -284,6 +297,11 @@
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4">
<div bind:this={dynamicPadding}></div>
{#if loadingForward}
<p class="py-20 flex justify-center">
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p>
{/if}
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "new-messages"}
<div
@@ -316,8 +334,8 @@
{/if}
{/each}
<p class="flex h-10 items-center justify-center py-20">
{#if loadingEvents}
<Spinner loading={loadingEvents}>Looking for messages...</Spinner>
{#if loadingBackward}
<Spinner loading={loadingBackward}>Looking for messages...</Spinner>
{:else}
<Spinner>End of message history</Spinner>
{/if}
@@ -49,7 +49,7 @@
url,
element: element!,
filters: [{kinds: [CLASSIFIED]}, makeCommentFilter([CLASSIFIED])],
onExhausted: () => {
onBackwardExhausted: () => {
loading = false
},
})
+1 -1
View File
@@ -48,7 +48,7 @@
url,
element: element!,
filters: [{kinds: [ZAP_GOAL]}, makeCommentFilter([ZAP_GOAL])],
onExhausted: () => {
onBackwardExhausted: () => {
loading = false
},
})
@@ -49,7 +49,7 @@
url,
element: element!,
filters: [{kinds: [THREAD]}, makeCommentFilter([THREAD])],
onExhausted: () => {
onBackwardExhausted: () => {
loading = false
},
})