From 251e73663fbeee0306b1e38b2dd634d562789eb8 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Sat, 29 Aug 2026 17:42:07 -0700 Subject: [PATCH] Re-work feeds for performance --- src/app/components/ProfilePageNotes.svelte | 22 +- src/app/components/RoomChat.svelte | 43 +- src/app/feeds.ts | 441 +++++++++--------- src/lib/html.ts | 31 +- .../spaces/[relay]/articles/+page.svelte | 25 +- .../spaces/[relay]/calendar/+page.svelte | 25 +- .../spaces/[relay]/classifieds/+page.svelte | 25 +- src/routes/spaces/[relay]/goals/+page.svelte | 25 +- src/routes/spaces/[relay]/polls/+page.svelte | 25 +- .../spaces/[relay]/threads/+page.svelte | 23 +- 10 files changed, 373 insertions(+), 312 deletions(-) diff --git a/src/app/components/ProfilePageNotes.svelte b/src/app/components/ProfilePageNotes.svelte index 29381080..2f0791d2 100644 --- a/src/app/components/ProfilePageNotes.svelte +++ b/src/app/components/ProfilePageNotes.svelte @@ -3,6 +3,7 @@ import {derived, writable} from "svelte/store" import type {Writable} from "svelte/store" import {sortBy, uniqBy, now} from "@welshman/lib" + import type {Maybe} from "@welshman/lib" import {NOTE, outbox} from "@welshman/util" import type {TrustedEvent} from "@welshman/util" import {getReplyTags} from "@welshman/domain" @@ -11,7 +12,7 @@ import Spinner from "@lib/components/Spinner.svelte" import NoteItem from "@app/components/NoteItem.svelte" import {app, network, router} from "@app/core" - import {makeFeed, makeFeedContext} from "@app/feeds" + import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds" type Props = { pubkey: string @@ -44,7 +45,10 @@ }) let element: HTMLElement | undefined = $state() - let exhausted = $state(false) + let older: Maybe> = $state() + + const exhausted = $derived($older?.status === "exhausted") + const loading = $derived(isFeedLoading($older)) let events: Writable = $state(writable([])) const feedEvents = $derived( @@ -60,16 +64,18 @@ relays.then($relays => { const feed = makeFeed({ relays: $relays, - element: element!, filters: [{kinds: [NOTE], authors: [pubkey]}], onEvent: context.add, - onBackwardExhausted: () => { - exhausted = true - }, }) events = feed.events - cleanup = feed.cleanup + + older = makeScrollLoader(element!, feed.loadOlder) + + cleanup = () => { + older?.stop() + feed.cleanup() + } }) return () => cleanup?.() @@ -86,7 +92,7 @@

No notes found for this profile.

