initial commit

This commit is contained in:
Moon.eth
2026-07-29 09:16:38 +09:00
commit 586b599d4c
67 changed files with 9906 additions and 0 deletions
+121
View File
@@ -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
}
}