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