diff --git a/package.json b/package.json index a9781eac..cfcd1d60 100644 --- a/package.json +++ b/package.json @@ -78,16 +78,16 @@ "@types/throttle-debounce": "^5.0.2", "@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/sveltekit": "^1.1.0", - "@welshman/app": "^0.9.7", - "@welshman/content": "^0.9.7", - "@welshman/domain": "^0.9.7", - "@welshman/editor": "^0.9.7", - "@welshman/feeds": "^0.9.7", - "@welshman/lib": "^0.9.7", - "@welshman/net": "^0.9.7", - "@welshman/signer": "^0.9.7", - "@welshman/store": "^0.9.7", - "@welshman/util": "^0.9.7", + "@welshman/app": "^0.9.8", + "@welshman/content": "^0.9.8", + "@welshman/domain": "^0.9.8", + "@welshman/editor": "^0.9.8", + "@welshman/feeds": "^0.9.8", + "@welshman/lib": "^0.9.8", + "@welshman/net": "^0.9.8", + "@welshman/signer": "^0.9.8", + "@welshman/store": "^0.9.8", + "@welshman/util": "^0.9.8", "cheerio": "^1.2.0", "compressorjs-next": "^1.1.2", "dompurify": "^3.4.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99f70844..82c00b6f 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ diff --git a/src/app/feeds.ts b/src/app/feeds.ts index 5f063694..ff05dde4 100644 --- a/src/app/feeds.ts +++ b/src/app/feeds.ts @@ -1,6 +1,6 @@ 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 {batch, call, int, ms, now, on, sleep, sortBy, uniqBy, MONTH, YEAR} from "@welshman/lib" import { COMMENT, DELETE, @@ -324,6 +324,11 @@ export type FeedLoadState = | {status: "searching"} | {status: "exhausted"} +// One span of a feed's timeline. `complete` is whether the relays actually answered for it: a +// request the socket dropped reports nothing found, which is not the same thing as a span with +// nothing in it, and the two have to move the window differently. +export type FeedSpan = {found: number; complete: boolean; exhausted: boolean} + // 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 @@ -334,7 +339,7 @@ const SPANS_PER_TRIGGER = 3 export const isFeedLoading = (state: Maybe) => state?.status === "loading" || state?.status === "searching" -const makeFeedLoader = (load: () => Promise<{found: number; exhausted: boolean}>) => { +const makeFeedLoader = (load: () => Promise) => { const state = writable({status: "idle"}) let running = false @@ -348,17 +353,21 @@ const makeFeedLoader = (load: () => Promise<{found: number; exhausted: boolean}> for (let span = 0; span < SPANS_PER_TRIGGER; span++) { state.set({status: span > 0 ? "searching" : "loading"}) - const {found, exhausted} = await load() + const {found, complete, exhausted} = await load() if (exhausted) { state.set({status: "exhausted"}) return } - if (found > 0) { - state.set({status: "idle"}) - return + // A span nobody answered for hasn't moved the window, so hold here rather than walking + // past it, and give the socket a moment before the scroller comes back around + if (!complete) { + await sleep(ms(3)) + break } + + if (found > 0) break } state.set({status: "idle"}) @@ -376,7 +385,7 @@ const makeFeedLoader = (load: () => Promise<{found: number; exhausted: boolean}> // ordinary feed scrolls toward the end of its own content. export const makeScrollLoader = ( element: HTMLElement, - load: () => Promise<{found: number; exhausted: boolean}>, + load: () => Promise, options: Partial = {}, ) => { const loader = makeFeedLoader(load) @@ -452,41 +461,58 @@ export const makeFeed = ({ const unsubscribers = syncFeed({relays, filters, events, seen, insertEvents}) const loadTimeframe = async (since: number, until: number) => { + let complete = false + const found = await network.get().request({ relays, autoClose: true, signal: controller.signal, filters: filters.map(filter => ({...filter, since, until})), + onEose: () => { + complete = true + }, }) // 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) + if (complete) { + interval = found.length > 0 ? int(MONTH) : Math.round(interval * 1.5) + } - return found.length + return {found: found.length, complete} } // 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} + // The window only moves once the relays have answered: a request the socket dropped looks + // exactly like an empty span, and walking past it would leave a hole nothing goes back for. + const loadOlder = async (): Promise => { + if (oldest < now() - int(2, YEAR)) return {found: 0, complete: true, exhausted: true} const until = oldest + const since = until - interval + const {found, complete} = await loadTimeframe(since, until) - oldest = until - interval + if (complete) { + oldest = since + } - return {found: await loadTimeframe(oldest, until), exhausted: false} + return {found, complete, exhausted: false} } - const loadNewer = async () => { - if (newest >= now()) return {found: 0, exhausted: true} + const loadNewer = async (): Promise => { + if (newest >= now()) return {found: 0, complete: true, exhausted: true} const since = newest + const until = Math.min(now(), since + interval) + const {found, complete} = await loadTimeframe(since, until) - newest = Math.min(now(), since + interval) + if (complete) { + newest = until + } - return {found: await loadTimeframe(since, newest), exhausted: false} + return {found, complete, exhausted: false} } for (const url of relays) { @@ -584,34 +610,47 @@ export const makeCalendarFeed = ({ }) const loadTimeframe = async (since: number, until: number) => { + let complete = false + const found = await network.get().request({ relays, autoClose: true, signal: controller.signal, filters: [{kinds: [EVENT_TIME], "#D": daysBetween(since, until).map(String)}], + onEose: () => { + complete = true + }, }) - return found.length + return {found: found.length, complete} } - const loadOlder = async () => { - if (oldest < now() - int(2, YEAR)) return {found: 0, exhausted: true} + const loadOlder = async (): Promise => { + if (oldest < now() - int(2, YEAR)) return {found: 0, complete: true, exhausted: true} const until = oldest + const since = until - interval + const {found, complete} = await loadTimeframe(since, until) - oldest = until - interval + if (complete) { + oldest = since + } - return {found: await loadTimeframe(oldest, until), exhausted: false} + return {found, complete, exhausted: false} } - const loadNewer = async () => { - if (newest > now() + int(2, YEAR)) return {found: 0, exhausted: true} + const loadNewer = async (): Promise => { + if (newest > now() + int(2, YEAR)) return {found: 0, complete: true, exhausted: true} const since = newest + const until = since + interval + const {found, complete} = await loadTimeframe(since, until) - newest = since + interval + if (complete) { + newest = until + } - return {found: await loadTimeframe(since, newest), exhausted: false} + return {found, complete, exhausted: false} } return {