/** * Cursor-paginated list state. * * Every list in the app — timelines, friend lists, notifications, the directory * — is the same shape: fetch a page, remember the cursor, append on demand. * This wraps that with the two things that bite in practice: de-duplication * (federated timelines repeat statuses across pages when new posts arrive * mid-scroll) and out-of-order responses from an impatient "more" button. */ import { ApiError, type Page } from '../api/client' import type { Cursor } from '../api/endpoints' export interface Identified { id: string } export type Loader = (cursor: Cursor) => Promise> interface RefreshOptions { /** A linked next page means an empty filtered page is not authoritative. */ emptyIsAuthoritative?: boolean /** Oldest timestamp the server query proved it had covered. */ coveredThroughTimestamp?: number } function asRecord(value: unknown): Record | null { return value !== null && typeof value === 'object' ? (value as Record) : null } function deletionId(value: unknown): string | null { const record = asRecord(value) if (!record) return null if (record.event === 'delete' && typeof record.payload === 'string') { return record.payload } if ( (record.deleted === true || record.tombstone === true) && typeof record.id === 'string' ) { return record.id } return null } function nestedReblog(value: unknown): Identified | null { const record = asRecord(value) const reblog = asRecord(record?.reblog) return reblog && typeof reblog.id === 'string' ? (reblog as unknown as Identified) : null } function createdAt(value: unknown): number | null { const record = asRecord(value) if (typeof record?.created_at !== 'string') return null const timestamp = Date.parse(record.created_at) return Number.isNaN(timestamp) ? null : timestamp } function referencesId(value: Identified, ids: Set): boolean { const reblog = nestedReblog(value) return ids.has(value.id) || Boolean(reblog && ids.has(reblog.id)) } /** * Merge a newest-first timeline response without replacing the existing list. * * Matching entries receive fresh API metadata, unseen entries are prepended, * explicit deletions remove direct posts and repost wrappers, and an absent * entry inside the returned chronological window is treated as removed. Older * paginated entries outside that window remain untouched. */ export function reconcileRefreshItems( current: T[], responseItems: T[], deletedIds: readonly string[] = [], options: RefreshOptions = {}, ): T[] { const removed = new Set(deletedIds) const incoming: T[] = [] for (const item of responseItems) { const deletedId = deletionId(item) if (deletedId) { removed.add(deletedId) } else if (item && typeof item.id === 'string') { incoming.push(item) } } if (incoming.length === 0) { if (options.emptyIsAuthoritative) return [] return current.filter((item) => !referencesId(item, removed)) } const incomingTopIds = new Set(incoming.map((item) => item.id)) const incomingById = new Map() for (const item of incoming) { const reblog = nestedReblog(item) if (reblog) incomingById.set(reblog.id, reblog) incomingById.set(item.id, item) } const timestamps = incoming .map(createdAt) .filter((timestamp): timestamp is number => timestamp !== null) const oldestIncoming = options.coveredThroughTimestamp ?? (timestamps.length > 0 ? Math.min(...timestamps) : null) const retainedOlder = current .filter((item) => { if (referencesId(item, removed)) return false if (incomingTopIds.has(item.id)) return false const timestamp = createdAt(item) // A missing entry newer than the oldest returned entry would have been // present in this response if it still belonged to the timeline. return oldestIncoming === null || timestamp === null || timestamp <= oldestIncoming }) .map((item) => { const direct = incomingById.get(item.id) if (direct) return direct as T const reblog = nestedReblog(item) const updatedReblog = reblog ? incomingById.get(reblog.id) : null return updatedReblog ? ({ ...(item as object), reblog: updatedReblog } as unknown as T) : item }) const refreshedWindow = incoming.filter((item) => !referencesId(item, removed)) return [...refreshedWindow, ...retainedOlder] } export class Feed { items = $state([]) loading = $state(false) /** Distinguishes the initial spinner from the "more entries" spinner. */ initialized = $state(false) error = $state(null) exhausted = $state(false) private loader: Loader private pageSize: number private nextCursor: string | undefined private seen = new Set() private generation = 0 constructor(loader: Loader, pageSize = 20) { this.loader = loader this.pageSize = pageSize } /** Swap in a new loader and reload — used when a route param changes. */ setLoader(loader: Loader): void { this.loader = loader void this.reload() } async reload(): Promise { this.generation += 1 this.nextCursor = undefined this.seen = new Set() this.items = [] this.exhausted = false this.initialized = false await this.run(this.generation, true) } async loadMore(): Promise { if (this.loading || this.exhausted) return await this.run(this.generation, false) } /** * Refresh the visible window in place. Unlike `reload`, this never clears the * list or resets its older-page cursor. */ async refresh(): Promise { if (this.loading) return const generation = ++this.generation this.loading = true this.error = null try { const oldestVisible = this.items.at(-1) const oldestVisibleAt = createdAt(oldestVisible) const collected: T[] = [] const collectedIds = new Set() const deletedIds: string[] = [] let cursor: string | undefined let coveredThroughTimestamp: number | undefined let emptyIsAuthoritative = false const maxPages = Math.min( 20, Math.max(2, Math.ceil(this.items.length / 40) + 2), ) for (let pageNumber = 0; pageNumber < maxPages; pageNumber += 1) { const page = await this.loader({ max_id: cursor, limit: 40 }) if (generation !== this.generation) return deletedIds.push(...(page.deletedIds ?? [])) const livePage = page.items.filter((item) => !deletionId(item)) for (const item of page.items) { const removedId = deletionId(item) if (removedId) { deletedIds.push(removedId) } else if (!collectedIds.has(item.id)) { collectedIds.add(item.id) collected.push(item) } } if (!oldestVisible) break const boundaryIndex = collected.findIndex( (item) => item.id === oldestVisible.id, ) if (boundaryIndex >= 0) { collected.splice(boundaryIndex + 1) coveredThroughTimestamp = oldestVisibleAt ?? undefined break } if (oldestVisibleAt !== null) { const timestampsBelowBoundary = collected .map(createdAt) .filter( (timestamp): timestamp is number => timestamp !== null && timestamp < oldestVisibleAt, ) if (timestampsBelowBoundary.length > 0) { coveredThroughTimestamp = Math.min(...timestampsBelowBoundary) const withinVisibleWindow = collected.filter((item) => { const timestamp = createdAt(item) return timestamp === null || timestamp >= oldestVisibleAt }) collected.splice(0, collected.length, ...withinVisibleWindow) break } } if (page.items.length === 0) { emptyIsAuthoritative = true coveredThroughTimestamp = Number.NEGATIVE_INFINITY break } const nextCursor = page.links.maxId ?? livePage.at(-1)?.id if (!nextCursor || nextCursor === cursor) break cursor = nextCursor } this.items = reconcileRefreshItems( this.items, collected, deletedIds, { emptyIsAuthoritative, coveredThroughTimestamp }, ) this.seen = new Set(this.items.map((item) => item.id)) } catch (cause) { if (generation !== this.generation) return this.error = cause instanceof ApiError ? cause.message : cause instanceof Error ? cause.message : 'Something went wrong.' } finally { if (generation === this.generation) { this.loading = false this.initialized = true } } } private async run(generation: number, replace: boolean): Promise { this.loading = true this.error = null try { const page = await this.loader({ max_id: replace ? undefined : this.nextCursor, limit: this.pageSize }) // A newer reload started while this request was in flight; drop it. if (generation !== this.generation) return const fresh = page.items.filter((item) => item && !this.seen.has(item.id)) for (const item of fresh) this.seen.add(item.id) this.items = replace ? fresh : [...this.items, ...fresh] const previousCursor = this.nextCursor this.nextCursor = page.links.maxId // Stop when the server runs out, or when it hands back the same cursor // (some servers echo the cursor forever on an empty page). if (page.items.length === 0 || !this.nextCursor || this.nextCursor === previousCursor) { this.exhausted = true } } catch (cause) { if (generation !== this.generation) return this.error = cause instanceof ApiError ? cause.message : cause instanceof Error ? cause.message : 'Something went wrong.' this.exhausted = true } finally { if (generation === this.generation) { this.loading = false this.initialized = true } } } /** Replace one item in place, e.g. after a favourite/repost toggle. */ update(id: string, updater: (item: T) => T): void { this.items = this.items.map((item) => (item.id === id ? updater(item) : item)) } /** Drop an item, e.g. after deleting a post. */ remove(id: string): void { this.items = this.items.filter((item) => item.id !== id) this.seen.delete(id) } /** Insert at the top, e.g. after composing. */ prepend(item: T): void { if (this.seen.has(item.id)) return this.seen.add(item.id) this.items = [item, ...this.items] } get isEmpty(): boolean { return this.initialized && this.items.length === 0 && !this.error } }