Re-work feeds for performance
This commit is contained in:
parent
8e33a722bb
commit
251e73663f
10 changed files with 373 additions and 312 deletions
|
|
@ -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...
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
441
src/app/feeds.ts
441
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<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)
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue