29 changed files with 1982 additions and 274 deletions
@ -0,0 +1,47 @@ |
|||||||
|
import { describe, expect, it } from 'vitest' |
||||||
|
import { |
||||||
|
calendarFeedDescriptor, |
||||||
|
embedFeedDescriptor, |
||||||
|
favoritesFeedDescriptor, |
||||||
|
homeFeedDescriptor, |
||||||
|
profileFeedDescriptor, |
||||||
|
relayFeedDescriptor, |
||||||
|
repliesFeedDescriptor, |
||||||
|
searchFeedDescriptor, |
||||||
|
spellsFeedDescriptor, |
||||||
|
threadFeedDescriptor |
||||||
|
} from './adapters' |
||||||
|
|
||||||
|
const requests = [{ urls: ['wss://relay.example/'], filter: { kinds: [1], limit: 20 } }] |
||||||
|
|
||||||
|
describe('feed surface adapters', () => { |
||||||
|
it('marks timeline surfaces as live and paginated by default', () => { |
||||||
|
for (const descriptor of [ |
||||||
|
homeFeedDescriptor(requests), |
||||||
|
favoritesFeedDescriptor(requests), |
||||||
|
relayFeedDescriptor(requests, 'wss://relay.example/'), |
||||||
|
profileFeedDescriptor(requests, 'pubkey'), |
||||||
|
spellsFeedDescriptor(requests), |
||||||
|
calendarFeedDescriptor(requests) |
||||||
|
]) { |
||||||
|
expect(descriptor.mode).toBe('live') |
||||||
|
expect(descriptor.pagination.enabled).toBe(true) |
||||||
|
expect(descriptor.source.cache).toBe('stale-while-refresh') |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
it('marks focused fetch surfaces as one-shot', () => { |
||||||
|
for (const descriptor of [ |
||||||
|
repliesFeedDescriptor(requests, 'reply-root'), |
||||||
|
threadFeedDescriptor(requests, 'thread-root'), |
||||||
|
embedFeedDescriptor(requests, 'embedded-note'), |
||||||
|
searchFeedDescriptor(requests, 'search:nostr') |
||||||
|
]) { |
||||||
|
expect(descriptor.mode).toBe('one-shot') |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
it('keeps surface identity separate for equivalent requests', () => { |
||||||
|
expect(homeFeedDescriptor(requests).key).not.toBe(favoritesFeedDescriptor(requests).key) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,281 @@ |
|||||||
|
import type { TFeedSubRequest } from '@/types' |
||||||
|
import { createFeedDescriptor, type FeedDescriptor, type FeedDescriptorInput, type FeedSurface } from './descriptor' |
||||||
|
|
||||||
|
type FeedAdapterOptions = { |
||||||
|
id?: string |
||||||
|
live?: boolean |
||||||
|
view?: FeedDescriptorInput['view'] |
||||||
|
source?: FeedDescriptorInput['source'] |
||||||
|
pagination?: FeedDescriptorInput['pagination'] |
||||||
|
} |
||||||
|
|
||||||
|
export function descriptorFromSubRequests(args: { |
||||||
|
surface: FeedSurface |
||||||
|
id?: string |
||||||
|
requests: readonly TFeedSubRequest[] |
||||||
|
live?: boolean |
||||||
|
view?: FeedDescriptorInput['view'] |
||||||
|
source?: FeedDescriptorInput['source'] |
||||||
|
pagination?: FeedDescriptorInput['pagination'] |
||||||
|
}): FeedDescriptor { |
||||||
|
return createFeedDescriptor({ |
||||||
|
surface: args.surface, |
||||||
|
id: args.id, |
||||||
|
mode: args.live === false ? 'one-shot' : 'live', |
||||||
|
requests: args.requests, |
||||||
|
view: args.view, |
||||||
|
source: args.source, |
||||||
|
pagination: args.pagination |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
function surfaceDescriptor( |
||||||
|
surface: FeedSurface, |
||||||
|
requests: readonly TFeedSubRequest[], |
||||||
|
defaults: FeedAdapterOptions, |
||||||
|
options: FeedAdapterOptions = {} |
||||||
|
): FeedDescriptor { |
||||||
|
return descriptorFromSubRequests({ |
||||||
|
surface, |
||||||
|
id: options.id ?? defaults.id, |
||||||
|
requests, |
||||||
|
live: options.live ?? defaults.live, |
||||||
|
view: { ...defaults.view, ...options.view }, |
||||||
|
source: { ...defaults.source, ...options.source }, |
||||||
|
pagination: { ...defaults.pagination, ...options.pagination } |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
export function homeFeedDescriptor(requests: readonly TFeedSubRequest[], id = 'home'): FeedDescriptor { |
||||||
|
return descriptorFromSubRequests({ |
||||||
|
surface: 'home', |
||||||
|
id, |
||||||
|
requests, |
||||||
|
source: { cache: 'stale-while-refresh', publicReadFallback: true }, |
||||||
|
pagination: { enabled: true } |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
export function favoritesFeedDescriptor(requests: readonly TFeedSubRequest[], id = 'favorites'): FeedDescriptor { |
||||||
|
return surfaceDescriptor('favorites', requests, { |
||||||
|
id, |
||||||
|
source: { cache: 'stale-while-refresh' }, |
||||||
|
pagination: { enabled: true } |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
export function relayFeedDescriptor( |
||||||
|
requests: readonly TFeedSubRequest[], |
||||||
|
relayUrl: string, |
||||||
|
options?: FeedAdapterOptions |
||||||
|
): FeedDescriptor { |
||||||
|
return surfaceDescriptor('relay', requests, { |
||||||
|
id: relayUrl, |
||||||
|
source: { cache: 'stale-while-refresh', preserveRowsOnRelayChange: true }, |
||||||
|
pagination: { enabled: true } |
||||||
|
}, options) |
||||||
|
} |
||||||
|
|
||||||
|
export function relaySetFeedDescriptor( |
||||||
|
requests: readonly TFeedSubRequest[], |
||||||
|
id: string, |
||||||
|
options?: FeedAdapterOptions |
||||||
|
): FeedDescriptor { |
||||||
|
return surfaceDescriptor('relay-set', requests, { |
||||||
|
id, |
||||||
|
source: { cache: 'stale-while-refresh', preserveRowsOnRelayChange: true }, |
||||||
|
pagination: { enabled: true } |
||||||
|
}, options) |
||||||
|
} |
||||||
|
|
||||||
|
export function profileFeedDescriptor( |
||||||
|
requests: readonly TFeedSubRequest[], |
||||||
|
pubkey: string, |
||||||
|
options?: FeedAdapterOptions |
||||||
|
): FeedDescriptor { |
||||||
|
return surfaceDescriptor('profile', requests, { |
||||||
|
id: pubkey, |
||||||
|
source: { cache: 'stale-while-refresh', publicReadFallback: true }, |
||||||
|
pagination: { enabled: true } |
||||||
|
}, options) |
||||||
|
} |
||||||
|
|
||||||
|
export function profileMediaFeedDescriptor( |
||||||
|
requests: readonly TFeedSubRequest[], |
||||||
|
pubkey: string, |
||||||
|
options?: FeedAdapterOptions |
||||||
|
): FeedDescriptor { |
||||||
|
return surfaceDescriptor('profile-media', requests, { |
||||||
|
id: pubkey, |
||||||
|
source: { cache: 'stale-while-refresh', publicReadFallback: true }, |
||||||
|
pagination: { enabled: true } |
||||||
|
}, options) |
||||||
|
} |
||||||
|
|
||||||
|
export function profilePublicationsFeedDescriptor( |
||||||
|
requests: readonly TFeedSubRequest[], |
||||||
|
pubkey: string, |
||||||
|
options?: FeedAdapterOptions |
||||||
|
): FeedDescriptor { |
||||||
|
return surfaceDescriptor('profile-publications', requests, { |
||||||
|
id: pubkey, |
||||||
|
source: { cache: 'stale-while-refresh', publicReadFallback: true }, |
||||||
|
pagination: { enabled: true } |
||||||
|
}, options) |
||||||
|
} |
||||||
|
|
||||||
|
export function notificationsFeedDescriptor(requests: readonly TFeedSubRequest[]): FeedDescriptor { |
||||||
|
return descriptorFromSubRequests({ |
||||||
|
surface: 'notifications', |
||||||
|
id: 'notifications', |
||||||
|
requests, |
||||||
|
source: { cache: 'stale-while-refresh', publicReadFallback: true }, |
||||||
|
pagination: { enabled: true } |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
export function spellsFeedDescriptor( |
||||||
|
requests: readonly TFeedSubRequest[], |
||||||
|
id = 'spells', |
||||||
|
options?: FeedAdapterOptions |
||||||
|
): FeedDescriptor { |
||||||
|
return surfaceDescriptor('spells', requests, { |
||||||
|
id, |
||||||
|
source: { cache: 'stale-while-refresh', publicReadFallback: true }, |
||||||
|
pagination: { enabled: true } |
||||||
|
}, options) |
||||||
|
} |
||||||
|
|
||||||
|
export function repliesFeedDescriptor(requests: readonly TFeedSubRequest[], id: string): FeedDescriptor { |
||||||
|
return descriptorFromSubRequests({ |
||||||
|
surface: 'replies', |
||||||
|
id, |
||||||
|
requests, |
||||||
|
live: false, |
||||||
|
source: { cache: 'stale-while-refresh' }, |
||||||
|
pagination: { enabled: false } |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
export function threadFeedDescriptor( |
||||||
|
requests: readonly TFeedSubRequest[], |
||||||
|
id: string, |
||||||
|
options?: FeedAdapterOptions |
||||||
|
): FeedDescriptor { |
||||||
|
return surfaceDescriptor('thread', requests, { |
||||||
|
id, |
||||||
|
live: false, |
||||||
|
source: { cache: 'stale-while-refresh' }, |
||||||
|
pagination: { enabled: false } |
||||||
|
}, options) |
||||||
|
} |
||||||
|
|
||||||
|
export function embedFeedDescriptor( |
||||||
|
requests: readonly TFeedSubRequest[], |
||||||
|
id: string, |
||||||
|
options?: FeedAdapterOptions |
||||||
|
): FeedDescriptor { |
||||||
|
return surfaceDescriptor('embed', requests, { |
||||||
|
id, |
||||||
|
live: false, |
||||||
|
source: { cache: 'fresh-required', publicReadFallback: true }, |
||||||
|
pagination: { enabled: false } |
||||||
|
}, options) |
||||||
|
} |
||||||
|
|
||||||
|
export function searchFeedDescriptor( |
||||||
|
requests: readonly TFeedSubRequest[], |
||||||
|
id: string, |
||||||
|
options?: FeedAdapterOptions |
||||||
|
): FeedDescriptor { |
||||||
|
return surfaceDescriptor('search', requests, { |
||||||
|
id, |
||||||
|
live: false, |
||||||
|
source: { cache: 'fresh-required', publicReadFallback: true }, |
||||||
|
pagination: { enabled: true } |
||||||
|
}, options) |
||||||
|
} |
||||||
|
|
||||||
|
export function hashtagFeedDescriptor( |
||||||
|
requests: readonly TFeedSubRequest[], |
||||||
|
tag: string, |
||||||
|
options?: FeedAdapterOptions |
||||||
|
): FeedDescriptor { |
||||||
|
return surfaceDescriptor('hashtag', requests, { |
||||||
|
id: tag, |
||||||
|
source: { cache: 'stale-while-refresh', publicReadFallback: true }, |
||||||
|
pagination: { enabled: true } |
||||||
|
}, options) |
||||||
|
} |
||||||
|
|
||||||
|
export function calendarFeedDescriptor( |
||||||
|
requests: readonly TFeedSubRequest[], |
||||||
|
id = 'calendar', |
||||||
|
options?: FeedAdapterOptions |
||||||
|
): FeedDescriptor { |
||||||
|
return surfaceDescriptor('calendar', requests, { |
||||||
|
id, |
||||||
|
source: { cache: 'stale-while-refresh', publicReadFallback: true }, |
||||||
|
pagination: { enabled: true } |
||||||
|
}, options) |
||||||
|
} |
||||||
|
|
||||||
|
export function relayReviewsFeedDescriptor( |
||||||
|
requests: readonly TFeedSubRequest[], |
||||||
|
id = 'relay-reviews', |
||||||
|
options?: FeedAdapterOptions |
||||||
|
): FeedDescriptor { |
||||||
|
return surfaceDescriptor('relay-reviews', requests, { |
||||||
|
id, |
||||||
|
source: { cache: 'stale-while-refresh', publicReadFallback: true }, |
||||||
|
pagination: { enabled: true } |
||||||
|
}, options) |
||||||
|
} |
||||||
|
|
||||||
|
export function bookmarksFeedDescriptor( |
||||||
|
requests: readonly TFeedSubRequest[], |
||||||
|
id = 'bookmarks', |
||||||
|
options?: FeedAdapterOptions |
||||||
|
): FeedDescriptor { |
||||||
|
return surfaceDescriptor('bookmarks', requests, { |
||||||
|
id, |
||||||
|
source: { cache: 'stale-while-refresh' }, |
||||||
|
pagination: { enabled: true } |
||||||
|
}, options) |
||||||
|
} |
||||||
|
|
||||||
|
export function pinsFeedDescriptor( |
||||||
|
requests: readonly TFeedSubRequest[], |
||||||
|
id = 'pins', |
||||||
|
options?: FeedAdapterOptions |
||||||
|
): FeedDescriptor { |
||||||
|
return surfaceDescriptor('pins', requests, { |
||||||
|
id, |
||||||
|
source: { cache: 'stale-while-refresh', publicReadFallback: true }, |
||||||
|
pagination: { enabled: true } |
||||||
|
}, options) |
||||||
|
} |
||||||
|
|
||||||
|
export function interestsFeedDescriptor( |
||||||
|
requests: readonly TFeedSubRequest[], |
||||||
|
id = 'interests', |
||||||
|
options?: FeedAdapterOptions |
||||||
|
): FeedDescriptor { |
||||||
|
return surfaceDescriptor('interests', requests, { |
||||||
|
id, |
||||||
|
source: { cache: 'stale-while-refresh', publicReadFallback: true }, |
||||||
|
pagination: { enabled: true } |
||||||
|
}, options) |
||||||
|
} |
||||||
|
|
||||||
|
export function customFeedDescriptor( |
||||||
|
requests: readonly TFeedSubRequest[], |
||||||
|
id: string, |
||||||
|
options?: FeedAdapterOptions |
||||||
|
): FeedDescriptor { |
||||||
|
return surfaceDescriptor('custom', requests, { |
||||||
|
id, |
||||||
|
source: { cache: 'stale-while-refresh' }, |
||||||
|
pagination: { enabled: true } |
||||||
|
}, options) |
||||||
|
} |
||||||
@ -0,0 +1,56 @@ |
|||||||
|
import { describe, expect, it } from 'vitest' |
||||||
|
import { applyFeedCursorToRequests, createFetchEventsFeedRuntimeLoader, type FeedEventsClient } from './client-loader' |
||||||
|
import type { Event, Filter } from 'nostr-tools' |
||||||
|
|
||||||
|
function evt(id: string, created_at: number): Event { |
||||||
|
return { |
||||||
|
id, |
||||||
|
pubkey: `pubkey-${id}`, |
||||||
|
created_at, |
||||||
|
kind: 1, |
||||||
|
tags: [], |
||||||
|
content: '', |
||||||
|
sig: `sig-${id}` |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
describe('feed client loader', () => { |
||||||
|
it('applies load-more cursors to request filters', () => { |
||||||
|
expect( |
||||||
|
applyFeedCursorToRequests( |
||||||
|
[{ urls: ['wss://relay.example/'], filter: { kinds: [1], until: 50, limit: 20 } }], |
||||||
|
40 |
||||||
|
)[0].filter |
||||||
|
).toEqual({ kinds: [1], until: 40, limit: 20 }) |
||||||
|
}) |
||||||
|
|
||||||
|
it('hydrates disk cache before relay reads and dedupes relay results', async () => { |
||||||
|
const calls: Array<{ urls: string[]; filter: Filter }> = [] |
||||||
|
const client: FeedEventsClient = { |
||||||
|
getTimelineDiskSnapshotEvents: async () => [evt('cached', 30)], |
||||||
|
fetchEvents: async (urls, filter) => { |
||||||
|
calls.push({ urls, filter: filter as Filter }) |
||||||
|
return [evt('relay-a', 20), evt('relay-a', 20), evt('relay-b', 10)] |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const loader = createFetchEventsFeedRuntimeLoader(client, { |
||||||
|
subRequests: [{ urls: ['wss://relay.example/'], filter: { kinds: [1], limit: 20 } }], |
||||||
|
hydrateFromDisk: true, |
||||||
|
cache: true |
||||||
|
}) |
||||||
|
const result = await loader({ |
||||||
|
descriptorKey: 'feed-a', |
||||||
|
generation: 1, |
||||||
|
refresh: false, |
||||||
|
page: 'initial', |
||||||
|
signal: new AbortController().signal |
||||||
|
}) |
||||||
|
|
||||||
|
expect(result.cacheEvents?.map((event) => event.id)).toEqual(['cached']) |
||||||
|
expect(result.cacheStale).toBe(true) |
||||||
|
expect(result.relayEvents?.map((event) => event.id).sort()).toEqual(['relay-a', 'relay-b']) |
||||||
|
expect(result.hasMore).toBe(true) |
||||||
|
expect(calls).toHaveLength(1) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,87 @@ |
|||||||
|
import type { TSubRequestFilter } from '@/types' |
||||||
|
import type { Event, Filter } from 'nostr-tools' |
||||||
|
import type { FeedRuntimeLoader, FeedRuntimeLoadResult } from './runtime' |
||||||
|
|
||||||
|
export type FeedLoaderSubRequest = { |
||||||
|
urls: string[] |
||||||
|
filter: Filter |
||||||
|
} |
||||||
|
|
||||||
|
export type FeedEventsClient = { |
||||||
|
fetchEvents: ( |
||||||
|
urls: string[], |
||||||
|
filter: Filter | Filter[], |
||||||
|
options?: { |
||||||
|
cache?: boolean |
||||||
|
globalTimeout?: number |
||||||
|
eoseTimeout?: number |
||||||
|
firstRelayResultGraceMs?: number | false |
||||||
|
} |
||||||
|
) => Promise<Event[]> |
||||||
|
getTimelineDiskSnapshotEvents?: (subRequests: { urls: string[]; filter: TSubRequestFilter }[]) => Promise<Event[]> |
||||||
|
} |
||||||
|
|
||||||
|
export type FetchEventsFeedLoaderOptions = { |
||||||
|
subRequests: readonly FeedLoaderSubRequest[] |
||||||
|
cache?: boolean |
||||||
|
hydrateFromDisk?: boolean |
||||||
|
globalTimeout?: number |
||||||
|
eoseTimeout?: number |
||||||
|
firstRelayResultGraceMs?: number | false |
||||||
|
} |
||||||
|
|
||||||
|
function filterWithCursor(filter: Filter, cursor: number | undefined): Filter { |
||||||
|
if (cursor === undefined) return filter |
||||||
|
const currentUntil = typeof filter.until === 'number' ? filter.until : cursor |
||||||
|
return { |
||||||
|
...filter, |
||||||
|
until: Math.min(currentUntil, cursor) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function applyFeedCursorToRequests( |
||||||
|
subRequests: readonly FeedLoaderSubRequest[], |
||||||
|
cursor: number | undefined |
||||||
|
): FeedLoaderSubRequest[] { |
||||||
|
return subRequests.map((request) => ({ |
||||||
|
...request, |
||||||
|
filter: filterWithCursor(request.filter, cursor) |
||||||
|
})) |
||||||
|
} |
||||||
|
|
||||||
|
export function createFetchEventsFeedRuntimeLoader( |
||||||
|
client: FeedEventsClient, |
||||||
|
options: FetchEventsFeedLoaderOptions |
||||||
|
): FeedRuntimeLoader { |
||||||
|
return async ({ page, cursor, signal }): Promise<FeedRuntimeLoadResult> => { |
||||||
|
const requests = applyFeedCursorToRequests(options.subRequests, page === 'load-more' ? cursor : undefined) |
||||||
|
const cacheEvents = |
||||||
|
page !== 'load-more' && options.hydrateFromDisk && client.getTimelineDiskSnapshotEvents |
||||||
|
? await client.getTimelineDiskSnapshotEvents( |
||||||
|
requests as Array<{ urls: string[]; filter: TSubRequestFilter }> |
||||||
|
) |
||||||
|
: undefined |
||||||
|
|
||||||
|
if (signal.aborted) return { cacheEvents, cacheStale: true, relayEvents: [] } |
||||||
|
|
||||||
|
const batches = await Promise.all( |
||||||
|
requests.map(({ urls, filter }) => |
||||||
|
client.fetchEvents(urls, filter as Filter, { |
||||||
|
cache: options.cache, |
||||||
|
globalTimeout: options.globalTimeout, |
||||||
|
eoseTimeout: options.eoseTimeout, |
||||||
|
firstRelayResultGraceMs: options.firstRelayResultGraceMs |
||||||
|
}) |
||||||
|
) |
||||||
|
) |
||||||
|
const byId = new Map<string, Event>() |
||||||
|
for (const event of batches.flat()) byId.set(event.id, event) |
||||||
|
const relayEvents = [...byId.values()] |
||||||
|
return { |
||||||
|
cacheEvents, |
||||||
|
cacheStale: cacheEvents ? true : undefined, |
||||||
|
relayEvents, |
||||||
|
hasMore: relayEvents.length > 0 |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,76 @@ |
|||||||
|
import { describe, expect, it } from 'vitest' |
||||||
|
import { |
||||||
|
buildFeedSessionSnapshotKey, |
||||||
|
createFeedDescriptor, |
||||||
|
legacyFeedSubscriptionKey, |
||||||
|
stableFeedKindKey |
||||||
|
} from './descriptor' |
||||||
|
|
||||||
|
describe('FeedDescriptor canonicalization', () => { |
||||||
|
it('uses the same key for equivalent relay and filter ordering', () => { |
||||||
|
const a = createFeedDescriptor({ |
||||||
|
surface: 'home', |
||||||
|
requests: [ |
||||||
|
{ |
||||||
|
urls: ['wss://relay-b.example/', 'wss://relay-a.example/'], |
||||||
|
filter: { kinds: [30023, 1], '#p': ['b', 'a'], limit: 50 } |
||||||
|
} |
||||||
|
] |
||||||
|
}) |
||||||
|
const b = createFeedDescriptor({ |
||||||
|
surface: 'home', |
||||||
|
requests: [ |
||||||
|
{ |
||||||
|
urls: ['wss://relay-a.example/', 'wss://relay-b.example/'], |
||||||
|
filter: { '#p': ['a', 'b'], limit: 50, kinds: [1, 30023] } |
||||||
|
} |
||||||
|
] |
||||||
|
}) |
||||||
|
|
||||||
|
expect(a.key).toBe(b.key) |
||||||
|
}) |
||||||
|
|
||||||
|
it('separates surfaces even when requests match', () => { |
||||||
|
const requests = [{ urls: ['wss://relay.example/'], filter: { kinds: [1], limit: 50 } }] |
||||||
|
|
||||||
|
expect(createFeedDescriptor({ surface: 'home', requests }).key).not.toBe( |
||||||
|
createFeedDescriptor({ surface: 'notifications', requests }).key |
||||||
|
) |
||||||
|
}) |
||||||
|
|
||||||
|
it('exposes a stable legacy subscription key for old NoteList callers', () => { |
||||||
|
const first = legacyFeedSubscriptionKey([ |
||||||
|
{ urls: ['wss://b.example/', 'wss://a.example/'], filter: { kinds: [7, 1], limit: 20 } } |
||||||
|
]) |
||||||
|
const second = legacyFeedSubscriptionKey([ |
||||||
|
{ urls: ['wss://a.example/', 'wss://b.example/'], filter: { limit: 20, kinds: [1, 7] } } |
||||||
|
]) |
||||||
|
|
||||||
|
expect(first).toBe(second) |
||||||
|
}) |
||||||
|
|
||||||
|
it('builds stable NoteList session snapshot identities outside the component', () => { |
||||||
|
const kindsKey = stableFeedKindKey([30023, 1]) |
||||||
|
expect(kindsKey).toBe('[1,30023]') |
||||||
|
|
||||||
|
expect( |
||||||
|
buildFeedSessionSnapshotKey({ |
||||||
|
feedKey: 'feed-a', |
||||||
|
kindsKey, |
||||||
|
showKind1OPs: true, |
||||||
|
showKind1Replies: false, |
||||||
|
showKind1111: true, |
||||||
|
seeAllFeedEvents: false |
||||||
|
}) |
||||||
|
).toBe( |
||||||
|
JSON.stringify({ |
||||||
|
feed: 'feed-a', |
||||||
|
kinds: '[1,30023]', |
||||||
|
op: true, |
||||||
|
rep: false, |
||||||
|
c1111: true, |
||||||
|
seeAll: false |
||||||
|
}) |
||||||
|
) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,187 @@ |
|||||||
|
import { normalizeAnyRelayUrl } from '@/lib/url' |
||||||
|
import type { TFeedSubRequest, TSubRequestFilter } from '@/types' |
||||||
|
import type { Filter } from 'nostr-tools' |
||||||
|
|
||||||
|
export type FeedExecutionMode = 'live' | 'one-shot' |
||||||
|
export type FeedSurface = |
||||||
|
| 'home' |
||||||
|
| 'favorites' |
||||||
|
| 'relay' |
||||||
|
| 'relay-set' |
||||||
|
| 'profile' |
||||||
|
| 'profile-media' |
||||||
|
| 'profile-publications' |
||||||
|
| 'spells' |
||||||
|
| 'notifications' |
||||||
|
| 'replies' |
||||||
|
| 'thread' |
||||||
|
| 'embed' |
||||||
|
| 'search' |
||||||
|
| 'hashtag' |
||||||
|
| 'calendar' |
||||||
|
| 'relay-reviews' |
||||||
|
| 'bookmarks' |
||||||
|
| 'pins' |
||||||
|
| 'interests' |
||||||
|
| 'custom' |
||||||
|
|
||||||
|
export type FeedViewPolicy = { |
||||||
|
showKinds?: readonly number[] |
||||||
|
clientSideKindFilter?: boolean |
||||||
|
includeReplies?: boolean |
||||||
|
gridLayout?: boolean |
||||||
|
} |
||||||
|
|
||||||
|
export type FeedSourcePolicy = { |
||||||
|
cache?: 'disabled' | 'stale-while-refresh' | 'fresh-required' |
||||||
|
publicReadFallback?: boolean |
||||||
|
preserveRowsOnRelayChange?: boolean |
||||||
|
} |
||||||
|
|
||||||
|
export type FeedPaginationPolicy = { |
||||||
|
enabled: boolean |
||||||
|
pageSize?: number |
||||||
|
} |
||||||
|
|
||||||
|
export type FeedDescriptor = { |
||||||
|
surface: FeedSurface |
||||||
|
id: string |
||||||
|
mode: FeedExecutionMode |
||||||
|
requests: readonly TFeedSubRequest[] |
||||||
|
view: FeedViewPolicy |
||||||
|
source: FeedSourcePolicy |
||||||
|
pagination: FeedPaginationPolicy |
||||||
|
key: string |
||||||
|
} |
||||||
|
|
||||||
|
export type FeedDescriptorInput = { |
||||||
|
surface: FeedSurface |
||||||
|
id?: string |
||||||
|
mode?: FeedExecutionMode |
||||||
|
requests: readonly TFeedSubRequest[] |
||||||
|
view?: FeedViewPolicy |
||||||
|
source?: FeedSourcePolicy |
||||||
|
pagination?: Partial<FeedPaginationPolicy> |
||||||
|
} |
||||||
|
|
||||||
|
type Jsonish = string | number | boolean | null | Jsonish[] | { [key: string]: Jsonish } |
||||||
|
|
||||||
|
function normalizeScalar(value: unknown): Jsonish { |
||||||
|
if (value == null) return null |
||||||
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value |
||||||
|
return String(value) |
||||||
|
} |
||||||
|
|
||||||
|
function stableValue(value: unknown): Jsonish { |
||||||
|
if (Array.isArray(value)) { |
||||||
|
return value.map(normalizeScalar).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) |
||||||
|
} |
||||||
|
if (value && typeof value === 'object') { |
||||||
|
const out: Record<string, Jsonish> = {} |
||||||
|
for (const [k, v] of Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) { |
||||||
|
out[k] = stableValue(v) |
||||||
|
} |
||||||
|
return out |
||||||
|
} |
||||||
|
return normalizeScalar(value) |
||||||
|
} |
||||||
|
|
||||||
|
export function canonicalFeedFilter(filter: Omit<Filter, 'since' | 'until'> | TSubRequestFilter): Jsonish { |
||||||
|
return stableValue(filter) |
||||||
|
} |
||||||
|
|
||||||
|
export function canonicalRelayUrls(urls: readonly string[]): string[] { |
||||||
|
return Array.from( |
||||||
|
new Set( |
||||||
|
urls |
||||||
|
.map((u) => normalizeAnyRelayUrl(u) || u.trim()) |
||||||
|
.filter(Boolean) |
||||||
|
.map((u) => u.toLowerCase()) |
||||||
|
) |
||||||
|
).sort((a, b) => a.localeCompare(b)) |
||||||
|
} |
||||||
|
|
||||||
|
export function canonicalFeedRequests(requests: readonly TFeedSubRequest[]): Jsonish { |
||||||
|
return requests |
||||||
|
.map((req) => ({ |
||||||
|
urls: canonicalRelayUrls(req.urls), |
||||||
|
filter: canonicalFeedFilter(req.filter), |
||||||
|
reasonLabel: req.reasonLabel ?? null, |
||||||
|
reasonLabelIfSeenOnRelay: req.reasonLabelIfSeenOnRelay |
||||||
|
? (normalizeAnyRelayUrl(req.reasonLabelIfSeenOnRelay) || req.reasonLabelIfSeenOnRelay.trim()).toLowerCase() |
||||||
|
: null |
||||||
|
})) |
||||||
|
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) |
||||||
|
} |
||||||
|
|
||||||
|
export function buildFeedDescriptorKey(input: Omit<FeedDescriptorInput, 'id'> & { id: string }): string { |
||||||
|
return JSON.stringify({ |
||||||
|
surface: input.surface, |
||||||
|
id: input.id, |
||||||
|
mode: input.mode ?? 'live', |
||||||
|
requests: canonicalFeedRequests(input.requests), |
||||||
|
view: stableValue(input.view ?? {}), |
||||||
|
source: stableValue(input.source ?? {}), |
||||||
|
pagination: stableValue({ |
||||||
|
enabled: input.pagination?.enabled ?? (input.mode ?? 'live') === 'live', |
||||||
|
pageSize: input.pagination?.pageSize ?? null |
||||||
|
}) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
export function createFeedDescriptor(input: FeedDescriptorInput): FeedDescriptor { |
||||||
|
const id = input.id ?? input.surface |
||||||
|
const mode = input.mode ?? 'live' |
||||||
|
const pagination = { |
||||||
|
enabled: input.pagination?.enabled ?? mode === 'live', |
||||||
|
pageSize: input.pagination?.pageSize |
||||||
|
} |
||||||
|
const key = buildFeedDescriptorKey({ ...input, id, mode, pagination }) |
||||||
|
return { |
||||||
|
surface: input.surface, |
||||||
|
id, |
||||||
|
mode, |
||||||
|
requests: input.requests, |
||||||
|
view: input.view ?? {}, |
||||||
|
source: input.source ?? {}, |
||||||
|
pagination, |
||||||
|
key |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function legacyFeedSubscriptionKey(requests: readonly TFeedSubRequest[]): string { |
||||||
|
return JSON.stringify(canonicalFeedRequests(requests)) |
||||||
|
} |
||||||
|
|
||||||
|
export function stableFeedKindKey(kinds: readonly number[] | undefined): string { |
||||||
|
if (!kinds?.length) return '' |
||||||
|
return JSON.stringify([...kinds].sort((a, b) => a - b)) |
||||||
|
} |
||||||
|
|
||||||
|
export type FeedSessionSnapshotKeyInput = { |
||||||
|
feedKey: string |
||||||
|
homeSurface?: string |
||||||
|
allowKindlessRelayExplore?: boolean |
||||||
|
showAllKinds?: boolean |
||||||
|
kindsKey?: string |
||||||
|
showKind1OPs?: boolean |
||||||
|
showKind1Replies?: boolean |
||||||
|
showKind1111?: boolean |
||||||
|
seeAllFeedEvents?: boolean |
||||||
|
} |
||||||
|
|
||||||
|
export function buildFeedSessionSnapshotKey(input: FeedSessionSnapshotKeyInput): string { |
||||||
|
return JSON.stringify({ |
||||||
|
feed: input.feedKey, |
||||||
|
...(input.homeSurface ? { homeSurface: input.homeSurface } : {}), |
||||||
|
...(input.allowKindlessRelayExplore |
||||||
|
? { relayKindless: true, showAllKinds: input.showAllKinds } |
||||||
|
: { |
||||||
|
kinds: input.kindsKey ?? '', |
||||||
|
op: input.showKind1OPs, |
||||||
|
rep: input.showKind1Replies, |
||||||
|
c1111: input.showKind1111, |
||||||
|
seeAll: input.seeAllFeedEvents |
||||||
|
}) |
||||||
|
}) |
||||||
|
} |
||||||
@ -0,0 +1,41 @@ |
|||||||
|
import { describe, expect, it } from 'vitest' |
||||||
|
import { homeFeedDescriptor } from './adapters' |
||||||
|
import { buildFeedDiagnosticsSnapshot } from './diagnostics' |
||||||
|
import type { FeedRuntimeSnapshot } from './runtime' |
||||||
|
|
||||||
|
describe('buildFeedDiagnosticsSnapshot', () => { |
||||||
|
it('includes relay policy, empty-state, and pagination diagnostics', () => { |
||||||
|
const descriptor = homeFeedDescriptor([ |
||||||
|
{ urls: ['wss://relay.example/'], filter: { kinds: [1], limit: 20 } } |
||||||
|
]) |
||||||
|
const runtime: FeedRuntimeSnapshot = { |
||||||
|
generation: 1, |
||||||
|
status: 'ready', |
||||||
|
rows: [], |
||||||
|
stale: false, |
||||||
|
rawCount: 2, |
||||||
|
visibleCount: 1, |
||||||
|
hiddenCount: 1, |
||||||
|
relayOutcomes: [{ relayUrl: 'wss://relay.example/', status: 'event', eventCount: 2 }], |
||||||
|
emptyReason: 'not-empty', |
||||||
|
hasMore: true, |
||||||
|
paginationStatus: 'idle', |
||||||
|
nextCursor: 10 |
||||||
|
} |
||||||
|
|
||||||
|
const snapshot = buildFeedDiagnosticsSnapshot({ |
||||||
|
descriptor, |
||||||
|
relayPolicy: { |
||||||
|
urls: ['wss://relay.example/'], |
||||||
|
dropped: [{ url: 'bad', normalizedUrl: 'bad', source: 'fallback', reason: 'invalid' }] |
||||||
|
}, |
||||||
|
runtime |
||||||
|
}) |
||||||
|
|
||||||
|
expect(snapshot.surface).toBe('home') |
||||||
|
expect(snapshot.relayUrls).toEqual(['wss://relay.example/']) |
||||||
|
expect(snapshot.droppedRelays[0].reason).toBe('invalid') |
||||||
|
expect(snapshot.runtime.paginationStatus).toBe('idle') |
||||||
|
expect(snapshot.runtime.nextCursor).toBe(10) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,56 @@ |
|||||||
|
import type { FeedDescriptor } from './descriptor' |
||||||
|
import type { FeedRelayPolicyResult } from './relay-policy' |
||||||
|
import type { FeedRuntimeSnapshot } from './runtime' |
||||||
|
|
||||||
|
export type FeedDiagnosticsSnapshot = { |
||||||
|
descriptorKey: string |
||||||
|
surface: FeedDescriptor['surface'] |
||||||
|
relayUrls: string[] |
||||||
|
droppedRelays: FeedRelayPolicyResult['dropped'] |
||||||
|
runtime: Pick< |
||||||
|
FeedRuntimeSnapshot, |
||||||
|
| 'status' |
||||||
|
| 'stale' |
||||||
|
| 'rawCount' |
||||||
|
| 'visibleCount' |
||||||
|
| 'hiddenCount' |
||||||
|
| 'emptyReason' |
||||||
|
| 'relayOutcomes' |
||||||
|
| 'hasMore' |
||||||
|
| 'paginationStatus' |
||||||
|
| 'nextCursor' |
||||||
|
| 'pageError' |
||||||
|
> |
||||||
|
} |
||||||
|
|
||||||
|
export function buildFeedDiagnosticsSnapshot(args: { |
||||||
|
descriptor: FeedDescriptor |
||||||
|
relayPolicy: FeedRelayPolicyResult |
||||||
|
runtime: FeedRuntimeSnapshot |
||||||
|
}): FeedDiagnosticsSnapshot { |
||||||
|
return { |
||||||
|
descriptorKey: args.descriptor.key, |
||||||
|
surface: args.descriptor.surface, |
||||||
|
relayUrls: args.relayPolicy.urls, |
||||||
|
droppedRelays: args.relayPolicy.dropped, |
||||||
|
runtime: { |
||||||
|
status: args.runtime.status, |
||||||
|
stale: args.runtime.stale, |
||||||
|
rawCount: args.runtime.rawCount, |
||||||
|
visibleCount: args.runtime.visibleCount, |
||||||
|
hiddenCount: args.runtime.hiddenCount, |
||||||
|
emptyReason: args.runtime.emptyReason, |
||||||
|
relayOutcomes: args.runtime.relayOutcomes, |
||||||
|
hasMore: args.runtime.hasMore, |
||||||
|
paginationStatus: args.runtime.paginationStatus, |
||||||
|
nextCursor: args.runtime.nextCursor, |
||||||
|
pageError: args.runtime.pageError |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function logFeedDiagnostics(label: string, snapshot: FeedDiagnosticsSnapshot) { |
||||||
|
if (!import.meta.env.DEV) return |
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.debug(`[feed:${label}]`, snapshot) |
||||||
|
} |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
export * from './adapters' |
||||||
|
export * from './descriptor' |
||||||
|
export * from './diagnostics' |
||||||
|
export * from './relay-policy' |
||||||
|
export * from './runtime' |
||||||
@ -0,0 +1,61 @@ |
|||||||
|
import { describe, expect, it } from 'vitest' |
||||||
|
import { mapNoteListSubRequestsForTimeline } from './note-list-requests' |
||||||
|
|
||||||
|
describe('mapNoteListSubRequestsForTimeline', () => { |
||||||
|
it('adds default kinds and timeline limits for normal feeds', () => { |
||||||
|
const [request] = mapNoteListSubRequestsForTimeline( |
||||||
|
[{ urls: ['wss://relay.example/'], filter: { authors: ['alice'] } }], |
||||||
|
{ |
||||||
|
defaultKinds: [1], |
||||||
|
seeAllFeedEvents: false, |
||||||
|
useFilterAsIs: false, |
||||||
|
areAlgoRelays: false, |
||||||
|
allowKindlessRelayExplore: false, |
||||||
|
clientSideKindFilter: false, |
||||||
|
limit: 150, |
||||||
|
algoLimit: 200, |
||||||
|
relayExploreLimit: 120 |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
expect(request.filter).toEqual({ authors: ['alice'], kinds: [1], limit: 150 }) |
||||||
|
}) |
||||||
|
|
||||||
|
it('keeps kindless single-relay exploration kindless', () => { |
||||||
|
const [request] = mapNoteListSubRequestsForTimeline( |
||||||
|
[{ urls: ['wss://relay.example/'], filter: {} }], |
||||||
|
{ |
||||||
|
defaultKinds: [1], |
||||||
|
seeAllFeedEvents: false, |
||||||
|
useFilterAsIs: true, |
||||||
|
areAlgoRelays: false, |
||||||
|
allowKindlessRelayExplore: true, |
||||||
|
clientSideKindFilter: false, |
||||||
|
limit: 150, |
||||||
|
algoLimit: 200, |
||||||
|
relayExploreLimit: 120 |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
expect(request.filter).toEqual({ limit: 120 }) |
||||||
|
}) |
||||||
|
|
||||||
|
it('removes server-side kind filters for see-all feeds', () => { |
||||||
|
const [request] = mapNoteListSubRequestsForTimeline( |
||||||
|
[{ urls: ['wss://relay.example/'], filter: { kinds: [1], limit: 10 } }], |
||||||
|
{ |
||||||
|
defaultKinds: [1], |
||||||
|
seeAllFeedEvents: true, |
||||||
|
useFilterAsIs: false, |
||||||
|
areAlgoRelays: false, |
||||||
|
allowKindlessRelayExplore: false, |
||||||
|
clientSideKindFilter: false, |
||||||
|
limit: 150, |
||||||
|
algoLimit: 200, |
||||||
|
relayExploreLimit: 120 |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
expect(request.filter).toEqual({ limit: 150 }) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,66 @@ |
|||||||
|
import type { TFeedSubRequest } from '@/types' |
||||||
|
import type { Filter } from 'nostr-tools' |
||||||
|
|
||||||
|
export type NoteListTimelineRequestOptions = { |
||||||
|
defaultKinds: readonly number[] |
||||||
|
seeAllFeedEvents: boolean |
||||||
|
useFilterAsIs: boolean |
||||||
|
areAlgoRelays: boolean |
||||||
|
allowKindlessRelayExplore: boolean |
||||||
|
clientSideKindFilter: boolean |
||||||
|
limit: number |
||||||
|
algoLimit: number |
||||||
|
relayExploreLimit: number |
||||||
|
} |
||||||
|
|
||||||
|
export function mapNoteListSubRequestsForTimeline( |
||||||
|
requests: readonly TFeedSubRequest[], |
||||||
|
options: NoteListTimelineRequestOptions |
||||||
|
): TFeedSubRequest[] { |
||||||
|
const seeAllNoSpell = options.seeAllFeedEvents && !options.useFilterAsIs |
||||||
|
return requests.map(({ urls, filter }) => { |
||||||
|
const baseLimit = filter.limit ?? (options.areAlgoRelays ? options.algoLimit : options.limit) |
||||||
|
if (options.useFilterAsIs) { |
||||||
|
const hasKindsInRequest = Array.isArray(filter.kinds) && filter.kinds.length > 0 |
||||||
|
if (options.allowKindlessRelayExplore && urls.length === 1 && !hasKindsInRequest) { |
||||||
|
const finalFilter: Filter = { |
||||||
|
...filter, |
||||||
|
limit: filter.limit ?? options.relayExploreLimit |
||||||
|
} |
||||||
|
delete finalFilter.kinds |
||||||
|
return { urls, filter: finalFilter } |
||||||
|
} |
||||||
|
const finalFilter: Filter = { ...filter, limit: baseLimit } |
||||||
|
if (options.clientSideKindFilter) { |
||||||
|
if (hasKindsInRequest) { |
||||||
|
finalFilter.kinds = filter.kinds |
||||||
|
} else { |
||||||
|
delete finalFilter.kinds |
||||||
|
} |
||||||
|
} else if (hasKindsInRequest) { |
||||||
|
finalFilter.kinds = filter.kinds |
||||||
|
} else { |
||||||
|
finalFilter.kinds = [...options.defaultKinds] |
||||||
|
} |
||||||
|
return { urls, filter: finalFilter } |
||||||
|
} |
||||||
|
if (seeAllNoSpell) { |
||||||
|
const { kinds: _omitKinds, ...rest } = filter |
||||||
|
return { |
||||||
|
urls, |
||||||
|
filter: { |
||||||
|
...rest, |
||||||
|
limit: options.areAlgoRelays ? options.algoLimit : options.limit |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return { |
||||||
|
urls, |
||||||
|
filter: { |
||||||
|
...filter, |
||||||
|
kinds: [...options.defaultKinds], |
||||||
|
limit: options.areAlgoRelays ? options.algoLimit : options.limit |
||||||
|
} |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
@ -0,0 +1,63 @@ |
|||||||
|
import { describe, expect, it } from 'vitest' |
||||||
|
import { applyFeedRelayPolicy } from './relay-policy' |
||||||
|
|
||||||
|
describe('applyFeedRelayPolicy', () => { |
||||||
|
it('prepends aggr.nostr.land for read feeds before caps', () => { |
||||||
|
const result = applyFeedRelayPolicy( |
||||||
|
[{ source: 'viewer-read', urls: ['wss://reader-a.example/', 'wss://reader-b.example/'] }], |
||||||
|
{ operation: 'read', maxRelays: 2, applySocialKindBlockedFilter: false } |
||||||
|
) |
||||||
|
|
||||||
|
expect(result.urls).toEqual(['wss://aggr.nostr.land/', 'wss://reader-a.example/']) |
||||||
|
expect(result.dropped.some((drop) => drop.reason === 'over-cap')).toBe(true) |
||||||
|
}) |
||||||
|
|
||||||
|
it('keeps favorites feed strictly curated and removes the aggregator', () => { |
||||||
|
const result = applyFeedRelayPolicy( |
||||||
|
[{ source: 'favorites', urls: ['wss://relay.example/', 'wss://aggr.nostr.land/'] }], |
||||||
|
{ operation: 'favorites-feed', nostrLandAggr: 'never' } |
||||||
|
) |
||||||
|
|
||||||
|
expect(result.urls).toEqual(['wss://relay.example/']) |
||||||
|
expect(result.dropped).toContainEqual( |
||||||
|
expect.objectContaining({ |
||||||
|
normalizedUrl: 'wss://aggr.nostr.land/', |
||||||
|
reason: 'favorites-feed-aggr' |
||||||
|
}) |
||||||
|
) |
||||||
|
}) |
||||||
|
|
||||||
|
it('always lets user-blocked relays win', () => { |
||||||
|
const result = applyFeedRelayPolicy( |
||||||
|
[{ source: 'viewer-read', urls: ['wss://relay.example/', 'wss://aggr.nostr.land/'] }], |
||||||
|
{ |
||||||
|
operation: 'read', |
||||||
|
blockedRelays: ['wss://aggr.nostr.land/'], |
||||||
|
applySocialKindBlockedFilter: false |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
expect(result.urls).toEqual(['wss://relay.example/']) |
||||||
|
expect(result.dropped).toContainEqual( |
||||||
|
expect.objectContaining({ |
||||||
|
normalizedUrl: 'wss://aggr.nostr.land/', |
||||||
|
reason: 'user-blocked' |
||||||
|
}) |
||||||
|
) |
||||||
|
}) |
||||||
|
|
||||||
|
it('excludes read-only relays for write operations', () => { |
||||||
|
const result = applyFeedRelayPolicy( |
||||||
|
[{ source: 'fast-read', urls: ['wss://aggr.nostr.land/', 'wss://relay.example/'] }], |
||||||
|
{ operation: 'write', applySocialKindBlockedFilter: false } |
||||||
|
) |
||||||
|
|
||||||
|
expect(result.urls).toEqual(['wss://relay.example/']) |
||||||
|
expect(result.dropped).toContainEqual( |
||||||
|
expect.objectContaining({ |
||||||
|
normalizedUrl: 'wss://aggr.nostr.land/', |
||||||
|
reason: 'read-only-for-write' |
||||||
|
}) |
||||||
|
) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,218 @@ |
|||||||
|
import { |
||||||
|
READ_ONLY_RELAY_URLS, |
||||||
|
SOCIAL_KIND_BLOCKED_RELAY_URLS, |
||||||
|
relayFilterIncludesSocialKindBlockedKind |
||||||
|
} from '@/constants' |
||||||
|
import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr' |
||||||
|
import { |
||||||
|
relayFiltersUseCapitalLetterTagKeys, |
||||||
|
relayUrlsStripExtendedTagReqBlocked |
||||||
|
} from '@/lib/relay-extended-tag-req-blocks' |
||||||
|
import { isLocalNetworkUrl, normalizeAnyRelayUrl } from '@/lib/url' |
||||||
|
import type { TSubRequestFilter } from '@/types' |
||||||
|
|
||||||
|
export type FeedRelayOperation = 'read' | 'write' | 'publish-picker' | 'favorites-feed' |
||||||
|
|
||||||
|
export type FeedRelayDropReason = |
||||||
|
| 'invalid' |
||||||
|
| 'duplicate' |
||||||
|
| 'user-blocked' |
||||||
|
| 'read-only-for-write' |
||||||
|
| 'social-kind-blocked' |
||||||
|
| 'extended-tag-blocked' |
||||||
|
| 'third-party-local' |
||||||
|
| 'over-cap' |
||||||
|
| 'favorites-feed-aggr' |
||||||
|
|
||||||
|
export type FeedRelayLayerSource = |
||||||
|
| 'explicit' |
||||||
|
| 'viewer-read' |
||||||
|
| 'viewer-write' |
||||||
|
| 'author-read' |
||||||
|
| 'author-write' |
||||||
|
| 'favorites' |
||||||
|
| 'fast-read' |
||||||
|
| 'fast-write' |
||||||
|
| 'read-only' |
||||||
|
| 'search' |
||||||
|
| 'cache' |
||||||
|
| 'http-index' |
||||||
|
| 'relay-hint' |
||||||
|
| 'seen-on' |
||||||
|
| 'fallback' |
||||||
|
|
||||||
|
export type FeedRelayLayer = { |
||||||
|
source: FeedRelayLayerSource | string |
||||||
|
urls: readonly string[] |
||||||
|
/** |
||||||
|
* True when the layer is explicitly selected by the viewer for this surface |
||||||
|
* (for example a single-relay feed). Explicit single-relay reads can opt out |
||||||
|
* of local/social stripping that would otherwise hide the selected relay. |
||||||
|
*/ |
||||||
|
explicit?: boolean |
||||||
|
} |
||||||
|
|
||||||
|
export type FeedRelayDrop = { |
||||||
|
url: string |
||||||
|
normalizedUrl: string |
||||||
|
source: FeedRelayLayerSource | string |
||||||
|
reason: FeedRelayDropReason |
||||||
|
} |
||||||
|
|
||||||
|
export type FeedRelayPolicyContext = { |
||||||
|
operation: FeedRelayOperation |
||||||
|
blockedRelays?: readonly string[] |
||||||
|
filters?: readonly TSubRequestFilter[] |
||||||
|
eventKind?: number |
||||||
|
maxRelays?: number |
||||||
|
/** |
||||||
|
* Default: read surfaces include aggr.nostr.land; favorites and write |
||||||
|
* surfaces do not. Set explicitly for specialized fetches. |
||||||
|
*/ |
||||||
|
nostrLandAggr?: 'default' | 'always' | 'never' |
||||||
|
applySocialKindBlockedFilter?: boolean |
||||||
|
applyExtendedTagBlockedFilter?: boolean |
||||||
|
preserveSingleExplicitRelay?: boolean |
||||||
|
socialKindBlockedExemptRelays?: readonly string[] |
||||||
|
allowThirdPartyLocalRelays?: boolean |
||||||
|
} |
||||||
|
|
||||||
|
export type FeedRelayPolicyResult = { |
||||||
|
urls: string[] |
||||||
|
dropped: FeedRelayDrop[] |
||||||
|
} |
||||||
|
|
||||||
|
function canonicalRelayUrl(url: string | undefined | null): string { |
||||||
|
return (normalizeAnyRelayUrl(url ?? '') || (url ?? '').trim()).toLowerCase() |
||||||
|
} |
||||||
|
|
||||||
|
function normalizedRelayUrl(url: string): string { |
||||||
|
return normalizeAnyRelayUrl(url) || url.trim() |
||||||
|
} |
||||||
|
|
||||||
|
function normalizedSet(urls: readonly string[] | undefined): Set<string> { |
||||||
|
return new Set((urls ?? []).map(canonicalRelayUrl).filter(Boolean)) |
||||||
|
} |
||||||
|
|
||||||
|
function shouldApplySocialFilter(ctx: FeedRelayPolicyContext): boolean { |
||||||
|
if (ctx.applySocialKindBlockedFilter !== undefined) return ctx.applySocialKindBlockedFilter |
||||||
|
if (ctx.eventKind !== undefined) return relayFilterIncludesSocialKindBlockedKind({ kinds: [ctx.eventKind], limit: 1 }) |
||||||
|
return (ctx.filters ?? []).some((filter) => relayFilterIncludesSocialKindBlockedKind(filter)) |
||||||
|
} |
||||||
|
|
||||||
|
function shouldApplyExtendedTagFilter(ctx: FeedRelayPolicyContext): boolean { |
||||||
|
if (ctx.applyExtendedTagBlockedFilter !== undefined) return ctx.applyExtendedTagBlockedFilter |
||||||
|
return (ctx.filters ?? []).some((filter) => relayFiltersUseCapitalLetterTagKeys([filter])) |
||||||
|
} |
||||||
|
|
||||||
|
function shouldEnsureAggr(ctx: FeedRelayPolicyContext): boolean { |
||||||
|
if (ctx.nostrLandAggr === 'always') return true |
||||||
|
if (ctx.nostrLandAggr === 'never') return false |
||||||
|
return ctx.operation === 'read' |
||||||
|
} |
||||||
|
|
||||||
|
function isReadOnlyRelay(norm: string): boolean { |
||||||
|
return normalizedSet(READ_ONLY_RELAY_URLS).has(norm) |
||||||
|
} |
||||||
|
|
||||||
|
function isSocialKindBlockedRelay(norm: string): boolean { |
||||||
|
return normalizedSet(SOCIAL_KIND_BLOCKED_RELAY_URLS).has(norm) |
||||||
|
} |
||||||
|
|
||||||
|
function isExtendedTagBlockedRelay(norm: string): boolean { |
||||||
|
const stripped = relayUrlsStripExtendedTagReqBlocked([norm]) |
||||||
|
return stripped.length === 0 |
||||||
|
} |
||||||
|
|
||||||
|
function addDrop( |
||||||
|
dropped: FeedRelayDrop[], |
||||||
|
url: string, |
||||||
|
source: FeedRelayLayerSource | string, |
||||||
|
reason: FeedRelayDropReason |
||||||
|
) { |
||||||
|
dropped.push({ |
||||||
|
url, |
||||||
|
normalizedUrl: normalizedRelayUrl(url), |
||||||
|
source, |
||||||
|
reason |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
export function applyFeedRelayPolicy( |
||||||
|
inputLayers: readonly FeedRelayLayer[], |
||||||
|
context: FeedRelayPolicyContext |
||||||
|
): FeedRelayPolicyResult { |
||||||
|
const blocked = normalizedSet(context.blockedRelays) |
||||||
|
const socialExempt = normalizedSet(context.socialKindBlockedExemptRelays) |
||||||
|
const socialFilter = shouldApplySocialFilter(context) |
||||||
|
const extendedFilter = shouldApplyExtendedTagFilter(context) |
||||||
|
const max = context.maxRelays ?? Number.POSITIVE_INFINITY |
||||||
|
const layers: FeedRelayLayer[] = shouldEnsureAggr(context) |
||||||
|
? [{ source: 'read-only', urls: [AGGR_NOSTR_LAND_WSS] }, ...inputLayers] |
||||||
|
: [...inputLayers] |
||||||
|
const seen = new Set<string>() |
||||||
|
const dropped: FeedRelayDrop[] = [] |
||||||
|
const urls: string[] = [] |
||||||
|
|
||||||
|
for (const layer of layers) { |
||||||
|
for (const raw of layer.urls) { |
||||||
|
const normalized = normalizedRelayUrl(raw) |
||||||
|
const key = canonicalRelayUrl(normalized) |
||||||
|
if (!normalized || !key) { |
||||||
|
addDrop(dropped, raw, layer.source, 'invalid') |
||||||
|
continue |
||||||
|
} |
||||||
|
if (seen.has(key)) { |
||||||
|
addDrop(dropped, normalized, layer.source, 'duplicate') |
||||||
|
continue |
||||||
|
} |
||||||
|
if (blocked.has(key)) { |
||||||
|
addDrop(dropped, normalized, layer.source, 'user-blocked') |
||||||
|
continue |
||||||
|
} |
||||||
|
if (context.operation === 'favorites-feed' && key === canonicalRelayUrl(AGGR_NOSTR_LAND_WSS)) { |
||||||
|
addDrop(dropped, normalized, layer.source, 'favorites-feed-aggr') |
||||||
|
continue |
||||||
|
} |
||||||
|
if ( |
||||||
|
(context.operation === 'write' || context.operation === 'publish-picker') && |
||||||
|
isReadOnlyRelay(key) |
||||||
|
) { |
||||||
|
addDrop(dropped, normalized, layer.source, 'read-only-for-write') |
||||||
|
continue |
||||||
|
} |
||||||
|
if ( |
||||||
|
socialFilter && |
||||||
|
isSocialKindBlockedRelay(key) && |
||||||
|
!socialExempt.has(key) && |
||||||
|
!(context.preserveSingleExplicitRelay && layer.explicit && layer.urls.length === 1) |
||||||
|
) { |
||||||
|
addDrop(dropped, normalized, layer.source, 'social-kind-blocked') |
||||||
|
continue |
||||||
|
} |
||||||
|
if (extendedFilter && isExtendedTagBlockedRelay(normalized)) { |
||||||
|
addDrop(dropped, normalized, layer.source, 'extended-tag-blocked') |
||||||
|
continue |
||||||
|
} |
||||||
|
if (!context.allowThirdPartyLocalRelays && isLocalNetworkUrl(normalized) && !layer.explicit) { |
||||||
|
addDrop(dropped, normalized, layer.source, 'third-party-local') |
||||||
|
continue |
||||||
|
} |
||||||
|
if (urls.length >= max) { |
||||||
|
addDrop(dropped, normalized, layer.source, 'over-cap') |
||||||
|
continue |
||||||
|
} |
||||||
|
seen.add(key) |
||||||
|
urls.push(normalized) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return { urls, dropped } |
||||||
|
} |
||||||
|
|
||||||
|
export function feedRelayPolicyUrls( |
||||||
|
layers: readonly FeedRelayLayer[], |
||||||
|
context: FeedRelayPolicyContext |
||||||
|
): string[] { |
||||||
|
return applyFeedRelayPolicy(layers, context).urls |
||||||
|
} |
||||||
@ -0,0 +1,99 @@ |
|||||||
|
import { describe, expect, it } from 'vitest' |
||||||
|
import { FeedRuntime, feedRuntimeReducer, createInitialFeedRuntimeState } from './runtime' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
|
||||||
|
function evt(id: string, created_at: number, kind = 1): Event { |
||||||
|
return { |
||||||
|
id, |
||||||
|
pubkey: `pubkey-${id}`, |
||||||
|
created_at, |
||||||
|
kind, |
||||||
|
tags: [], |
||||||
|
content: '', |
||||||
|
sig: `sig-${id}` |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
describe('feedRuntimeReducer', () => { |
||||||
|
it('marks cached rows stale during refresh until relay rows arrive', () => { |
||||||
|
let state = createInitialFeedRuntimeState('feed-a') |
||||||
|
state = feedRuntimeReducer(state, { type: 'cache', events: [evt('a', 10)], stale: true }) |
||||||
|
expect(state.stale).toBe(true) |
||||||
|
expect(state.emptyReason).toBe('stale-cache-only') |
||||||
|
|
||||||
|
state = feedRuntimeReducer(state, { |
||||||
|
type: 'relayBatch', |
||||||
|
events: [evt('b', 11)], |
||||||
|
relayOutcomes: [{ relayUrl: 'wss://relay.example/', status: 'event', eventCount: 1 }] |
||||||
|
}) |
||||||
|
state = feedRuntimeReducer(state, { type: 'relayDone' }) |
||||||
|
|
||||||
|
expect(state.stale).toBe(false) |
||||||
|
expect(state.status).toBe('ready') |
||||||
|
expect(state.rows.map((row) => row.id)).toEqual(['b', 'a']) |
||||||
|
}) |
||||||
|
|
||||||
|
it('reports visible-vs-raw empty states', () => { |
||||||
|
const state = feedRuntimeReducer( |
||||||
|
createInitialFeedRuntimeState('feed-a'), |
||||||
|
{ type: 'relayBatch', events: [evt('zap', 10, 9735)] }, |
||||||
|
{ isVisibleEvent: (event) => event.kind === 1 } |
||||||
|
) |
||||||
|
|
||||||
|
expect(state.rawCount).toBe(1) |
||||||
|
expect(state.visibleCount).toBe(0) |
||||||
|
expect(state.hiddenCount).toBe(1) |
||||||
|
expect(state.emptyReason).toBe('no-visible-events') |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
describe('FeedRuntime', () => { |
||||||
|
it('keeps old rows stale during manual refresh and replaces them with fresh relay rows', async () => { |
||||||
|
const runtime = new FeedRuntime({ descriptorKey: 'feed-a' }) |
||||||
|
await runtime.load(async () => ({ |
||||||
|
relayEvents: [evt('old', 10)], |
||||||
|
relayOutcomes: [{ relayUrl: 'wss://relay.example/', status: 'event', eventCount: 1 }] |
||||||
|
})) |
||||||
|
|
||||||
|
const refreshed = await runtime.load( |
||||||
|
async () => ({ |
||||||
|
cacheEvents: [evt('old', 10)], |
||||||
|
cacheStale: true, |
||||||
|
relayEvents: [evt('new', 20)], |
||||||
|
relayOutcomes: [{ relayUrl: 'wss://relay.example/', status: 'event', eventCount: 1 }] |
||||||
|
}), |
||||||
|
true |
||||||
|
) |
||||||
|
|
||||||
|
expect(refreshed.stale).toBe(false) |
||||||
|
expect(refreshed.rows.map((row) => row.id)).toEqual(['new', 'old']) |
||||||
|
expect(refreshed.generation).toBe(2) |
||||||
|
}) |
||||||
|
|
||||||
|
it('loads older pages with the cursor from the previous batch', async () => { |
||||||
|
const runtime = new FeedRuntime({ descriptorKey: 'feed-a' }) |
||||||
|
const first = await runtime.load(async ({ page }) => { |
||||||
|
expect(page).toBe('initial') |
||||||
|
return { |
||||||
|
relayEvents: [evt('new', 20), evt('middle', 10)], |
||||||
|
hasMore: true |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
expect(first.hasMore).toBe(true) |
||||||
|
expect(first.nextCursor).toBe(9) |
||||||
|
|
||||||
|
const next = await runtime.loadMore(async ({ page, cursor }) => { |
||||||
|
expect(page).toBe('load-more') |
||||||
|
expect(cursor).toBe(9) |
||||||
|
return { |
||||||
|
relayEvents: [evt('old', 5)], |
||||||
|
hasMore: false |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
expect(next.rows.map((row) => row.id)).toEqual(['new', 'middle', 'old']) |
||||||
|
expect(next.paginationStatus).toBe('exhausted') |
||||||
|
expect(next.hasMore).toBe(false) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,407 @@ |
|||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
|
||||||
|
export type FeedRelayOutcomeStatus = |
||||||
|
| 'event' |
||||||
|
| 'eose-empty' |
||||||
|
| 'closed' |
||||||
|
| 'auth-required' |
||||||
|
| 'timeout' |
||||||
|
| 'transport-error' |
||||||
|
|
||||||
|
export type FeedRelayOutcome = { |
||||||
|
relayUrl: string |
||||||
|
status: FeedRelayOutcomeStatus |
||||||
|
message?: string |
||||||
|
eventCount?: number |
||||||
|
} |
||||||
|
|
||||||
|
export type FeedRuntimeStatus = 'idle' | 'loading' | 'refreshing' | 'ready' | 'empty' | 'error' |
||||||
|
export type FeedRuntimePaginationStatus = 'idle' | 'loading' | 'exhausted' | 'error' |
||||||
|
export type FeedRuntimeLoadPage = 'initial' | 'refresh' | 'load-more' |
||||||
|
|
||||||
|
export type FeedRuntimeEmptyReason = |
||||||
|
| 'not-empty' |
||||||
|
| 'no-relay-success' |
||||||
|
| 'no-raw-events' |
||||||
|
| 'no-visible-events' |
||||||
|
| 'blocked-relays-only' |
||||||
|
| 'stale-cache-only' |
||||||
|
| 'error' |
||||||
|
|
||||||
|
export type FeedRuntimeSnapshot = { |
||||||
|
generation: number |
||||||
|
status: FeedRuntimeStatus |
||||||
|
rows: Event[] |
||||||
|
stale: boolean |
||||||
|
rawCount: number |
||||||
|
visibleCount: number |
||||||
|
hiddenCount: number |
||||||
|
relayOutcomes: FeedRelayOutcome[] |
||||||
|
emptyReason: FeedRuntimeEmptyReason |
||||||
|
error?: string |
||||||
|
hasMore: boolean |
||||||
|
paginationStatus: FeedRuntimePaginationStatus |
||||||
|
nextCursor?: number |
||||||
|
pageError?: string |
||||||
|
} |
||||||
|
|
||||||
|
export type FeedRuntimeState = FeedRuntimeSnapshot & { |
||||||
|
descriptorKey: string |
||||||
|
rawRows: Event[] |
||||||
|
} |
||||||
|
|
||||||
|
export type FeedRuntimeAction = |
||||||
|
| { type: 'start'; descriptorKey: string; generation: number; refresh: boolean; keepRowsStale?: boolean } |
||||||
|
| { type: 'cache'; events: Event[]; stale: boolean } |
||||||
|
| { type: 'relayBatch'; events: Event[]; relayOutcomes?: FeedRelayOutcome[]; fresh?: boolean } |
||||||
|
| { type: 'relayDone'; relayOutcomes?: FeedRelayOutcome[]; hasMore?: boolean; nextCursor?: number } |
||||||
|
| { type: 'pageStart' } |
||||||
|
| { |
||||||
|
type: 'pageBatch' |
||||||
|
events: Event[] |
||||||
|
relayOutcomes?: FeedRelayOutcome[] |
||||||
|
hasMore?: boolean |
||||||
|
nextCursor?: number |
||||||
|
} |
||||||
|
| { type: 'pageError'; error: string; relayOutcomes?: FeedRelayOutcome[] } |
||||||
|
| { type: 'error'; error: string; relayOutcomes?: FeedRelayOutcome[] } |
||||||
|
| { type: 'reset'; descriptorKey: string } |
||||||
|
|
||||||
|
export type FeedRuntimeOptions = { |
||||||
|
descriptorKey: string |
||||||
|
isVisibleEvent?: (event: Event) => boolean |
||||||
|
sortEvents?: (a: Event, b: Event) => number |
||||||
|
cap?: number |
||||||
|
} |
||||||
|
|
||||||
|
export type FeedRuntimeLoadResult = { |
||||||
|
cacheEvents?: Event[] |
||||||
|
cacheStale?: boolean |
||||||
|
relayEvents?: Event[] |
||||||
|
relayOutcomes?: FeedRelayOutcome[] |
||||||
|
hasMore?: boolean |
||||||
|
nextCursor?: number |
||||||
|
} |
||||||
|
|
||||||
|
export type FeedRuntimeLoader = (args: { |
||||||
|
descriptorKey: string |
||||||
|
generation: number |
||||||
|
refresh: boolean |
||||||
|
page: FeedRuntimeLoadPage |
||||||
|
cursor?: number |
||||||
|
signal: AbortSignal |
||||||
|
}) => Promise<FeedRuntimeLoadResult> |
||||||
|
|
||||||
|
function defaultSort(a: Event, b: Event) { |
||||||
|
return b.created_at - a.created_at || b.id.localeCompare(a.id) |
||||||
|
} |
||||||
|
|
||||||
|
function mergeById(existing: readonly Event[], incoming: readonly Event[], sortEvents: (a: Event, b: Event) => number): Event[] { |
||||||
|
const byId = new Map<string, Event>() |
||||||
|
for (const evt of existing) byId.set(evt.id, evt) |
||||||
|
for (const evt of incoming) { |
||||||
|
const prev = byId.get(evt.id) |
||||||
|
if (!prev || evt.created_at >= prev.created_at) byId.set(evt.id, evt) |
||||||
|
} |
||||||
|
return [...byId.values()].sort(sortEvents) |
||||||
|
} |
||||||
|
|
||||||
|
function cursorFromEvents(events: readonly Event[]): number | undefined { |
||||||
|
if (!events.length) return undefined |
||||||
|
return Math.min(...events.map((event) => event.created_at)) - 1 |
||||||
|
} |
||||||
|
|
||||||
|
function classifyEmpty(state: FeedRuntimeState): FeedRuntimeEmptyReason { |
||||||
|
if (state.stale && state.rawCount > 0) return 'stale-cache-only' |
||||||
|
if (state.visibleCount > 0) return 'not-empty' |
||||||
|
if (state.error) return 'error' |
||||||
|
if (state.rawCount === 0 && state.relayOutcomes.length === 0) return 'no-relay-success' |
||||||
|
if (state.rawCount === 0) return 'no-raw-events' |
||||||
|
return 'no-visible-events' |
||||||
|
} |
||||||
|
|
||||||
|
function derive( |
||||||
|
state: FeedRuntimeState, |
||||||
|
opts: Pick<FeedRuntimeOptions, 'isVisibleEvent' | 'sortEvents' | 'cap'> |
||||||
|
): FeedRuntimeState { |
||||||
|
const isVisible = opts.isVisibleEvent ?? (() => true) |
||||||
|
const sortEvents = opts.sortEvents ?? defaultSort |
||||||
|
const sorted = [...state.rawRows].sort(sortEvents) |
||||||
|
const rawRows = typeof opts.cap === 'number' ? sorted.slice(0, opts.cap) : sorted |
||||||
|
const visibleRows = rawRows.filter(isVisible) |
||||||
|
const next: FeedRuntimeState = { |
||||||
|
...state, |
||||||
|
rows: visibleRows, |
||||||
|
rawRows, |
||||||
|
rawCount: rawRows.length, |
||||||
|
visibleCount: visibleRows.length, |
||||||
|
hiddenCount: Math.max(0, rawRows.length - visibleRows.length) |
||||||
|
} |
||||||
|
const emptyReason = classifyEmpty(next) |
||||||
|
return { |
||||||
|
...next, |
||||||
|
emptyReason, |
||||||
|
status: |
||||||
|
next.status === 'loading' || next.status === 'refreshing' |
||||||
|
? next.status |
||||||
|
: next.visibleCount > 0 |
||||||
|
? 'ready' |
||||||
|
: emptyReason === 'not-empty' |
||||||
|
? 'ready' |
||||||
|
: next.error |
||||||
|
? 'error' |
||||||
|
: 'empty' |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function createInitialFeedRuntimeState(descriptorKey: string): FeedRuntimeState { |
||||||
|
return { |
||||||
|
descriptorKey, |
||||||
|
generation: 0, |
||||||
|
status: 'idle', |
||||||
|
rows: [], |
||||||
|
rawRows: [], |
||||||
|
stale: false, |
||||||
|
rawCount: 0, |
||||||
|
visibleCount: 0, |
||||||
|
hiddenCount: 0, |
||||||
|
relayOutcomes: [], |
||||||
|
emptyReason: 'no-raw-events', |
||||||
|
hasMore: false, |
||||||
|
paginationStatus: 'idle' |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function feedRuntimeReducer( |
||||||
|
state: FeedRuntimeState, |
||||||
|
action: FeedRuntimeAction, |
||||||
|
options: Pick<FeedRuntimeOptions, 'isVisibleEvent' | 'sortEvents' | 'cap'> = {} |
||||||
|
): FeedRuntimeState { |
||||||
|
const sortEvents = options.sortEvents ?? defaultSort |
||||||
|
switch (action.type) { |
||||||
|
case 'reset': |
||||||
|
return createInitialFeedRuntimeState(action.descriptorKey) |
||||||
|
case 'start': { |
||||||
|
const keepRows = action.keepRowsStale ? state.rawRows : [] |
||||||
|
return derive( |
||||||
|
{ |
||||||
|
...state, |
||||||
|
descriptorKey: action.descriptorKey, |
||||||
|
generation: action.generation, |
||||||
|
status: action.refresh ? 'refreshing' : 'loading', |
||||||
|
rawRows: keepRows, |
||||||
|
stale: action.keepRowsStale ? true : false, |
||||||
|
relayOutcomes: [], |
||||||
|
error: undefined, |
||||||
|
hasMore: false, |
||||||
|
paginationStatus: 'idle', |
||||||
|
nextCursor: undefined, |
||||||
|
pageError: undefined |
||||||
|
}, |
||||||
|
options |
||||||
|
) |
||||||
|
} |
||||||
|
case 'cache': |
||||||
|
return derive( |
||||||
|
{ |
||||||
|
...state, |
||||||
|
rawRows: mergeById(state.rawRows, action.events, sortEvents), |
||||||
|
stale: action.stale |
||||||
|
}, |
||||||
|
options |
||||||
|
) |
||||||
|
case 'relayBatch': |
||||||
|
return derive( |
||||||
|
{ |
||||||
|
...state, |
||||||
|
rawRows: mergeById(state.rawRows, action.events, sortEvents), |
||||||
|
stale: action.fresh === false ? state.stale : false, |
||||||
|
relayOutcomes: action.relayOutcomes ?? state.relayOutcomes, |
||||||
|
error: undefined |
||||||
|
}, |
||||||
|
options |
||||||
|
) |
||||||
|
case 'relayDone': |
||||||
|
return derive( |
||||||
|
{ |
||||||
|
...state, |
||||||
|
status: state.visibleCount > 0 ? 'ready' : 'empty', |
||||||
|
relayOutcomes: action.relayOutcomes ?? state.relayOutcomes, |
||||||
|
hasMore: action.hasMore ?? state.hasMore, |
||||||
|
paginationStatus: |
||||||
|
action.hasMore === false ? 'exhausted' : action.hasMore === true ? 'idle' : state.paginationStatus, |
||||||
|
nextCursor: action.nextCursor ?? state.nextCursor |
||||||
|
}, |
||||||
|
options |
||||||
|
) |
||||||
|
case 'pageStart': |
||||||
|
return { |
||||||
|
...state, |
||||||
|
paginationStatus: 'loading', |
||||||
|
pageError: undefined |
||||||
|
} |
||||||
|
case 'pageBatch': |
||||||
|
return derive( |
||||||
|
{ |
||||||
|
...state, |
||||||
|
rawRows: mergeById(state.rawRows, action.events, sortEvents), |
||||||
|
stale: false, |
||||||
|
relayOutcomes: action.relayOutcomes ?? state.relayOutcomes, |
||||||
|
hasMore: action.hasMore ?? state.hasMore, |
||||||
|
paginationStatus: action.hasMore === false ? 'exhausted' : 'idle', |
||||||
|
nextCursor: action.nextCursor ?? cursorFromEvents(action.events) ?? state.nextCursor, |
||||||
|
pageError: undefined |
||||||
|
}, |
||||||
|
options |
||||||
|
) |
||||||
|
case 'pageError': |
||||||
|
return derive( |
||||||
|
{ |
||||||
|
...state, |
||||||
|
paginationStatus: 'error', |
||||||
|
pageError: action.error, |
||||||
|
relayOutcomes: action.relayOutcomes ?? state.relayOutcomes |
||||||
|
}, |
||||||
|
options |
||||||
|
) |
||||||
|
case 'error': |
||||||
|
return derive( |
||||||
|
{ |
||||||
|
...state, |
||||||
|
status: 'error', |
||||||
|
error: action.error, |
||||||
|
relayOutcomes: action.relayOutcomes ?? state.relayOutcomes |
||||||
|
}, |
||||||
|
options |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export class FeedRuntime { |
||||||
|
private state: FeedRuntimeState |
||||||
|
private generation = 0 |
||||||
|
private abortController: AbortController | null = null |
||||||
|
private pageAbortController: AbortController | null = null |
||||||
|
|
||||||
|
constructor(private readonly options: FeedRuntimeOptions) { |
||||||
|
this.state = createInitialFeedRuntimeState(options.descriptorKey) |
||||||
|
} |
||||||
|
|
||||||
|
snapshot(): FeedRuntimeSnapshot { |
||||||
|
const { descriptorKey: _descriptorKey, rawRows: _rawRows, ...snapshot } = this.state |
||||||
|
return snapshot |
||||||
|
} |
||||||
|
|
||||||
|
async load(loader: FeedRuntimeLoader, refresh = false): Promise<FeedRuntimeSnapshot> { |
||||||
|
this.abortController?.abort() |
||||||
|
const generation = ++this.generation |
||||||
|
const abortController = new AbortController() |
||||||
|
this.abortController = abortController |
||||||
|
this.state = feedRuntimeReducer( |
||||||
|
this.state, |
||||||
|
{ |
||||||
|
type: 'start', |
||||||
|
descriptorKey: this.options.descriptorKey, |
||||||
|
generation, |
||||||
|
refresh, |
||||||
|
keepRowsStale: refresh |
||||||
|
}, |
||||||
|
this.options |
||||||
|
) |
||||||
|
try { |
||||||
|
const result = await loader({ |
||||||
|
descriptorKey: this.options.descriptorKey, |
||||||
|
generation, |
||||||
|
refresh, |
||||||
|
page: refresh ? 'refresh' : 'initial', |
||||||
|
signal: abortController.signal |
||||||
|
}) |
||||||
|
if (abortController.signal.aborted || generation !== this.generation) return this.snapshot() |
||||||
|
if (result.cacheEvents?.length) { |
||||||
|
this.state = feedRuntimeReducer( |
||||||
|
this.state, |
||||||
|
{ type: 'cache', events: result.cacheEvents, stale: result.cacheStale ?? true }, |
||||||
|
this.options |
||||||
|
) |
||||||
|
} |
||||||
|
if (result.relayEvents) { |
||||||
|
this.state = feedRuntimeReducer( |
||||||
|
this.state, |
||||||
|
{ |
||||||
|
type: 'relayBatch', |
||||||
|
events: result.relayEvents, |
||||||
|
relayOutcomes: result.relayOutcomes, |
||||||
|
fresh: true |
||||||
|
}, |
||||||
|
this.options |
||||||
|
) |
||||||
|
} |
||||||
|
this.state = feedRuntimeReducer( |
||||||
|
this.state, |
||||||
|
{ |
||||||
|
type: 'relayDone', |
||||||
|
relayOutcomes: result.relayOutcomes, |
||||||
|
hasMore: result.hasMore, |
||||||
|
nextCursor: result.nextCursor ?? cursorFromEvents(result.relayEvents ?? []) |
||||||
|
}, |
||||||
|
this.options |
||||||
|
) |
||||||
|
} catch (e) { |
||||||
|
if (!abortController.signal.aborted) { |
||||||
|
this.state = feedRuntimeReducer( |
||||||
|
this.state, |
||||||
|
{ type: 'error', error: e instanceof Error ? e.message : String(e) }, |
||||||
|
this.options |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
return this.snapshot() |
||||||
|
} |
||||||
|
|
||||||
|
async loadMore(loader: FeedRuntimeLoader): Promise<FeedRuntimeSnapshot> { |
||||||
|
if (!this.state.hasMore || this.state.paginationStatus === 'loading') return this.snapshot() |
||||||
|
this.pageAbortController?.abort() |
||||||
|
const generation = this.generation |
||||||
|
const pageAbortController = new AbortController() |
||||||
|
this.pageAbortController = pageAbortController |
||||||
|
this.state = feedRuntimeReducer(this.state, { type: 'pageStart' }, this.options) |
||||||
|
try { |
||||||
|
const result = await loader({ |
||||||
|
descriptorKey: this.options.descriptorKey, |
||||||
|
generation, |
||||||
|
refresh: false, |
||||||
|
page: 'load-more', |
||||||
|
cursor: this.state.nextCursor, |
||||||
|
signal: pageAbortController.signal |
||||||
|
}) |
||||||
|
if (pageAbortController.signal.aborted || generation !== this.generation) return this.snapshot() |
||||||
|
this.state = feedRuntimeReducer( |
||||||
|
this.state, |
||||||
|
{ |
||||||
|
type: 'pageBatch', |
||||||
|
events: result.relayEvents ?? [], |
||||||
|
relayOutcomes: result.relayOutcomes, |
||||||
|
hasMore: result.hasMore, |
||||||
|
nextCursor: result.nextCursor |
||||||
|
}, |
||||||
|
this.options |
||||||
|
) |
||||||
|
} catch (e) { |
||||||
|
if (!pageAbortController.signal.aborted) { |
||||||
|
this.state = feedRuntimeReducer( |
||||||
|
this.state, |
||||||
|
{ type: 'pageError', error: e instanceof Error ? e.message : String(e) }, |
||||||
|
this.options |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
return this.snapshot() |
||||||
|
} |
||||||
|
|
||||||
|
abort() { |
||||||
|
this.abortController?.abort() |
||||||
|
this.pageAbortController?.abort() |
||||||
|
this.abortController = null |
||||||
|
this.pageAbortController = null |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue