smarter timeline update

This commit is contained in:
Moon.eth
2026-07-29 14:47:57 +09:00
parent cc132ad6b4
commit 110b659213
8 changed files with 449 additions and 12 deletions
+2
View File
@@ -39,6 +39,8 @@ export interface PageLinks {
export interface Page<T> {
items: T[]
links: PageLinks
/** Optional deletion/tombstone IDs supplied by streaming-aware loaders. */
deletedIds?: string[]
}
export type QueryValue = string | number | boolean | undefined | null | string[]
+220
View File
@@ -17,6 +17,123 @@ export interface Identified {
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)
@@ -57,6 +174,109 @@ export class Feed<T extends Identified> {
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
+160
View File
@@ -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<Page<Entry>> => {
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<Entry>) => void
const refreshPage = new Promise<Page<Entry>>((resolve) => {
resolveRefresh = resolve
})
const visible = entry('visible', 10)
const loader = vi
.fn()
.mockResolvedValueOnce({ items: [visible], links: {} })
.mockReturnValueOnce(refreshPage)
const feed = new Feed<Entry>(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<Page<Entry>> => {
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])
})
})