mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
smarter timeline update
This commit is contained in:
@@ -39,6 +39,8 @@ export interface PageLinks {
|
|||||||
export interface Page<T> {
|
export interface Page<T> {
|
||||||
items: T[]
|
items: T[]
|
||||||
links: PageLinks
|
links: PageLinks
|
||||||
|
/** Optional deletion/tombstone IDs supplied by streaming-aware loaders. */
|
||||||
|
deletedIds?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type QueryValue = string | number | boolean | undefined | null | string[]
|
export type QueryValue = string | number | boolean | undefined | null | string[]
|
||||||
|
|||||||
@@ -17,6 +17,123 @@ export interface Identified {
|
|||||||
|
|
||||||
export type Loader<T> = (cursor: Cursor) => Promise<Page<T>>
|
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> {
|
export class Feed<T extends Identified> {
|
||||||
items = $state<T[]>([])
|
items = $state<T[]>([])
|
||||||
loading = $state(false)
|
loading = $state(false)
|
||||||
@@ -57,6 +174,109 @@ export class Feed<T extends Identified> {
|
|||||||
await this.run(this.generation, false)
|
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> {
|
private async run(generation: number, replace: boolean): Promise<void> {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
this.error = null
|
this.error = null
|
||||||
|
|||||||
@@ -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])
|
||||||
|
})
|
||||||
|
})
|
||||||
+13
-2
@@ -18,6 +18,7 @@
|
|||||||
import { toPlainText } from '$lib/util/html'
|
import { toPlainText } from '$lib/util/html'
|
||||||
import { relativeTime, shortDate, stampDate } from '$lib/util/time'
|
import { relativeTime, shortDate, stampDate } from '$lib/util/time'
|
||||||
import { useTimelineRefresh } from '$lib/timeline-refresh'
|
import { useTimelineRefresh } from '$lib/timeline-refresh'
|
||||||
|
import { reconcileRefreshItems } from '$lib/stores/feed.svelte'
|
||||||
import Module from '$components/common/Module.svelte'
|
import Module from '$components/common/Module.svelte'
|
||||||
import Avatar from '$components/common/Avatar.svelte'
|
import Avatar from '$components/common/Avatar.svelte'
|
||||||
import RichText from '$components/common/RichText.svelte'
|
import RichText from '$components/common/RichText.svelte'
|
||||||
@@ -155,7 +156,12 @@
|
|||||||
session.host === host &&
|
session.host === host &&
|
||||||
session.signedIn === signedIn
|
session.signedIn === signedIn
|
||||||
) {
|
) {
|
||||||
friendStatus = page.items
|
friendStatus = reconcileRefreshItems(
|
||||||
|
friendStatus,
|
||||||
|
page.items,
|
||||||
|
page.deletedIds,
|
||||||
|
{ emptyIsAuthoritative: !page.links.maxId },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (generation === primaryRefreshGeneration && session.host === host) {
|
if (generation === primaryRefreshGeneration && session.host === host) {
|
||||||
@@ -173,7 +179,12 @@
|
|||||||
session.host === host &&
|
session.host === host &&
|
||||||
session.signedIn === signedIn
|
session.signedIn === signedIn
|
||||||
) {
|
) {
|
||||||
bulletins = page.items
|
bulletins = reconcileRefreshItems(
|
||||||
|
bulletins,
|
||||||
|
page.items,
|
||||||
|
page.deletedIds,
|
||||||
|
{ emptyIsAuthoritative: !page.links.maxId },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (generation === primaryRefreshGeneration && session.host === host) {
|
if (generation === primaryRefreshGeneration && session.host === host) {
|
||||||
|
|||||||
@@ -89,7 +89,7 @@
|
|||||||
const currentView = view
|
const currentView = view
|
||||||
const loaded = account !== null
|
const loaded = account !== null
|
||||||
if (!timelineRefresh || !loaded || currentView === 'friends') return
|
if (!timelineRefresh || !loaded || currentView === 'friends') return
|
||||||
return timelineRefresh.register(() => void entries.reload())
|
return timelineRefresh.register(() => void entries.refresh())
|
||||||
})
|
})
|
||||||
|
|
||||||
async function load(
|
async function load(
|
||||||
|
|||||||
@@ -143,8 +143,26 @@ describe('Profile', () => {
|
|||||||
|
|
||||||
it('refreshes only the entry feed without reloading the profile', async () => {
|
it('refreshes only the entry feed without reloading the profile', async () => {
|
||||||
const lookupAccount = vi.fn().mockResolvedValue(account())
|
const lookupAccount = vi.fn().mockResolvedValue(account())
|
||||||
const fetchAccountStatuses = vi.fn().mockResolvedValue({
|
const fetchAccountStatuses = vi
|
||||||
items: [status({ content: '<p>Timeline note</p>' })],
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
items: [
|
||||||
|
status({
|
||||||
|
id: 'timeline-note',
|
||||||
|
content: '<p>Timeline note</p>',
|
||||||
|
favourites_count: 0,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
links: {},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
items: [
|
||||||
|
status({
|
||||||
|
id: 'timeline-note',
|
||||||
|
content: '<p>Timeline note</p>',
|
||||||
|
favourites_count: 5,
|
||||||
|
}),
|
||||||
|
],
|
||||||
links: {},
|
links: {},
|
||||||
})
|
})
|
||||||
const services = testServices({
|
const services = testServices({
|
||||||
@@ -166,5 +184,8 @@ describe('Profile', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(fetchAccountStatuses).toHaveBeenCalledTimes(2))
|
await waitFor(() => expect(fetchAccountStatuses).toHaveBeenCalledTimes(2))
|
||||||
expect(lookupAccount).toHaveBeenCalledOnce()
|
expect(lookupAccount).toHaveBeenCalledOnce()
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(view.getByRole('button', { name: 'Kudos (5)' })).toBeInTheDocument(),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -95,10 +95,7 @@
|
|||||||
const host = session.host
|
const host = session.host
|
||||||
const loaded = status !== null
|
const loaded = status !== null
|
||||||
if (!timelineRefresh || !host || !loaded) return
|
if (!timelineRefresh || !host || !loaded) return
|
||||||
return timelineRefresh.register(() => {
|
return timelineRefresh.register(() => void refreshEntry(currentId, host))
|
||||||
const generation = ++loadGeneration
|
|
||||||
void load(currentId, host, generation)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async function load(currentId: string, host: string, generation: number): Promise<void> {
|
async function load(currentId: string, host: string, generation: number): Promise<void> {
|
||||||
@@ -123,6 +120,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Refresh the entry and complete thread snapshot without hiding the page. */
|
||||||
|
async function refreshEntry(currentId: string, host: string): Promise<void> {
|
||||||
|
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 {
|
function onPosted(created: Status): void {
|
||||||
descendants = [...descendants, created]
|
descendants = [...descendants, created]
|
||||||
if (status) status = { ...status, replies_count: status.replies_count + 1 }
|
if (status) status = { ...status, replies_count: status.replies_count + 1 }
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => timelineRefresh?.register(() => void feed.reload()))
|
$effect(() => timelineRefresh?.register(() => void feed.refresh()))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="page timeline-page" data-timeline={kind} data-tag={tag ?? ''}>
|
<div class="page timeline-page" data-timeline={kind} data-tag={tag ?? ''}>
|
||||||
|
|||||||
Reference in New Issue
Block a user