Re-work feeds for performance

This commit is contained in:
Jon Staab 2026-08-29 17:42:07 -07:00
parent 8e33a722bb
commit 251e73663f
10 changed files with 373 additions and 312 deletions

View file

@ -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<ReturnType<typeof makeScrollLoader>> = $state()
const exhausted = $derived($older?.status === "exhausted")
const loading = $derived(isFeedLoading($older))
let events: Writable<TrustedEvent[]> = $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 @@
<p class="py-12 text-center text-sm opacity-75">No notes found for this profile.</p>
{/if}
{/each}
{#if !exhausted}
{#if loading}
<p class="my-12 flex items-center justify-center gap-2">
<Spinner loading />
Loading notes...

View file

@ -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<ReturnType<typeof makeScrollLoader>> = $state()
let newer: Maybe<ReturnType<typeof makeScrollLoader>> = $state()
let share: Maybe<Share> = $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 @@
</div>
</div>
{:else}
{#if loadingForward && elements.length > 0}
{#if loadingForward}
<p class="py-20 flex justify-center">
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p>
@ -588,8 +603,8 @@
{/each}
<p class="flex h-10 items-center justify-center py-20">
{#if loadingBackward}
<Spinner loading={loadingBackward}>Looking for messages...</Spinner>
{:else}
<Spinner loading>Looking for messages...</Spinner>
{:else if reachedStartOfHistory}
End of message history
{/if}
</p>

View file

@ -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<string[]>}
export type FeedContext = ReturnType<typeof makeFeedContext>
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<TrustedEvent[]>
seen: Set<string>
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<TrustedEvent[]>([])
const seen = new Set<string>()
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<TrustedEvent>) => {
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<FeedLoadState>) =>
state?.status === "loading" || state?.status === "searching"
const makeFeedLoader = (load: () => Promise<{found: number; exhausted: boolean}>) => {
const state = writable<FeedLoadState>({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<ScrollerOpts> = {},
) => {
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<TrustedEvent[]>([])
const seen = new Set<string>()
// 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<TrustedEvent>) => {
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<string>()
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)
},
}

View file

@ -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

View file

@ -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<ReturnType<typeof makeScrollLoader>> = $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()
}
})
</script>
@ -102,11 +109,11 @@
<Spinner {loading}>
{#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}
</Spinner>

View file

@ -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<ReturnType<typeof makeScrollLoader>> = $state()
let newer: Maybe<ReturnType<typeof makeScrollLoader>> = $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<TrustedEvent[]> = $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()
}
})
</script>

View file

@ -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<ReturnType<typeof makeScrollLoader>> = $state()
const loading = $derived(isFeedLoading($older))
const exhausted = $derived($older?.status === "exhausted")
let element: HTMLElement | undefined = $state()
let events: Readable<TrustedEvent[]> = $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()
}
})
</script>
@ -89,9 +96,9 @@
<Spinner {loading}>
{#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}
</Spinner>

View file

@ -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<ReturnType<typeof makeScrollLoader>> = $state()
const loading = $derived(isFeedLoading($older))
const exhausted = $derived($older?.status === "exhausted")
let element: HTMLElement | undefined = $state()
let events: Readable<TrustedEvent[]> = $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()
}
})
</script>
@ -89,9 +96,9 @@
<Spinner {loading}>
{#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}
</Spinner>

View file

@ -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<ReturnType<typeof makeScrollLoader>> = $state()
const loading = $derived(isFeedLoading($older))
const exhausted = $derived($older?.status === "exhausted")
let element: HTMLElement | undefined = $state()
let events: Readable<TrustedEvent[]> = $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()
}
})
</script>
@ -89,9 +96,9 @@
<Spinner {loading}>
{#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}
</Spinner>

View file

@ -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<ReturnType<typeof makeScrollLoader>> = $state()
const loading = $derived(isFeedLoading($older))
const exhausted = $derived($older?.status === "exhausted")
let element: HTMLElement | undefined = $state()
let events: Readable<TrustedEvent[]> = $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()
}
})
</script>
@ -93,7 +100,7 @@
<Spinner {loading}>
{#if loading}
Looking for threads...
{:else if threadFeed.items.length === 0}
{:else if exhausted && threadFeed.items.length === 0}
No threads found.
{/if}
</Spinner>