diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 8d42403..d4ae4b8 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -39,6 +39,8 @@ export interface PageLinks { export interface Page { items: T[] links: PageLinks + /** Optional deletion/tombstone IDs supplied by streaming-aware loaders. */ + deletedIds?: string[] } export type QueryValue = string | number | boolean | undefined | null | string[] diff --git a/src/lib/stores/feed.svelte.ts b/src/lib/stores/feed.svelte.ts index ff52b87..c35b5cc 100644 --- a/src/lib/stores/feed.svelte.ts +++ b/src/lib/stores/feed.svelte.ts @@ -17,6 +17,123 @@ export interface Identified { 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) @@ -57,6 +174,109 @@ export class Feed { 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 diff --git a/src/lib/stores/feed.test.ts b/src/lib/stores/feed.test.ts new file mode 100644 index 0000000..f9d90a8 --- /dev/null +++ b/src/lib/stores/feed.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Page } from '../api/client' +import type { Cursor } from '../api/endpoints' +import { Feed, reconcileRefreshItems } from './feed.svelte' + +interface Entry { + id: string + created_at: string + favourites_count: number + reblogs_count?: number + reblog?: Entry | null +} + +function entry( + id: string, + minute: number, + favourites_count = 0, + reblog: Entry | null = null, +): Entry { + return { + id, + created_at: `2026-01-01T00:${String(minute).padStart(2, '0')}:00.000Z`, + favourites_count, + reblog, + } +} + +describe('Feed.refresh', () => { + it('prepends new entries, updates metadata, and preserves older pagination', async () => { + const newest = entry('new', 12) + const updated = { ...entry('first', 11, 7), reblogs_count: 4 } + const first = entry('first', 11) + const older = entry('older', 10) + const oldest = entry('oldest', 9) + const loader = vi.fn(async (cursor: Cursor): Promise> => { + if (cursor.max_id === 'older') { + return { items: [oldest], links: {} } + } + if (cursor.limit === 20 && loader.mock.calls.length === 1) { + return { items: [first, older], links: { maxId: 'older' } } + } + return { items: [newest, updated, older], links: { maxId: 'older' } } + }) + const feed = new Feed(loader, 20) + + await feed.reload() + await feed.refresh() + + expect(feed.items).toEqual([newest, updated, older]) + expect(feed.items.find((item) => item.id === 'first')?.favourites_count).toBe(7) + expect(feed.items.find((item) => item.id === 'first')?.reblogs_count).toBe(4) + + await feed.loadMore() + expect(loader.mock.calls[2][0]).toMatchObject({ max_id: 'older' }) + expect(feed.items).toEqual([newest, updated, older, oldest]) + }) + + it('keeps the visible list in place while a refresh request is pending', async () => { + let resolveRefresh!: (page: Page) => void + const refreshPage = new Promise>((resolve) => { + resolveRefresh = resolve + }) + const visible = entry('visible', 10) + const loader = vi + .fn() + .mockResolvedValueOnce({ items: [visible], links: {} }) + .mockReturnValueOnce(refreshPage) + const feed = new Feed(loader) + await feed.reload() + + const pending = feed.refresh() + expect(feed.items).toEqual([visible]) + + resolveRefresh({ items: [entry('visible', 10, 3)], links: {} }) + await pending + expect(feed.items[0].favourites_count).toBe(3) + }) + + it('queries enough newest pages to update every loaded visible entry', async () => { + const first = entry('first', 12) + const second = entry('second', 11) + const third = entry('third', 10) + const loader = vi.fn(async (cursor: Cursor): Promise> => { + if (cursor.limit === 2 && !cursor.max_id) { + return { items: [first, second], links: { maxId: 'second' } } + } + if (cursor.limit === 2 && cursor.max_id === 'second') { + return { items: [third], links: {} } + } + if (!cursor.max_id) { + return { + items: [entry('new', 13), { ...first, reblogs_count: 2 }], + links: { maxId: 'first' }, + } + } + return { + items: [{ ...second, favourites_count: 6 }, third], + links: { maxId: 'third' }, + } + }) + const feed = new Feed(loader, 2) + await feed.reload() + await feed.loadMore() + + await feed.refresh() + + expect(feed.items.map((item) => item.id)).toEqual([ + 'new', + 'first', + 'second', + 'third', + ]) + expect(feed.items[1].reblogs_count).toBe(2) + expect(feed.items[2].favourites_count).toBe(6) + expect(loader.mock.calls.slice(2).map(([cursor]) => cursor.max_id)).toEqual([ + undefined, + 'first', + ]) + }) +}) + +describe('reconcileRefreshItems', () => { + it('removes entries missing inside the refreshed chronological window', () => { + const first = entry('first', 12) + const deleted = entry('deleted', 11) + const last = entry('last', 10) + + expect(reconcileRefreshItems([first, deleted, last], [first, last])).toEqual([ + first, + last, + ]) + }) + + it('preserves older entries outside the refreshed window', () => { + const first = entry('first', 12) + const refreshedLast = entry('refreshed-last', 11) + const older = entry('older', 8) + + expect( + reconcileRefreshItems( + [first, older], + [entry('new', 13), first, refreshedLast], + ), + ).toEqual([entry('new', 13), first, refreshedLast, older]) + }) + + it('removes explicit deletions and repost wrappers anywhere in the list', () => { + const deletedOriginal = entry('deleted-original', 4) + const wrapper = entry('wrapper', 5, 0, deletedOriginal) + const retained = entry('retained', 3) + + expect( + reconcileRefreshItems( + [wrapper, retained], + [retained], + ['deleted-original'], + ), + ).toEqual([retained]) + }) +}) diff --git a/src/routes/Home.svelte b/src/routes/Home.svelte index 3b8ad26..654617b 100644 --- a/src/routes/Home.svelte +++ b/src/routes/Home.svelte @@ -18,6 +18,7 @@ import { toPlainText } from '$lib/util/html' import { relativeTime, shortDate, stampDate } from '$lib/util/time' import { useTimelineRefresh } from '$lib/timeline-refresh' + import { reconcileRefreshItems } from '$lib/stores/feed.svelte' import Module from '$components/common/Module.svelte' import Avatar from '$components/common/Avatar.svelte' import RichText from '$components/common/RichText.svelte' @@ -155,7 +156,12 @@ session.host === host && session.signedIn === signedIn ) { - friendStatus = page.items + friendStatus = reconcileRefreshItems( + friendStatus, + page.items, + page.deletedIds, + { emptyIsAuthoritative: !page.links.maxId }, + ) } } catch (cause) { if (generation === primaryRefreshGeneration && session.host === host) { @@ -173,7 +179,12 @@ session.host === host && session.signedIn === signedIn ) { - bulletins = page.items + bulletins = reconcileRefreshItems( + bulletins, + page.items, + page.deletedIds, + { emptyIsAuthoritative: !page.links.maxId }, + ) } } catch (cause) { if (generation === primaryRefreshGeneration && session.host === host) { diff --git a/src/routes/Profile.svelte b/src/routes/Profile.svelte index 1b13957..8f1c40c 100644 --- a/src/routes/Profile.svelte +++ b/src/routes/Profile.svelte @@ -89,7 +89,7 @@ const currentView = view const loaded = account !== null if (!timelineRefresh || !loaded || currentView === 'friends') return - return timelineRefresh.register(() => void entries.reload()) + return timelineRefresh.register(() => void entries.refresh()) }) async function load( diff --git a/src/routes/Profile.test.ts b/src/routes/Profile.test.ts index 7485e20..ad360ce 100644 --- a/src/routes/Profile.test.ts +++ b/src/routes/Profile.test.ts @@ -143,10 +143,28 @@ describe('Profile', () => { it('refreshes only the entry feed without reloading the profile', async () => { const lookupAccount = vi.fn().mockResolvedValue(account()) - const fetchAccountStatuses = vi.fn().mockResolvedValue({ - items: [status({ content: '

Timeline note

' })], - links: {}, - }) + const fetchAccountStatuses = vi + .fn() + .mockResolvedValueOnce({ + items: [ + status({ + id: 'timeline-note', + content: '

Timeline note

', + favourites_count: 0, + }), + ], + links: {}, + }) + .mockResolvedValueOnce({ + items: [ + status({ + id: 'timeline-note', + content: '

Timeline note

', + favourites_count: 5, + }), + ], + links: {}, + }) const services = testServices({ session: session(), theme: theme(), @@ -166,5 +184,8 @@ describe('Profile', () => { await waitFor(() => expect(fetchAccountStatuses).toHaveBeenCalledTimes(2)) expect(lookupAccount).toHaveBeenCalledOnce() + await waitFor(() => + expect(view.getByRole('button', { name: 'Kudos (5)' })).toBeInTheDocument(), + ) }) }) diff --git a/src/routes/StatusPage.svelte b/src/routes/StatusPage.svelte index 0a574f8..c618efe 100644 --- a/src/routes/StatusPage.svelte +++ b/src/routes/StatusPage.svelte @@ -95,10 +95,7 @@ const host = session.host const loaded = status !== null if (!timelineRefresh || !host || !loaded) return - return timelineRefresh.register(() => { - const generation = ++loadGeneration - void load(currentId, host, generation) - }) + return timelineRefresh.register(() => void refreshEntry(currentId, host)) }) async function load(currentId: string, host: string, generation: number): Promise { @@ -123,6 +120,32 @@ } } + /** Refresh the entry and complete thread snapshot without hiding the page. */ + async function refreshEntry(currentId: string, host: string): Promise { + const generation = ++loadGeneration + try { + const [entry, context] = await Promise.all([ + endpoints.fetchStatus(session.api, currentId), + endpoints.fetchContext(session.api, currentId).catch(() => ({ + ancestors: [], + descendants: [], + })), + ]) + if (generation !== loadGeneration || id !== currentId || session.host !== host) return + + status = entry + ancestors = context.ancestors + descendants = context.descendants + error = null + } catch (cause) { + if (generation !== loadGeneration) return + status = null + ancestors = [] + descendants = [] + error = cause instanceof Error ? cause.message : 'That entry was deleted or is no longer available.' + } + } + function onPosted(created: Status): void { descendants = [...descendants, created] if (status) status = { ...status, replies_count: status.replies_count + 1 } diff --git a/src/routes/Timeline.svelte b/src/routes/Timeline.svelte index 95b3591..e6ee858 100644 --- a/src/routes/Timeline.svelte +++ b/src/routes/Timeline.svelte @@ -64,7 +64,7 @@ }) }) - $effect(() => timelineRefresh?.register(() => void feed.reload())) + $effect(() => timelineRefresh?.register(() => void feed.refresh()))