From dde9dbfbfefedf52303d84138d9ea59a16b4c0ca Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 16 Feb 2026 13:31:43 -0800 Subject: [PATCH] Add forward scrolling to makeMakeFeed --- README.md | 2 +- src/app/core/requests.ts | 120 ++++++++++++------ src/routes/spaces/[relay]/[h]/+page.svelte | 37 ++++-- src/routes/spaces/[relay]/chat/+page.svelte | 62 +++++---- .../spaces/[relay]/classifieds/+page.svelte | 2 +- src/routes/spaces/[relay]/goals/+page.svelte | 2 +- .../spaces/[relay]/threads/+page.svelte | 2 +- 7 files changed, 150 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 51731e26..29e880ec 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/app/core/requests.ts b/src/app/core/requests.ts index abfecfc6..92bf1a34 100644 --- a/src/app/core/requests.ts +++ b/src/app/core/requests.ts @@ -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() + const interval = int(12, HOUR) const controller = new AbortController() - const buffer = writable([]) const events = writable([]) + 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) }, } diff --git a/src/routes/spaces/[relay]/[h]/+page.svelte b/src/routes/spaces/[relay]/[h]/+page.svelte index 201add1c..8768938b 100644 --- a/src/routes/spaces/[relay]/[h]/+page.svelte +++ b/src/routes/spaces/[relay]/[h]/+page.svelte @@ -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("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?.() - }) @@ -373,6 +383,11 @@ {:else} + {#if loadingForward} +

+ Looking for messages... +

+ {/if} {#each elements as { type, id, value, showPubkey } (id)} {#if type === "new-messages"}
- {#if loadingEvents} - Looking for messages... + {#if loadingBackward} + Looking for messages... {:else} End of message history {/if} diff --git a/src/routes/spaces/[relay]/chat/+page.svelte b/src/routes/spaces/[relay]/chat/+page.svelte index 5a233166..5c98c2dc 100644 --- a/src/routes/spaces/[relay]/chat/+page.svelte +++ b/src/routes/spaces/[relay]/chat/+page.svelte @@ -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("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) } }) @@ -284,6 +297,11 @@
+ {#if loadingForward} +

+ Looking for messages... +

+ {/if} {#each elements as { type, id, value, showPubkey } (id)} {#if type === "new-messages"}
- {#if loadingEvents} - Looking for messages... + {#if loadingBackward} + Looking for messages... {:else} End of message history {/if} diff --git a/src/routes/spaces/[relay]/classifieds/+page.svelte b/src/routes/spaces/[relay]/classifieds/+page.svelte index a274ddbe..bbca8fb0 100644 --- a/src/routes/spaces/[relay]/classifieds/+page.svelte +++ b/src/routes/spaces/[relay]/classifieds/+page.svelte @@ -49,7 +49,7 @@ url, element: element!, filters: [{kinds: [CLASSIFIED]}, makeCommentFilter([CLASSIFIED])], - onExhausted: () => { + onBackwardExhausted: () => { loading = false }, }) diff --git a/src/routes/spaces/[relay]/goals/+page.svelte b/src/routes/spaces/[relay]/goals/+page.svelte index bd9c6a44..d1475ce8 100644 --- a/src/routes/spaces/[relay]/goals/+page.svelte +++ b/src/routes/spaces/[relay]/goals/+page.svelte @@ -48,7 +48,7 @@ url, element: element!, filters: [{kinds: [ZAP_GOAL]}, makeCommentFilter([ZAP_GOAL])], - onExhausted: () => { + onBackwardExhausted: () => { loading = false }, }) diff --git a/src/routes/spaces/[relay]/threads/+page.svelte b/src/routes/spaces/[relay]/threads/+page.svelte index adddc5e0..a4079b1b 100644 --- a/src/routes/spaces/[relay]/threads/+page.svelte +++ b/src/routes/spaces/[relay]/threads/+page.svelte @@ -49,7 +49,7 @@ url, element: element!, filters: [{kinds: [THREAD]}, makeCommentFilter([THREAD])], - onExhausted: () => { + onBackwardExhausted: () => { loading = false }, })