29 changed files with 1982 additions and 274 deletions
@ -0,0 +1,47 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -0,0 +1,5 @@
|
||||
export * from './adapters' |
||||
export * from './descriptor' |
||||
export * from './diagnostics' |
||||
export * from './relay-policy' |
||||
export * from './runtime' |
||||
@ -0,0 +1,61 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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