mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
342 lines
11 KiB
TypeScript
342 lines
11 KiB
TypeScript
/**
|
|
* 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<T> = (cursor: Cursor) => Promise<Page<T>>
|
|
|
|
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<string, unknown> | null {
|
|
return value !== null && typeof value === 'object'
|
|
? (value as Record<string, unknown>)
|
|
: 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<string>): 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<T extends Identified>(
|
|
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<string, Identified>()
|
|
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<T extends Identified> {
|
|
items = $state<T[]>([])
|
|
loading = $state(false)
|
|
/** Distinguishes the initial spinner from the "more entries" spinner. */
|
|
initialized = $state(false)
|
|
error = $state<string | null>(null)
|
|
exhausted = $state(false)
|
|
|
|
private loader: Loader<T>
|
|
private pageSize: number
|
|
private nextCursor: string | undefined
|
|
private seen = new Set<string>()
|
|
private generation = 0
|
|
|
|
constructor(loader: Loader<T>, pageSize = 20) {
|
|
this.loader = loader
|
|
this.pageSize = pageSize
|
|
}
|
|
|
|
/** Swap in a new loader and reload — used when a route param changes. */
|
|
setLoader(loader: Loader<T>): void {
|
|
this.loader = loader
|
|
void this.reload()
|
|
}
|
|
|
|
async reload(): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string>()
|
|
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<void> {
|
|
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
|
|
}
|
|
}
|