{/if} {/each} - {#if !exhausted} + {#if loading}

Loading notes... diff --git a/src/app/components/RoomChat.svelte b/src/app/components/RoomChat.svelte index c98a8504..58ce9963 100644 --- a/src/app/components/RoomChat.svelte +++ b/src/app/components/RoomChat.svelte @@ -41,7 +41,7 @@ prependParent, } from "@app/rooms" import {userSettingsValues} from "@app/settings" - import {makeFeed, makeFeedContext} from "@app/feeds" + import {makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds" import {checked, deferredRoomPath, setChecked} from "@app/notifications" import {makeRoomPath} from "@app/routes" import {pendingShare, type Share} from "@app/share" @@ -308,8 +308,8 @@ let userHasScrolled = $state(false) let isProgrammaticScroll = $state(false) let isUserScrolling = $state(false) - let loadingBackward = $state(true) - let loadingForward = $state(true) + let older: Maybe> = $state() + let newer: Maybe> = $state() let share: Maybe = $state() let parent: TrustedEvent | undefined = $state() let element: HTMLElement | undefined = $state() @@ -324,6 +324,11 @@ let compose: RoomCompose | undefined = $state() let eventToEdit: TrustedEvent | undefined = $state() + // There is always more history until the feed says otherwise, so this stays up rather than + // blinking between spans while it walks a quiet room. + const loadingBackward = $derived($older?.status !== "exhausted") + const reachedStartOfHistory = $derived($older?.status === "exhausted") + // Claim the share once we're on screen. Sharing into the room you're already looking at // doesn't re-create this component, so this can't be read once on mount. $effect(() => { @@ -415,6 +420,14 @@ return elements }) + // Newer messages are only worth waiting for when the window stops short of the present, which + // only happens after jumping into history — anything published from here on arrives through the + // repository rather than through a forward walk. And with no messages between them the two + // loaders would sit against each other, so this one yields while the other is still running. + const loadingForward = $derived( + !isNaN(at) && $newer?.status !== "exhausted" && !(elements.length === 0 && loadingBackward), + ) + $effect(() => { if (elements.length > 0 && !isUserScrolling) { requestAnimationFrame(manageScrollPosition) @@ -427,21 +440,23 @@ const feed = makeFeed({ relays: [url], at: at || now(), - element: element!, filters: [ h ? {kinds: [MESSAGE, addMemberKind], "#h": [h]} : {kinds: [MESSAGE, addMemberKind]}, ], onEvent: context.add, - onBackwardExhausted: () => { - loadingBackward = false - }, - onForwardExhausted: () => { - loadingForward = false - }, }) + // The container is reversed, so scrolling away from its origin reaches older messages and + // sitting at the origin is the newest end. + older = makeScrollLoader(element!, feed.loadOlder) + newer = makeScrollLoader(element!, feed.loadNewer, {reverse: true}) + events = feed.events - cleanup = feed.cleanup + cleanup = () => { + older?.stop() + newer?.stop() + feed.cleanup() + } } const onEscape = () => { @@ -548,7 +563,7 @@ {:else} - {#if loadingForward && elements.length > 0} + {#if loadingForward}

Looking for messages...

@@ -588,8 +603,8 @@ {/each}

{#if loadingBackward} - Looking for messages... - {:else} + Looking for messages... + {:else if reachedStartOfHistory} End of message history {/if}

diff --git a/src/app/feeds.ts b/src/app/feeds.ts index 783b55bd..96632394 100644 --- a/src/app/feeds.ts +++ b/src/app/feeds.ts @@ -1,6 +1,6 @@ -import {derived, readable, writable} from "svelte/store" -import type {Readable} from "svelte/store" -import {batch, between, call, int, now, on, sortBy, uniqBy, MONTH, YEAR} from "@welshman/lib" +import {derived, get, readable, writable} from "svelte/store" +import type {Readable, Writable} from "svelte/store" +import {batch, call, int, now, on, sortBy, uniqBy, MONTH, YEAR} from "@welshman/lib" import { COMMENT, DELETE, @@ -16,10 +16,12 @@ import { tagValue, tagValues, } from "@welshman/util" +import type {Maybe} from "@welshman/lib" import type {Filter, TrustedEvent} from "@welshman/util" import {mergeRepositoryUpdates} from "@welshman/net" import type {RepositoryUpdate} from "@welshman/net" import {createScroller} from "@lib/html" +import type {ScrollerOpts} from "@lib/html" import {daysBetween} from "@lib/util" import {EVENT_CONTEXT_KINDS, REACTION_KINDS} from "@app/content" import {app, network} from "@app/core" @@ -203,100 +205,24 @@ export const makeFeedContext = ({relays}: {relays: string[] | Promise} export type FeedContext = ReturnType -export const makeFeed = ({ +// Keeps a feed's store in step with the repository: events that arrive later, events that get +// deleted, and events already held that have only now been seen on one of the feed's relays. +const syncFeed = ({ relays, filters, - element, - onEvent, - onBackwardExhausted, - onForwardExhausted, - at = now(), + events, + seen, + insertEvents, + requireRelay = true, }: { relays: string[] filters: Filter[] - element: HTMLElement - onEvent?: (event: TrustedEvent) => void - onBackwardExhausted?: () => void - onForwardExhausted?: () => void - at?: number + events: Writable + seen: Set + insertEvents: (events: TrustedEvent[]) => void + // Whether an event has to have been seen on one of `relays` to belong to this feed + requireRelay?: boolean }) => { - const controller = new AbortController() - const events = writable([]) - const seen = new Set() - - let interval = int(6, MONTH) - let buffer: TrustedEvent[] = [] - let backwardWindow = [at - interval, at] - let forwardWindow = [at, at + interval] - - const insertIntoBuffer = (event: TrustedEvent) => { - for (let i = 0; i < buffer.length; i++) { - if (buffer[i].created_at < event.created_at) { - buffer.splice(i, 0, event) - return - } - } - buffer.push(event) - } - - // Batch-insert events into the visible store with a single update - const insertEvents = (newEvents: Iterable) => { - const visible: TrustedEvent[] = [] - - for (const event of newEvents) { - if (seen.has(event.id)) { - continue - } - - seen.add(event.id) - - if (between([backwardWindow[0], forwardWindow[1]], event.created_at)) { - visible.push(event) - } else { - insertIntoBuffer(event) - } - } - - if (visible.length > 0) { - visible.sort((a, b) => a.created_at - b.created_at) - - for (const event of visible) { - onEvent?.(event) - } - - events.update($events => { - const merged: TrustedEvent[] = [] - let i = 0 - let j = 0 - - while (i < $events.length && j < visible.length) { - if ($events[i].created_at <= visible[j].created_at) { - merged.push($events[i++]) - } else { - merged.push(visible[j++]) - } - } - - while (i < $events.length) merged.push($events[i++]) - while (j < visible.length) merged.push(visible[j++]) - - return merged - }) - } - } - - // Buffered events are routed through insertEvents again, so forget we've seen - // them to let the window check run a second time - const drainBuffer = () => { - const drained = buffer.splice(0, 30) - - for (const event of drained) { - seen.delete(event.id) - } - - insertEvents(drained) - } - const onTrackedId = batch(150, (ids: string[]) => { const matching: TrustedEvent[] = [] @@ -313,7 +239,7 @@ export const makeFeed = ({ } }) - const unsubscribers = [ + return [ on( app.get().repository, "update", @@ -321,7 +247,6 @@ export const makeFeed = ({ const {added, removed} = mergeRepositoryUpdates(updates) if (removed.size > 0) { - buffer = buffer.filter(e => !removed.has(e.id)) events.update($events => $events.filter(e => !removed.has(e.id))) for (const id of removed) { @@ -332,7 +257,7 @@ export const makeFeed = ({ const matching = added.filter( event => matchFilters(filters, event) && - relays.some(url => app.get().tracker.getRelays(event.id).has(url)), + (!requireRelay || relays.some(url => app.get().tracker.getRelays(event.id).has(url))), ) if (matching.length > 0) { @@ -346,63 +271,181 @@ export const makeFeed = ({ } }), ] +} + +// One direction of a feed. A span that comes back empty is a gap in the timeline, not the end of +// it — conflating the two either stops loading at the first gap or walks the whole history +// looking for the end of one. +export type FeedLoadState = + | {status: "idle"} + | {status: "loading"} + | {status: "searching"} + | {status: "exhausted"} + +// Empty spans to walk per trigger. Enough to cross a gap; not enough to reach the end of the +// history on a single request. +const SPANS_PER_TRIGGER = 3 + +// Whether a request is actually in flight, which is not the same as whether more might exist. A +// list that already has what it needs shouldn't sit under a spinner just because it hasn't +// walked to the end of the history. +export const isFeedLoading = (state: Maybe) => + state?.status === "loading" || state?.status === "searching" + +const makeFeedLoader = (load: () => Promise<{found: number; exhausted: boolean}>) => { + const state = writable({status: "idle"}) + + let running = false + + const run = async () => { + if (running || get(state).status === "exhausted") return + + running = true + + try { + for (let span = 0; span < SPANS_PER_TRIGGER; span++) { + state.set({status: span > 0 ? "searching" : "loading"}) + + const {found, exhausted} = await load() + + if (exhausted) { + state.set({status: "exhausted"}) + return + } + + if (found > 0) { + state.set({status: "idle"}) + return + } + } + + state.set({status: "idle"}) + } finally { + running = false + } + } + + return {subscribe: state.subscribe, run} +} + +// A loader triggered by proximity to the end of a scroll container, which is how every list in +// the app pages. The container's orientation decides which direction `reverse` reaches, so the +// caller passes it — a reversed chat scrolls away from its origin to find older messages, an +// ordinary feed scrolls toward the end of its own content. +export const makeScrollLoader = ( + element: HTMLElement, + load: () => Promise<{found: number; exhausted: boolean}>, + options: Partial = {}, +) => { + const loader = makeFeedLoader(load) + const scroller = createScroller({ + element, + delay: 300, + threshold: 5000, + ...options, + onScroll: loader.run, + }) + + return {subscribe: loader.subscribe, stop: scroller.stop} +} + +// Holds every event a view has loaded, sorted oldest to newest, and knows how to ask for the +// next span in either direction. It does not decide *when* to ask — the view does, because the +// view is what knows what is on screen. +export const makeFeed = ({ + relays, + filters, + onEvent, + at = now(), +}: { + relays: string[] + filters: Filter[] + onEvent?: (event: TrustedEvent) => void + at?: number +}) => { + const controller = new AbortController() + const events = writable([]) + const seen = new Set() + + // The span the relays have been asked about, which grows outward from the anchor + let oldest = at + let newest = at + let interval = int(MONTH) + + const insertEvents = (newEvents: Iterable) => { + const added: TrustedEvent[] = [] + + for (const event of newEvents) { + if (!seen.has(event.id)) { + seen.add(event.id) + added.push(event) + onEvent?.(event) + } + } + + if (added.length > 0) { + added.sort((a, b) => a.created_at - b.created_at) + + events.update($events => { + const merged: TrustedEvent[] = [] + let i = 0 + let j = 0 + + while (i < $events.length && j < added.length) { + if ($events[i].created_at <= added[j].created_at) { + merged.push($events[i++]) + } else { + merged.push(added[j++]) + } + } + + while (i < $events.length) merged.push($events[i++]) + while (j < added.length) merged.push(added[j++]) + + return merged + }) + } + } + + const unsubscribers = syncFeed({relays, filters, events, seen, insertEvents}) const loadTimeframe = async (since: number, until: number) => { - const events = await network.get().request({ + const found = await network.get().request({ relays, autoClose: true, signal: controller.signal, filters: filters.map(filter => ({...filter, since, until})), }) - // If we found nothing, accelerate - if (events.length === 0) { - interval = Math.round(interval * 1.1) - } else { - interval = int(MONTH) - } + // A span that turns up nothing widens the next one, so walking a sparse history doesn't + // take dozens of round trips + interval = found.length > 0 ? int(MONTH) : Math.round(interval * 1.5) + + return found.length } - const backwardScroller = createScroller({ - element, - delay: 300, - threshold: 5000, - onScroll: async () => { - const [since, until] = backwardWindow + // Ask for the next span in each direction. A span that comes back empty is normal while + // walking a sparse history, so the count is reported separately from whether there is any + // history left — a caller watching its list for changes would never hear about an empty one. + const loadOlder = async () => { + if (oldest < now() - int(2, YEAR)) return {found: 0, exhausted: true} - backwardWindow = [since - interval, since] + const until = oldest - drainBuffer() + oldest = until - interval - if (until > now() - int(2, YEAR)) { - await loadTimeframe(since, until) - } else if (!buffer.some(e => e.created_at < at)) { - backwardScroller.stop() - onBackwardExhausted?.() - } - }, - }) + return {found: await loadTimeframe(oldest, until), exhausted: false} + } - const forwardScroller = createScroller({ - element, - reverse: true, - delay: 300, - threshold: 5000, - onScroll: async () => { - const [since, until] = forwardWindow + const loadNewer = async () => { + if (newest >= now()) return {found: 0, exhausted: true} - forwardWindow = [until, until + interval] + const since = newest - drainBuffer() + newest = Math.min(now(), since + interval) - if (until < now()) { - await loadTimeframe(since, until) - } else if (!buffer.some(e => e.created_at > at)) { - forwardScroller.stop() - onForwardExhausted?.() - } - }, - }) + return {found: await loadTimeframe(since, newest), exhausted: false} + } for (const url of relays) { insertEvents(getEventsForUrl(url, filters)) @@ -410,35 +453,33 @@ export const makeFeed = ({ return { events, + loadOlder, + loadNewer, cleanup: () => { controller.abort() - forwardScroller.stop() - backwardScroller.stop() unsubscribers.forEach(call) }, } } +// Same split as makeFeed: it holds what has been loaded and knows how to reach further out in +// either direction, while the page decides when to ask. Calendar events are addressed by the +// days they cover rather than by when they were published, so the spans are date hashes. export const makeCalendarFeed = ({ relays, filters, - element, onEvent, - onExhausted, }: { relays: string[] filters: Filter[] - element: HTMLElement onEvent?: (event: TrustedEvent) => void - onExhausted?: () => void }) => { const interval = int(5, MONTH) const controller = new AbortController() const seen = new Set() - let exhaustedScrollers = 0 - let backwardWindow = [now() - interval, now()] - let forwardWindow = [now(), now() + interval] + let oldest = now() + let newest = now() const getStart = (event: TrustedEvent) => parseInt(tagValue(tagSpec("start"), event.tags) || "") @@ -454,7 +495,6 @@ export const makeCalendarFeed = ({ ), ) - // Batch-insert calendar events into the store with a single update const insertEvents = (newEvents: TrustedEvent[]) => { const valid = newEvents.filter(e => !isNaN(getStart(e)) && !isNaN(getEnd(e)) && !seen.has(e.id)) @@ -490,107 +530,54 @@ export const makeCalendarFeed = ({ }) } - const onTrackedId = batch(150, (ids: string[]) => { - const matching: TrustedEvent[] = [] - - for (const id of new Set(ids)) { - const event = app.get().repository.getEvent(id) - - if (event && matchFilters(filters, event)) { - matching.push(event) - } - } - - if (matching.length > 0) { - insertEvents(matching) - } + // Calendar events are addressable and often relayed on from elsewhere, so this feed takes any + // matching event rather than only those seen on its own relays + const unsubscribers = syncFeed({ + relays, + filters, + events, + seen, + insertEvents, + requireRelay: false, }) - const unsubscribers = [ - on( - app.get().repository, - "update", - batch(150, (updates: RepositoryUpdate[]) => { - const {added, removed} = mergeRepositoryUpdates(updates) - - if (removed.size > 0) { - events.update($events => $events.filter(e => !removed.has(e.id))) - - for (const id of removed) { - seen.delete(id) - } - } - - const matching = added.filter(event => matchFilters(filters, event)) - - if (matching.length > 0) { - insertEvents(matching) - } - }), - ), - on(app.get().tracker, "add", (id: string, url: string) => { - if (relays.includes(url)) { - onTrackedId(id) - } - }), - ] - - const loadTimeframe = (since: number, until: number) => { - const hashes = daysBetween(since, until).map(String) - - network.get().request({ + const loadTimeframe = async (since: number, until: number) => { + const found = await network.get().request({ relays, autoClose: true, signal: controller.signal, - filters: [{kinds: [EVENT_TIME], "#D": hashes}], + filters: [{kinds: [EVENT_TIME], "#D": daysBetween(since, until).map(String)}], }) + + return found.length } - const maybeExhausted = () => { - if (++exhaustedScrollers === 2) { - onExhausted?.() - } + const loadOlder = async () => { + if (oldest < now() - int(2, YEAR)) return {found: 0, exhausted: true} + + const until = oldest + + oldest = until - interval + + return {found: await loadTimeframe(oldest, until), exhausted: false} } - const backwardScroller = createScroller({ - element, - reverse: true, - onScroll: () => { - const [since, until] = backwardWindow + const loadNewer = async () => { + if (newest > now() + int(2, YEAR)) return {found: 0, exhausted: true} - backwardWindow = [since - interval, since] + const since = newest - if (until > now() - int(2, YEAR)) { - loadTimeframe(since, until) - } else { - backwardScroller.stop() - maybeExhausted() - } - }, - }) + newest = since + interval - const forwardScroller = createScroller({ - element, - onScroll: () => { - const [since, until] = forwardWindow - - forwardWindow = [until, until + interval] - - if (until < now() + int(2, YEAR)) { - loadTimeframe(since, until) - } else { - forwardScroller.stop() - maybeExhausted() - } - }, - }) + return {found: await loadTimeframe(since, newest), exhausted: false} + } return { events, + loadOlder, + loadNewer, cleanup: () => { controller.abort() - forwardScroller.stop() - backwardScroller.stop() unsubscribers.forEach(call) }, } diff --git a/src/lib/html.ts b/src/lib/html.ts index 133747dd..cf9b3940 100644 --- a/src/lib/html.ts +++ b/src/lib/html.ts @@ -117,20 +117,27 @@ export const createScroller = ({ const check = async () => { const isHidden = (el: Element) => !(el as HTMLElement).offsetParent || el.clientHeight === 0 - if (container && !isHidden(container)) { - // While we have empty space, fill it - const {scrollY, innerHeight} = window - const {scrollHeight, scrollTop, clientHeight} = container - const viewHeight = clientHeight || innerHeight - const offset = Math.abs(scrollTop || scrollY) - const shouldLoad = reverse - ? offset < threshold - : offset + viewHeight + threshold > scrollHeight + // A relay that rejects — a dropped socket, an aborted request — must not take the loop with + // it. Letting it throw skips the rAF below, which silently ends scrolling for the life of + // the component and leaves whatever spinner the caller is showing up forever. + try { + if (container && !isHidden(container)) { + // While we have empty space, fill it + const {scrollY, innerHeight} = window + const {scrollHeight, scrollTop, clientHeight} = container + const viewHeight = clientHeight || innerHeight + const offset = Math.abs(scrollTop || scrollY) + const shouldLoad = reverse + ? offset < threshold + : offset + viewHeight + threshold > scrollHeight - // Only trigger loading the first time we reach the threshold - if (shouldLoad) { - await onScroll() + // Only trigger loading the first time we reach the threshold + if (shouldLoad) { + await onScroll() + } } + } catch (error) { + console.error(error) } // No need to check all that often diff --git a/src/routes/spaces/[relay]/articles/+page.svelte b/src/routes/spaces/[relay]/articles/+page.svelte index 008ca911..c4351143 100644 --- a/src/routes/spaces/[relay]/articles/+page.svelte +++ b/src/routes/spaces/[relay]/articles/+page.svelte @@ -4,6 +4,7 @@ import type {Readable} from "svelte/store" import {page} from "$app/stores" import {sortBy, partition, spec, max, pushToMapKey} from "@welshman/lib" + import type {Maybe} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" import {LONG_FORM, getAddress, tagSpec, tagValue} from "@welshman/util" import {Article} from "@welshman/domain" @@ -21,7 +22,7 @@ import {reader} from "@app/core" import {decodeRelay} from "@app/relays" import {makeCommentFilter} from "@app/content" - import {makeFeed, makeFeedContext} from "@app/feeds" + import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds" import {pushModal} from "@app/modal" const url = decodeRelay($page.params.relay!) @@ -29,7 +30,10 @@ onDestroy(context.cleanup) - let loading = $state(true) + let older: Maybe> = $state() + + const loading = $derived(isFeedLoading($older)) + const exhausted = $derived($older?.status === "exhausted") let author: string | undefined = $state() let topic: string | undefined = $state() let element: HTMLElement | undefined = $state() @@ -62,17 +66,20 @@ onMount(() => { const feed = makeFeed({ relays: [url], - element: element!, onEvent: context.add, filters: [{kinds: [LONG_FORM]}, makeCommentFilter([LONG_FORM])], - onBackwardExhausted: () => { - loading = false - }, }) events = feed.events - return () => feed.cleanup() + // These lists are sorted newest first, so reaching the bottom is reaching the oldest thing + // loaded. + older = makeScrollLoader(element!, feed.loadOlder) + + return () => { + older?.stop() + feed.cleanup() + } }) @@ -102,11 +109,11 @@ {#if loading} Looking for articles... - {:else if articles.length === 0} + {:else if exhausted && articles.length === 0} No articles found. {:else if filtered.length === 0} No articles match that filter. - {:else} + {:else if exhausted} That's all! {/if} diff --git a/src/routes/spaces/[relay]/calendar/+page.svelte b/src/routes/spaces/[relay]/calendar/+page.svelte index 37eb4e83..e26f7a6a 100644 --- a/src/routes/spaces/[relay]/calendar/+page.svelte +++ b/src/routes/spaces/[relay]/calendar/+page.svelte @@ -4,6 +4,7 @@ import {readable} from "svelte/store" import {page} from "$app/stores" import {now, last, formatTimestampAsDate} from "@welshman/lib" + import type {Maybe} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" import {EVENT_TIME, tagValue, tagSpec} from "@welshman/util" import {fly} from "@lib/transition" @@ -20,7 +21,7 @@ import {pushModal} from "@app/modal" import {decodeRelay} from "@app/relays" import {makeCommentFilter} from "@app/content" - import {makeCalendarFeed, makeFeedContext} from "@app/feeds" + import {makeCalendarFeed, makeFeedContext, makeScrollLoader} from "@app/feeds" const url = decodeRelay($page.params.relay!) const context = makeFeedContext({relays: [url]}) @@ -32,7 +33,13 @@ let element: HTMLElement | undefined = $state() onDestroy(context.cleanup) - let loading = $state(true) + let older: Maybe> = $state() + let newer: Maybe> = $state() + + // Unlike the other feeds this one asks whether more might exist rather than whether a request + // is in flight: a digest fills in progressively across many spans, and a spinner that blinked + // between each of them would read as broken. + const loading = $derived(!$older || $older.status !== "exhausted") let events: Readable = $state(readable([])) type Item = { @@ -96,17 +103,21 @@ onMount(() => { const feed = makeCalendarFeed({ relays: [url], - element: element!, onEvent: context.add, filters: [{kinds: [EVENT_TIME]}, makeCommentFilter([EVENT_TIME])], - onExhausted: () => { - loading = false - }, }) events = feed.events - return () => feed.cleanup() + // The calendar runs in both directions from today, so both ends fetch as they are reached + older = makeScrollLoader(element!, feed.loadOlder, {reverse: true}) + newer = makeScrollLoader(element!, feed.loadNewer) + + return () => { + older?.stop() + newer?.stop() + feed.cleanup() + } }) diff --git a/src/routes/spaces/[relay]/classifieds/+page.svelte b/src/routes/spaces/[relay]/classifieds/+page.svelte index 14c6d003..a693c29a 100644 --- a/src/routes/spaces/[relay]/classifieds/+page.svelte +++ b/src/routes/spaces/[relay]/classifieds/+page.svelte @@ -4,6 +4,7 @@ import type {Readable} from "svelte/store" import {page} from "$app/stores" import {sortBy, partition, spec, max, pushToMapKey} from "@welshman/lib" + import type {Maybe} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" import {CLASSIFIED, tagSpec, tagValue} from "@welshman/util" import {fly} from "@lib/transition" @@ -18,7 +19,7 @@ import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte" import {decodeRelay} from "@app/relays" import {makeCommentFilter} from "@app/content" - import {makeFeed, makeFeedContext} from "@app/feeds" + import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds" import {pushModal} from "@app/modal" const url = decodeRelay($page.params.relay!) @@ -26,7 +27,10 @@ onDestroy(context.cleanup) - let loading = $state(true) + let older: Maybe> = $state() + + const loading = $derived(isFeedLoading($older)) + const exhausted = $derived($older?.status === "exhausted") let element: HTMLElement | undefined = $state() let events: Readable = $state(readable([])) @@ -50,17 +54,20 @@ onMount(() => { const feed = makeFeed({ relays: [url], - element: element!, onEvent: context.add, filters: [{kinds: [CLASSIFIED]}, makeCommentFilter([CLASSIFIED])], - onBackwardExhausted: () => { - loading = false - }, }) events = feed.events - return () => feed.cleanup() + // These lists are sorted newest first, so reaching the bottom is reaching the oldest thing + // loaded. + older = makeScrollLoader(element!, feed.loadOlder) + + return () => { + older?.stop() + feed.cleanup() + } }) @@ -89,9 +96,9 @@ {#if loading} Looking for listings... - {:else if items.length === 0} + {:else if exhausted && items.length === 0} No classified listings found. - {:else} + {:else if exhausted} That's all! {/if} diff --git a/src/routes/spaces/[relay]/goals/+page.svelte b/src/routes/spaces/[relay]/goals/+page.svelte index 7fdbfcf7..c3fd05c2 100644 --- a/src/routes/spaces/[relay]/goals/+page.svelte +++ b/src/routes/spaces/[relay]/goals/+page.svelte @@ -4,6 +4,7 @@ import type {Readable} from "svelte/store" import {page} from "$app/stores" import {sortBy, partition, spec, pushToMapKey, max} from "@welshman/lib" + import type {Maybe} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" import {ZAP_GOAL, tagSpec, tagValue} from "@welshman/util" import {fly} from "@lib/transition" @@ -18,7 +19,7 @@ import GoalCreate from "@app/components/GoalCreate.svelte" import {decodeRelay} from "@app/relays" import {makeCommentFilter} from "@app/content" - import {makeFeed, makeFeedContext} from "@app/feeds" + import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds" import {pushModal} from "@app/modal" const url = decodeRelay($page.params.relay!) @@ -26,7 +27,10 @@ onDestroy(context.cleanup) - let loading = $state(true) + let older: Maybe> = $state() + + const loading = $derived(isFeedLoading($older)) + const exhausted = $derived($older?.status === "exhausted") let element: HTMLElement | undefined = $state() let events: Readable = $state(readable([])) @@ -50,17 +54,20 @@ onMount(() => { const feed = makeFeed({ relays: [url], - element: element!, onEvent: context.add, filters: [{kinds: [ZAP_GOAL]}, makeCommentFilter([ZAP_GOAL])], - onBackwardExhausted: () => { - loading = false - }, }) events = feed.events - return () => feed.cleanup() + // These lists are sorted newest first, so reaching the bottom is reaching the oldest thing + // loaded. + older = makeScrollLoader(element!, feed.loadOlder) + + return () => { + older?.stop() + feed.cleanup() + } }) @@ -89,9 +96,9 @@ {#if loading} Looking for goals... - {:else if items.length === 0} + {:else if exhausted && items.length === 0} No goals found. - {:else} + {:else if exhausted} That's all! {/if} diff --git a/src/routes/spaces/[relay]/polls/+page.svelte b/src/routes/spaces/[relay]/polls/+page.svelte index b304f1eb..91668bc5 100644 --- a/src/routes/spaces/[relay]/polls/+page.svelte +++ b/src/routes/spaces/[relay]/polls/+page.svelte @@ -4,6 +4,7 @@ import type {Readable} from "svelte/store" import {page} from "$app/stores" import {sortBy, partition, spec, pushToMapKey, max} from "@welshman/lib" + import type {Maybe} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" import {POLL, tagValue, tagSpec} from "@welshman/util" import {fly} from "@lib/transition" @@ -18,7 +19,7 @@ import PollCreate from "@app/components/PollCreate.svelte" import {decodeRelay} from "@app/relays" import {makeCommentFilter} from "@app/content" - import {makeFeed, makeFeedContext} from "@app/feeds" + import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds" import {pushModal} from "@app/modal" const url = decodeRelay($page.params.relay!) @@ -26,7 +27,10 @@ onDestroy(context.cleanup) - let loading = $state(true) + let older: Maybe> = $state() + + const loading = $derived(isFeedLoading($older)) + const exhausted = $derived($older?.status === "exhausted") let element: HTMLElement | undefined = $state() let events: Readable = $state(readable([])) @@ -50,17 +54,20 @@ onMount(() => { const feed = makeFeed({ relays: [url], - element: element!, onEvent: context.add, filters: [{kinds: [POLL]}, makeCommentFilter([POLL])], - onBackwardExhausted: () => { - loading = false - }, }) events = feed.events - return () => feed.cleanup() + // These lists are sorted newest first, so reaching the bottom is reaching the oldest thing + // loaded. + older = makeScrollLoader(element!, feed.loadOlder) + + return () => { + older?.stop() + feed.cleanup() + } }) @@ -89,9 +96,9 @@ {#if loading} Looking for polls... - {:else if items.length === 0} + {:else if exhausted && items.length === 0} No polls found. - {:else} + {:else if exhausted} That's all! {/if} diff --git a/src/routes/spaces/[relay]/threads/+page.svelte b/src/routes/spaces/[relay]/threads/+page.svelte index f16e5710..01c73701 100644 --- a/src/routes/spaces/[relay]/threads/+page.svelte +++ b/src/routes/spaces/[relay]/threads/+page.svelte @@ -4,6 +4,7 @@ import type {Readable} from "svelte/store" import {page} from "$app/stores" import {sortBy, partition, spec, max, pushToMapKey, groupBy} from "@welshman/lib" + import type {Maybe} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" import {THREAD, tagValue, tagSpec} from "@welshman/util" import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl" @@ -18,7 +19,7 @@ import {decodeRelay} from "@app/relays" import {displayRoom} from "@app/rooms" import {makeCommentFilter} from "@app/content" - import {makeFeed, makeFeedContext} from "@app/feeds" + import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds" import {pushModal} from "@app/modal" const url = decodeRelay($page.params.relay!) @@ -26,7 +27,10 @@ onDestroy(context.cleanup) - let loading = $state(true) + let older: Maybe> = $state() + + const loading = $derived(isFeedLoading($older)) + const exhausted = $derived($older?.status === "exhausted") let element: HTMLElement | undefined = $state() let events: Readable = $state(readable([])) @@ -56,17 +60,20 @@ onMount(() => { const feed = makeFeed({ relays: [url], - element: element!, onEvent: context.add, filters: [{kinds: [THREAD]}, makeCommentFilter([THREAD])], - onBackwardExhausted: () => { - loading = false - }, }) events = feed.events - return () => feed.cleanup() + // These lists are sorted newest first, so reaching the bottom is reaching the oldest thing + // loaded. + older = makeScrollLoader(element!, feed.loadOlder) + + return () => { + older?.stop() + feed.cleanup() + } }) @@ -93,7 +100,7 @@ {#if loading} Looking for threads... - {:else if threadFeed.items.length === 0} + {:else if exhausted && threadFeed.items.length === 0} No threads found. {/if}