mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
initial commit
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 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>>
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user