diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index dba60025..f9625bb1 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -19,7 +19,7 @@ import indexedDb from '@/services/indexed-db.service' import nip66Service from '@/services/nip66.service' import { navigationEventStore } from '@/services/navigation-event-store' import { useViewerInboxRelayUrlsAndAggrEligibility } from '@/hooks/useViewerInboxRelayUrlsAndAggr' -import { ensureNostrLandAggrRelay } from '@/lib/nostr-land-aggr' +import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { useFavoriteRelays } from '@/providers/favorite-relays-context' import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useReply } from '@/providers/ReplyProvider' @@ -333,7 +333,14 @@ function EmbeddedNoteFetched({ if (cancelled || eventRef.current) return const wide0 = embedFetchCtxRef.current.wideRelaysStatic const wideMerged = preferPublicIndexRelaysFirst(dedupeRelayUrls([...wide0, ...extra])) - const ev = await runWidePass(ensureNostrLandAggrRelay(wideMerged, { blockedRelays })) + const ev = await runWidePass( + feedRelayPolicyUrls([{ source: 'fallback', urls: wideMerged }], { + operation: 'read', + blockedRelays, + applySocialKindBlockedFilter: false, + allowThirdPartyLocalRelays: true + }) + ) if (cancelled || !ev) return resolve(ev) })() @@ -519,19 +526,29 @@ function buildEmbedWideRelayUrlsStatic( relayHintsFromParent: string[], viewerInboxRelayUrls: string[] ): string[] { - return ensureNostrLandAggrRelay( - preferPublicIndexRelaysFirst( - dedupeRelayUrls([ - ...relayHintsFromParent, - ...viewerInboxRelayUrls, - ...nip66Service.getSearchableRelayUrls(), - ...SEARCHABLE_RELAY_URLS, - ...FAST_READ_RELAY_URLS, - ...FAST_WRITE_RELAY_URLS, - ...PROFILE_RELAY_URLS, - ...menuRelayUrls - ]) - ) + return feedRelayPolicyUrls( + [ + { + source: 'fallback', + urls: preferPublicIndexRelaysFirst( + dedupeRelayUrls([ + ...relayHintsFromParent, + ...viewerInboxRelayUrls, + ...nip66Service.getSearchableRelayUrls(), + ...SEARCHABLE_RELAY_URLS, + ...FAST_READ_RELAY_URLS, + ...FAST_WRITE_RELAY_URLS, + ...PROFILE_RELAY_URLS, + ...menuRelayUrls + ]) + ) + } + ], + { + operation: 'read', + applySocialKindBlockedFilter: false, + allowThirdPartyLocalRelays: true + } ) } diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 681b227d..f133fc3e 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -20,11 +20,10 @@ import { import { shouldFilterEvent } from '@/lib/event-filtering' import { isRelayUrlStrictSupersetIdentityKey, - isSpellSubRequestsSameFiltersDifferentRelays, - stableSpellFeedFilterKey + isSpellSubRequestsSameFiltersDifferentRelays } from '@/lib/spell-feed-request-identity' import logger from '@/lib/logger' -import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' +import { isLocalNetworkUrl, normalizeUrl } from '@/lib/url' import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' import { shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata' import { isTouchDevice } from '@/lib/utils' @@ -91,6 +90,12 @@ import { } from '@/components/ui/select' import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' import MediaGridItem from '../MediaGridItem' +import { + buildFeedSessionSnapshotKey, + legacyFeedSubscriptionKey, + stableFeedKindKey +} from '@/features/feed/descriptor' +import { mapNoteListSubRequestsForTimeline } from '@/features/feed/note-list-requests' const LIMIT = 150 // Per-shard REQ limit for timeline + loadMore (larger batches = fewer round-trips) const ALGO_LIMIT = 200 // Increased from 500 for algorithm feeds @@ -934,23 +939,11 @@ const NoteList = forwardRef( ) // Memoize subRequests serialization to avoid expensive JSON.stringify on every render - const subRequestsKey = useMemo(() => { - return JSON.stringify( - subRequests.map((req) => ({ - urls: [...req.urls].map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean).sort(), - filter: stableSpellFeedFilterKey(req.filter) - })) - ) - }, [subRequests]) + const subRequestsKey = useMemo(() => legacyFeedSubscriptionKey(subRequests), [subRequests]) const followingFeedDeltaSubRequestsKey = useMemo( () => - JSON.stringify( - (followingFeedDeltaSubRequests ?? []).map((req) => ({ - urls: [...req.urls].map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean).sort(), - filter: stableSpellFeedFilterKey(req.filter) - })) - ), + legacyFeedSubscriptionKey(followingFeedDeltaSubRequests ?? []), [followingFeedDeltaSubRequests] ) @@ -962,51 +955,16 @@ const NoteList = forwardRef( const mapLiveSubRequestsForTimeline = useCallback( (requests: TFeedSubRequest[]) => { const defaultKinds = effectiveShowKinds.length > 0 ? effectiveShowKinds : [kinds.ShortTextNote] - const seeAllNoSpell = seeAllFeedEvents && !useFilterAsIs - return requests.map(({ urls, filter }) => { - const baseLimit = filter.limit ?? (areAlgoRelays ? ALGO_LIMIT : LIMIT) - if (useFilterAsIs) { - const hasKindsInRequest = Array.isArray(filter.kinds) && filter.kinds.length > 0 - if (allowKindlessRelayExplore && urls.length === 1 && !hasKindsInRequest) { - const finalFilter: Filter = { - ...filter, - limit: filter.limit ?? RELAY_EXPLORE_LIMIT - } - delete finalFilter.kinds - return { urls, filter: finalFilter } - } - const finalFilter: Filter = { ...filter, limit: baseLimit } - if (clientSideKindFilter) { - if (hasKindsInRequest) { - finalFilter.kinds = filter.kinds - } else { - delete finalFilter.kinds - } - } else if (hasKindsInRequest) { - finalFilter.kinds = filter.kinds - } else { - finalFilter.kinds = defaultKinds - } - return { urls, filter: finalFilter } - } - if (seeAllNoSpell) { - const { kinds: _omitKinds, ...rest } = filter - return { - urls, - filter: { - ...rest, - limit: areAlgoRelays ? ALGO_LIMIT : LIMIT - } - } - } - return { - urls, - filter: { - ...filter, - kinds: defaultKinds, - limit: areAlgoRelays ? ALGO_LIMIT : LIMIT - } - } + return mapNoteListSubRequestsForTimeline(requests, { + defaultKinds, + seeAllFeedEvents, + useFilterAsIs, + areAlgoRelays, + allowKindlessRelayExplore, + clientSideKindFilter, + limit: LIMIT, + algoLimit: ALGO_LIMIT, + relayExploreLimit: RELAY_EXPLORE_LIMIT }) }, [ @@ -1118,12 +1076,7 @@ const NoteList = forwardRef( const subRequestsRef = useRef(subRequests) subRequestsRef.current = subRequests - // Stable key for kind filter so subscription effect doesn't re-run on parent re-renders with same kinds - // Use sorted array and JSON.stringify to create a stable key that only changes when content changes - const showKindsKey = useMemo(() => { - if (!effectiveShowKinds || effectiveShowKinds.length === 0) return '' - return JSON.stringify([...effectiveShowKinds].sort((a, b) => a - b)) - }, [effectiveShowKinds]) + const showKindsKey = useMemo(() => stableFeedKindKey(effectiveShowKinds), [effectiveShowKinds]) /** * Session snapshot identity: feed + kind UI toggles that affect **REQ** / merged rows. @@ -1132,18 +1085,16 @@ const NoteList = forwardRef( */ const sessionSnapshotIdentityKey = useMemo( () => - JSON.stringify({ - feed: timelineSubscriptionKey, - ...(homeFeedListMode ? { homeSurface: homeFeedListMode } : {}), - ...(allowKindlessRelayExplore - ? { relayKindless: true, showAllKinds } - : { - kinds: showKindsKey, - op: showKind1OPs, - rep: showKind1Replies, - c1111: showKind1111, - seeAll: seeAllFeedEvents - }) + buildFeedSessionSnapshotKey({ + feedKey: timelineSubscriptionKey, + homeSurface: homeFeedListMode, + allowKindlessRelayExplore, + showAllKinds, + kindsKey: showKindsKey, + showKind1OPs, + showKind1Replies, + showKind1111, + seeAllFeedEvents }), [ timelineSubscriptionKey, diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 5edce3d0..ac774ac2 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -48,7 +48,7 @@ import noteStatsService from '@/services/note-stats.service' import discussionFeedCache from '@/services/discussion-feed-cache.service' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder' -import { ensureNostrLandAggrRelay } from '@/lib/nostr-land-aggr' +import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { eventReferencesThreadTarget } from '@/lib/op-reference-tags' import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match' import { @@ -1224,8 +1224,11 @@ function ReplyNoteList({ filters.push(...buildRssArticleUrlThreadInteractionFilters(rootInfo.id, LIMIT)) } - const relayUrlsForThreadReq = ensureNostrLandAggrRelay(finalRelayUrls, { - blockedRelays: replyBlockedRelays + const relayUrlsForThreadReq = feedRelayPolicyUrls([{ source: 'fallback', urls: finalRelayUrls }], { + operation: 'read', + blockedRelays: replyBlockedRelays, + applySocialKindBlockedFilter: false, + allowThirdPartyLocalRelays: true }) // For URL threads: stream events as they arrive from each relay so replies appear diff --git a/src/constants.ts b/src/constants.ts index 575b9bb9..283c8a59 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -114,9 +114,8 @@ export const TIMELINE_SHARD_SUBSCRIBE_CONCURRENCY = 5 export const MAX_PUBLISH_RELAYS = 20 /** - * Kind 24 / 31925: {@link mergeRelayPriorityLayers} used the full {@link MAX_PUBLISH_RELAYS} budget on the author’s - * outbox list first, so recipient **read** inboxes were often never reached. This higher cap plus an author slice - * (see client.service) reserves space for organizer/recipient relays. + * Kind 24 / 31925: reserve space for organizer/recipient inboxes instead of letting the author's outboxes consume + * the entire {@link MAX_PUBLISH_RELAYS} budget first. */ export const PUBLIC_MESSAGE_RSVP_PUBLISH_MAX_RELAYS = 28 diff --git a/src/features/feed/adapters.test.ts b/src/features/feed/adapters.test.ts new file mode 100644 index 00000000..6014a0d9 --- /dev/null +++ b/src/features/feed/adapters.test.ts @@ -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) + }) +}) diff --git a/src/features/feed/adapters.ts b/src/features/feed/adapters.ts new file mode 100644 index 00000000..8bb05078 --- /dev/null +++ b/src/features/feed/adapters.ts @@ -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) +} diff --git a/src/features/feed/client-loader.test.ts b/src/features/feed/client-loader.test.ts new file mode 100644 index 00000000..ee91d54e --- /dev/null +++ b/src/features/feed/client-loader.test.ts @@ -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) + }) +}) diff --git a/src/features/feed/client-loader.ts b/src/features/feed/client-loader.ts new file mode 100644 index 00000000..1832f955 --- /dev/null +++ b/src/features/feed/client-loader.ts @@ -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 + getTimelineDiskSnapshotEvents?: (subRequests: { urls: string[]; filter: TSubRequestFilter }[]) => Promise +} + +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 => { + 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() + 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 + } + } +} diff --git a/src/features/feed/descriptor.test.ts b/src/features/feed/descriptor.test.ts new file mode 100644 index 00000000..53fb430a --- /dev/null +++ b/src/features/feed/descriptor.test.ts @@ -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 + }) + ) + }) +}) diff --git a/src/features/feed/descriptor.ts b/src/features/feed/descriptor.ts new file mode 100644 index 00000000..ab2adac6 --- /dev/null +++ b/src/features/feed/descriptor.ts @@ -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 +} + +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 = {} + 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 | 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 & { 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 + }) + }) +} diff --git a/src/features/feed/diagnostics.test.ts b/src/features/feed/diagnostics.test.ts new file mode 100644 index 00000000..650c866e --- /dev/null +++ b/src/features/feed/diagnostics.test.ts @@ -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) + }) +}) diff --git a/src/features/feed/diagnostics.ts b/src/features/feed/diagnostics.ts new file mode 100644 index 00000000..5e13599d --- /dev/null +++ b/src/features/feed/diagnostics.ts @@ -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) +} diff --git a/src/features/feed/index.ts b/src/features/feed/index.ts new file mode 100644 index 00000000..2863cac9 --- /dev/null +++ b/src/features/feed/index.ts @@ -0,0 +1,5 @@ +export * from './adapters' +export * from './descriptor' +export * from './diagnostics' +export * from './relay-policy' +export * from './runtime' diff --git a/src/features/feed/note-list-requests.test.ts b/src/features/feed/note-list-requests.test.ts new file mode 100644 index 00000000..37184246 --- /dev/null +++ b/src/features/feed/note-list-requests.test.ts @@ -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 }) + }) +}) diff --git a/src/features/feed/note-list-requests.ts b/src/features/feed/note-list-requests.ts new file mode 100644 index 00000000..7078f89a --- /dev/null +++ b/src/features/feed/note-list-requests.ts @@ -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 + } + } + }) +} diff --git a/src/features/feed/relay-policy.test.ts b/src/features/feed/relay-policy.test.ts new file mode 100644 index 00000000..5f1fa8a6 --- /dev/null +++ b/src/features/feed/relay-policy.test.ts @@ -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' + }) + ) + }) +}) diff --git a/src/features/feed/relay-policy.ts b/src/features/feed/relay-policy.ts new file mode 100644 index 00000000..fd138663 --- /dev/null +++ b/src/features/feed/relay-policy.ts @@ -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 { + 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() + 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 +} diff --git a/src/features/feed/runtime.test.ts b/src/features/feed/runtime.test.ts new file mode 100644 index 00000000..59065e8a --- /dev/null +++ b/src/features/feed/runtime.test.ts @@ -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) + }) +}) diff --git a/src/features/feed/runtime.ts b/src/features/feed/runtime.ts new file mode 100644 index 00000000..3da29ccf --- /dev/null +++ b/src/features/feed/runtime.ts @@ -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 + +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() + 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 +): 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 = {} +): 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 { + 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 { + 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 + } +} diff --git a/src/lib/favorites-feed-relays.ts b/src/lib/favorites-feed-relays.ts index 15310bbd..15a94a67 100644 --- a/src/lib/favorites-feed-relays.ts +++ b/src/lib/favorites-feed-relays.ts @@ -14,10 +14,10 @@ import { buildReadRelayPriorityLayers, dedupeNormalizeRelayUrlsOrdered, MAX_REQ_RELAY_URLS, - mergeRelayPriorityLayers, relayUrlsLocalsFirst } from '@/lib/relay-url-priority' -import { ensureNostrLandAggrRelay, stripNostrLandAggrRelay } from '@/lib/nostr-land-aggr' +import { feedRelayPolicyUrls, type FeedRelayLayer } from '@/features/feed/relay-policy' +import { stripNostrLandAggrRelay } from '@/lib/nostr-land-aggr' import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize' const blockedSet = (blockedRelays: string[]) => @@ -50,15 +50,16 @@ export function getFavoritesFeedRelayUrls( return k && !blocked.has(k) }) const base = visible.length > 0 ? visible : DEFAULT_FAVORITE_RELAYS - const seen = new Set() - const out: string[] = [] - for (const u of base) { - const k = normalizeAnyRelayUrl(u) || u - if (!k || seen.has(k)) continue - seen.add(k) - out.push(k) - } - return stripNostrLandAggrRelay(out) + return feedRelayPolicyUrls( + [{ source: 'favorites', urls: stripNostrLandAggrRelay(base) }], + { + operation: 'favorites-feed', + blockedRelays, + nostrLandAggr: 'never', + applySocialKindBlockedFilter: false, + allowThirdPartyLocalRelays: true + } + ) } /** @@ -271,15 +272,20 @@ export function augmentSubRequestsWithFavoritesFastReadAndInbox( const layers = foldIntoAuthor ? coreLayers : [relayUrlsLocalsFirst(r.urls), ...coreLayers] + const policyLayers: FeedRelayLayer[] = layers.map((urls, index) => ({ + source: index === 0 && !foldIntoAuthor ? 'explicit' : index === 0 ? 'viewer-read' : 'fallback', + urls + })) return { ...r, - urls: ensureNostrLandAggrRelay( - mergeRelayPriorityLayers(layers, blockedRelays, max, { - applySocialKindBlockedFilter: applySocial, - exemptNormUrlsFromSocialKindBlock: userReadSocialExempt - }), - { blockedRelays, maxRelays: max } - ) + urls: feedRelayPolicyUrls(policyLayers, { + operation: 'read', + blockedRelays, + maxRelays: max, + applySocialKindBlockedFilter: applySocial, + socialKindBlockedExemptRelays: [...userReadSocialExempt], + allowThirdPartyLocalRelays: true + }) } }) } diff --git a/src/lib/live-activities.ts b/src/lib/live-activities.ts index 91cb63f4..10ec7ee6 100644 --- a/src/lib/live-activities.ts +++ b/src/lib/live-activities.ts @@ -1,10 +1,10 @@ import { FAST_READ_RELAY_URLS } from '@/constants' +import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { isAudio, isHlsPlaylistUrl, isVideo } from '@/lib/url' import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' import { dedupeNormalizeRelayUrlsOrdered, MAX_REQ_RELAY_URLS, - mergeRelayPriorityLayers, relayUrlsLocalsFirst } from '@/lib/relay-url-priority' import { normalizeAnyRelayUrl } from '@/lib/url' @@ -683,16 +683,32 @@ export function buildLiveActivitiesRelayUrls(options: { const fast = dedupeNormalizeRelayUrlsOrdered( FAST_READ_RELAY_URLS.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) ) - return mergeRelayPriorityLayers([fav, read, write, fast], blockedRelays, MAX_REQ_RELAY_URLS, { - applySocialKindBlockedFilter: true + return feedRelayPolicyUrls([ + { source: 'favorites', urls: fav }, + { source: 'viewer-read', urls: read }, + { source: 'viewer-write', urls: write }, + { source: 'fast-read', urls: fast } + ], { + operation: 'read', + blockedRelays, + maxRelays: MAX_REQ_RELAY_URLS, + applySocialKindBlockedFilter: true, + allowThirdPartyLocalRelays: true }) } const fav = relayUrlsLocalsFirst(getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays)) const fast = dedupeNormalizeRelayUrlsOrdered( FAST_READ_RELAY_URLS.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) ) - return mergeRelayPriorityLayers([fav, fast], blockedRelays, MAX_REQ_RELAY_URLS, { - applySocialKindBlockedFilter: true + return feedRelayPolicyUrls([ + { source: 'favorites', urls: fav }, + { source: 'fast-read', urls: fast } + ], { + operation: 'read', + blockedRelays, + maxRelays: MAX_REQ_RELAY_URLS, + applySocialKindBlockedFilter: true, + allowThirdPartyLocalRelays: true }) } diff --git a/src/lib/nostr-land-aggr.ts b/src/lib/nostr-land-aggr.ts index 8b9451c2..8234dfaa 100644 --- a/src/lib/nostr-land-aggr.ts +++ b/src/lib/nostr-land-aggr.ts @@ -91,27 +91,3 @@ export function stripNostrLandAggrRelay(urls: readonly string[]): string[] { } return out } - -/** - * Feed/read surfaces should always hit the nostr.land aggregator. Prepend it before relay caps - * can drop it, unless the user explicitly blocked it for that surface. - */ -export function ensureNostrLandAggrRelay( - urls: readonly string[], - options: { blockedRelays?: readonly string[]; maxRelays?: number } = {} -): string[] { - const blocked = new Set((options.blockedRelays ?? []).map(canonWs)) - const out: string[] = [] - const seen = new Set() - const push = (u: string) => { - const c = canonWs(u) - if (!c || blocked.has(c) || seen.has(c)) return - seen.add(c) - out.push(normalizeAnyRelayUrl(u) || u.trim()) - } - push(AGGR_NOSTR_LAND_WSS) - for (const u of urls) { - push(u) - } - return typeof options.maxRelays === 'number' ? out.slice(0, options.maxRelays) : out -} diff --git a/src/lib/profile-report-relay-urls.ts b/src/lib/profile-report-relay-urls.ts index bed0bc07..a808f509 100644 --- a/src/lib/profile-report-relay-urls.ts +++ b/src/lib/profile-report-relay-urls.ts @@ -3,8 +3,9 @@ * relays — no profile outboxes or global read mirrors, to limit abusive report spam. */ +import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' -import { mergeRelayPriorityLayers, relayUrlsLocalsFirst } from '@/lib/relay-url-priority' +import { relayUrlsLocalsFirst } from '@/lib/relay-url-priority' import { normalizeUrl } from '@/lib/url' import client from '@/services/client.service' @@ -21,7 +22,14 @@ export async function buildProfileReportRelayUrls(options: { .map((u) => normalizeUrl(u) || u) .filter(Boolean) as string[] const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) - return mergeRelayPriorityLayers([favorites, inbox], blockedRelays, MAX_PROFILE_REPORT_RELAYS, { - applySocialKindBlockedFilter: false + return feedRelayPolicyUrls([ + { source: 'favorites', urls: favorites }, + { source: 'viewer-read', urls: inbox } + ], { + operation: 'read', + blockedRelays, + maxRelays: MAX_PROFILE_REPORT_RELAYS, + applySocialKindBlockedFilter: false, + allowThirdPartyLocalRelays: true }) } diff --git a/src/lib/relay-list-builder.ts b/src/lib/relay-list-builder.ts index 1415bb50..5a59f659 100644 --- a/src/lib/relay-list-builder.ts +++ b/src/lib/relay-list-builder.ts @@ -10,8 +10,8 @@ */ import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' +import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' -import { ensureNostrLandAggrRelay } from '@/lib/nostr-land-aggr' import { isHttpRelayUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { getCacheRelayUrls } from './private-relays' import client from '@/services/client.service' @@ -246,7 +246,12 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio } const merged = Array.from(relayUrls) - return ensureNostrLandAggrRelay(merged, { blockedRelays }) + return feedRelayPolicyUrls([{ source: 'fallback', urls: merged }], { + operation: 'read', + blockedRelays, + applySocialKindBlockedFilter: false, + allowThirdPartyLocalRelays: true + }) } /** @@ -371,9 +376,12 @@ export async function buildPollResultsReadRelayUrls(options: { pushLayer([...FAST_READ_RELAY_URLS]) pushLayer(authorReadSlice) - return ensureNostrLandAggrRelay(ordered.slice(0, POLL_RESULTS_MAX_RELAYS), { + return feedRelayPolicyUrls([{ source: 'fallback', urls: ordered }], { + operation: 'read', blockedRelays, - maxRelays: POLL_RESULTS_MAX_RELAYS + maxRelays: POLL_RESULTS_MAX_RELAYS, + applySocialKindBlockedFilter: false, + allowThirdPartyLocalRelays: true }) } diff --git a/src/lib/relay-url-priority.ts b/src/lib/relay-url-priority.ts index 3ad0b6ec..aa641964 100644 --- a/src/lib/relay-url-priority.ts +++ b/src/lib/relay-url-priority.ts @@ -1,11 +1,10 @@ import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, - SOCIAL_KIND_BLOCKED_RELAY_URLS, MAX_PUBLISH_RELAYS, MAX_REQ_RELAY_URLS } from '@/constants' -import { ensureNostrLandAggrRelay } from '@/lib/nostr-land-aggr' +import { feedRelayPolicyUrls, type FeedRelayLayer } from '@/features/feed/relay-policy' import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' export { MAX_REQ_RELAY_URLS } @@ -54,68 +53,6 @@ export function relayUrlsLocalsFirst(urls: string[]): string[] { return dedupeNormalizeRelayUrlsOrdered([...local, ...remote]) } -function blockedNormSet(blockedRelays: string[] | undefined): Set { - return new Set( - (blockedRelays ?? []).map((b) => normalizeAnyRelayUrl(b) || b.trim()).filter(Boolean) - ) -} - -let socialKindBlockedNormCache: Set | undefined -function socialKindBlockedNormSet(): Set { - if (!socialKindBlockedNormCache) { - socialKindBlockedNormCache = new Set( - SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) - ) - } - return socialKindBlockedNormCache -} - -export type MergeRelayPriorityLayersOptions = { - /** When true, drop {@link SOCIAL_KIND_BLOCKED_RELAY_URLS} before applying the max cap. */ - applySocialKindBlockedFilter?: boolean - /** - * Normalized relay URLs that stay in the stack even when {@link applySocialKindBlockedFilter} is on — e.g. the - * user’s NIP-65 read list — so an explicit inbox still appears under “Seen on”. ({@link READ_ONLY_RELAY_URLS} such as - * aggr are a separate concern: no publishes, but they are not in {@link SOCIAL_KIND_BLOCKED_RELAY_URLS}.) - */ - exemptNormUrlsFromSocialKindBlock?: Set -} - -/** - * Merge priority layers in order; first occurrence wins; skip blocked (and optional social-kind block list); stop at `max`. - */ -export function mergeRelayPriorityLayers( - layers: string[][], - blockedRelays: string[] | undefined, - max: number, - mergeOpts?: MergeRelayPriorityLayersOptions -): string[] { - const blocked = blockedNormSet(blockedRelays) - const socialBlocked = mergeOpts?.applySocialKindBlockedFilter - ? socialKindBlockedNormSet() - : new Set() - const socialExempt = mergeOpts?.exemptNormUrlsFromSocialKindBlock - const seen = new Set() - const out: string[] = [] - for (const layer of layers) { - for (const u of layer) { - // Must not use {@link normalizeUrl}: it turns http(s) index relays into ws(s), which then hit the WS pool. - const n = normalizeAnyRelayUrl(u) || u.trim() - if (!n || blocked.has(n) || seen.has(n)) continue - if ( - socialBlocked.has(n) && - !(socialExempt?.has(n) ?? false) - ) { - continue - } - seen.add(n) - out.push(n) - if (out.length >= max) return out - } - } - return out -} - const normFastRead = (): string[] => dedupeNormalizeRelayUrlsOrdered( FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[] @@ -175,13 +112,20 @@ export function buildPrioritizedReadRelayUrls(opts: { authorWriteRelays: opts.authorWriteRelays, favoriteRelays: opts.favoriteRelays }) - return ensureNostrLandAggrRelay( - mergeRelayPriorityLayers(layers, opts.blockedRelays, max, { - applySocialKindBlockedFilter: applySocial, - exemptNormUrlsFromSocialKindBlock: exemptFromSocial - }), - { blockedRelays: opts.blockedRelays, maxRelays: max } - ) + const policyLayers: FeedRelayLayer[] = [ + { source: 'viewer-read', urls: layers[0] ?? [] }, + { source: 'author-write', urls: layers[1] ?? [] }, + { source: 'favorites', urls: layers[2] ?? [] }, + { source: 'fast-read', urls: layers[3] ?? [] } + ] + return feedRelayPolicyUrls(policyLayers, { + operation: 'read', + blockedRelays: opts.blockedRelays, + maxRelays: max, + applySocialKindBlockedFilter: applySocial, + socialKindBlockedExemptRelays: [...exemptFromSocial], + allowThirdPartyLocalRelays: true + }) } /** @@ -222,7 +166,19 @@ export function buildPrioritizedWriteRelayUrls(opts: { favoriteRelays: opts.favoriteRelays, extraRelays: opts.extraRelays }) - return mergeRelayPriorityLayers(layers, opts.blockedRelays, max, { - applySocialKindBlockedFilter: opts.applySocialKindBlockedFilter === true + return feedRelayPolicyUrls([ + { source: 'viewer-write', urls: layers[0] ?? [] }, + { source: 'author-read', urls: layers[1] ?? [] }, + { source: 'favorites', urls: layers[2] ?? [] }, + { source: 'explicit', urls: layers[3] ?? [] }, + { source: 'fast-write', urls: layers[4] ?? [] }, + { source: 'fast-read', urls: layers[5] ?? [] } + ], { + operation: 'write', + blockedRelays: opts.blockedRelays, + maxRelays: max, + nostrLandAggr: 'never', + applySocialKindBlockedFilter: opts.applySocialKindBlockedFilter === true, + allowThirdPartyLocalRelays: true }) } diff --git a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts index e2aa4b4a..2a3e2a13 100644 --- a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts +++ b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts @@ -18,7 +18,7 @@ import { } from '@/constants' import { RENDERABLE_NOTE_KINDS_SORTED } from '@/lib/note-renderable-kinds' import { buildProfileAugmentedReadRelayUrls } from '@/lib/favorites-feed-relays' -import { ensureNostrLandAggrRelay } from '@/lib/nostr-land-aggr' +import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority' import { normalizeTopic } from '@/lib/discussion-topics' import { userIdToPubkey } from '@/lib/pubkey' @@ -94,8 +94,11 @@ export function ensureFauxSpellRelayStackTouchesFastRead(urls: string[]): string const n = normalizeAnyRelayUrl(u) || u.trim() if (n) fastNormSet.add(n) } - const out = ensureNostrLandAggrRelay(dedupeNormalizeRelayUrlsOrdered(urls), { - maxRelays: FAUX_SPELL_MAX_RELAYS + const out = feedRelayPolicyUrls([{ source: 'fallback', urls: dedupeNormalizeRelayUrlsOrdered(urls) }], { + operation: 'read', + maxRelays: FAUX_SPELL_MAX_RELAYS, + applySocialKindBlockedFilter: false, + allowThirdPartyLocalRelays: true }) if (!out.length) return fast.slice(0, FAUX_SPELL_MAX_RELAYS) @@ -128,8 +131,11 @@ export function ensureFauxSpellRelayStackTouchesFastRead(urls: string[]): string } if (!addedOne) break } - return ensureNostrLandAggrRelay(dedupeNormalizeRelayUrlsOrdered(out), { - maxRelays: FAUX_SPELL_MAX_RELAYS + return feedRelayPolicyUrls([{ source: 'fallback', urls: dedupeNormalizeRelayUrlsOrdered(out) }], { + operation: 'read', + maxRelays: FAUX_SPELL_MAX_RELAYS, + applySocialKindBlockedFilter: false, + allowThirdPartyLocalRelays: true }) } diff --git a/src/providers/FeedProvider.tsx b/src/providers/FeedProvider.tsx index 0d88a008..f1b86995 100644 --- a/src/providers/FeedProvider.tsx +++ b/src/providers/FeedProvider.tsx @@ -1,6 +1,6 @@ -import { getFavoritesFeedRelayUrls, mergeRelayUrlLayers } from '@/lib/favorites-feed-relays' +import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' +import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' import { getRelaySetFromEvent, getRelayListFromEvent, getHttpRelayListFromEvent } from '@/lib/event-metadata' -import { stripNostrLandAggrRelay } from '@/lib/nostr-land-aggr' import logger from '@/lib/logger' import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl } from '@/lib/url' import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' @@ -24,6 +24,23 @@ function relayUrlListIdentity(urls: string[]): string { .join('\n') } +function buildAllFavoritesFeedRelayUrls( + favoriteRelays: string[], + blockedRelays: string[], + extraFeedRelayUrls: string[] +): string[] { + return feedRelayPolicyUrls([ + { source: 'favorites', urls: getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) }, + { source: 'fallback', urls: extraFeedRelayUrls } + ], { + operation: 'favorites-feed', + blockedRelays, + nostrLandAggr: 'never', + applySocialKindBlockedFilter: false, + allowThirdPartyLocalRelays: true + }) +} + export function FeedProvider({ children }: { children: React.ReactNode }) { const { pubkey, isInitialized, cacheRelayListEvent, httpRelayListEvent } = useNostr() const { relaySets, favoriteRelays, blockedRelays } = useFavoriteRelays() @@ -48,16 +65,14 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { }, [cacheRelayListEvent, httpRelayListEvent]) /** Default relays immediately so feeds / sidebar REQ never wait on Nostr session restore. */ const [relayUrls, setRelayUrls] = useState(() => - stripNostrLandAggrRelay( - mergeRelayUrlLayers([getFavoritesFeedRelayUrls([], []), [buildWispTrendingNotesRelayUrl()]], []) - ) + buildAllFavoritesFeedRelayUrls([], [], [buildWispTrendingNotesRelayUrl()]) ) const [isReady, setIsReady] = useState(true) const [feedInfo, setFeedInfo] = useState({ feedType: 'all-favorites' }) const feedInfoRef = useRef(feedInfo) - /** Same logical list as {@link mergeRelayUrlLayers} result — reuse array ref so NoteList does not re-subscribe. */ + /** Same logical relay policy result — reuse array ref so NoteList does not re-subscribe. */ const setRelayUrlsIfChanged = useCallback((next: string[]) => { setRelayUrls((prev) => { if (relayUrlListIdentity(prev) === relayUrlListIdentity(next)) return prev @@ -140,10 +155,7 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { return } if (feedType === 'all-favorites') { - const baseRelays = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) - const finalRelays = stripNostrLandAggrRelay( - mergeRelayUrlLayers([baseRelays, extraFeedRelayUrls], blockedRelays) - ) + const finalRelays = buildAllFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, extraFeedRelayUrls) logger.debug('Switching to all-favorites, finalRelays:', finalRelays) const newFeedInfo = { feedType } setFeedInfo(newFeedInfo) @@ -238,10 +250,7 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { // Update relay URLs when favoriteRelays, blocked, or extra relay lists change while in all-favorites mode useEffect(() => { if (feedInfo.feedType !== 'all-favorites') return - const baseRelays = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) - const finalRelays = stripNostrLandAggrRelay( - mergeRelayUrlLayers([baseRelays, extraFeedRelayUrls], blockedRelays) - ) + const finalRelays = buildAllFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, extraFeedRelayUrls) logger.debug('Updating relay URLs for all-favorites:', finalRelays) // Same logical list can be merged into a new array each run; keep the previous reference so // feed consumers (RelaysFeed → NoteList relay subscription) do not re-enter effects in a tight loop. diff --git a/src/services/client.service.ts b/src/services/client.service.ts index c701cf1a..ed2e09bb 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -111,7 +111,6 @@ import { buildPrioritizedWriteRelayUrls, dedupeNormalizeRelayUrlsOrdered, filterContextAuthorReadRelaysForPublish, - mergeRelayPriorityLayers, relayUrlsLocalsFirst } from '@/lib/relay-url-priority' import { @@ -138,6 +137,8 @@ import { normalizeUrl, simplifyUrl } from '@/lib/url' +import { canonicalFeedFilter, canonicalRelayUrls } from '@/features/feed/descriptor' +import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { isSafari } from '@/lib/utils' import { ISigner, @@ -926,12 +927,18 @@ class ClientService extends EventTarget { const authorOverflow = authorWriteOrdered.slice(authorTier1Cap) const publishCap = recipientReadDeduped.length > 0 ? PUBLIC_MESSAGE_RSVP_PUBLISH_MAX_RELAYS : MAX_PUBLISH_RELAYS - let pubRelays = mergeRelayPriorityLayers( - [authorPrimary, recipientReadDeduped, authorOverflow], - blockedRelayUrls, - publishCap, - { applySocialKindBlockedFilter: false } - ) + let pubRelays = feedRelayPolicyUrls([ + { source: 'viewer-write', urls: authorPrimary }, + { source: 'author-read', urls: recipientReadDeduped }, + { source: 'viewer-write', urls: authorOverflow } + ], { + operation: 'write', + blockedRelays: blockedRelayUrls, + maxRelays: publishCap, + nostrLandAggr: 'never', + applySocialKindBlockedFilter: false, + allowThirdPartyLocalRelays: true + }) pubRelays = this.filterPublishingRelays(pubRelays, event) logger.debug('[DetermineTargetRelays] Public message / calendar RSVP: author outbox + recipient inboxes only', { kind: event.kind, @@ -941,12 +948,14 @@ class ClientService extends EventTarget { }) if (pubRelays.length > 0) return pubRelays return this.filterPublishingRelays( - mergeRelayPriorityLayers( - [relayUrlsLocalsFirst([...FAST_WRITE_RELAY_URLS])], - blockedRelayUrls, - MAX_PUBLISH_RELAYS, - { applySocialKindBlockedFilter: false } - ), + feedRelayPolicyUrls([{ source: 'fast-write', urls: relayUrlsLocalsFirst([...FAST_WRITE_RELAY_URLS]) }], { + operation: 'write', + blockedRelays: blockedRelayUrls, + maxRelays: MAX_PUBLISH_RELAYS, + nostrLandAggr: 'never', + applySocialKindBlockedFilter: false, + allowThirdPartyLocalRelays: true + }), event ) } @@ -1774,18 +1783,9 @@ class ClientService extends EventTarget { } private generateTimelineKey(urls: string[], filter: Filter) { - const stableFilter: any = {} - Object.entries(filter) - .sort() - .forEach(([key, value]) => { - if (Array.isArray(value)) { - stableFilter[key] = [...value].sort() - } - stableFilter[key] = value - }) const paramsStr = JSON.stringify({ - urls: [...urls].sort(), - filter: stableFilter + urls: canonicalRelayUrls(urls), + filter: canonicalFeedFilter(filter) }) const encoder = new TextEncoder() const data = encoder.encode(paramsStr) diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index c88359cd..4dd00cd2 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -27,8 +27,8 @@ import { getWebExternalReactionTargetUrl, rssArticleStableEventId } from '@/lib/rss-article' +import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' -import { ensureNostrLandAggrRelay } from '@/lib/nostr-land-aggr' import { getEmojiInfosFromEmojiTags, getFirstHexEventIdFromETags, tagNameEquals } from '@/lib/tag' import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import client, { eventService } from '@/services/client.service' @@ -541,8 +541,11 @@ class NoteStatsService { // ignore } - return ensureNostrLandAggrRelay(Array.from(seen), { - blockedRelays: E_TAG_FILTER_BLOCKED_RELAY_URLS + return feedRelayPolicyUrls([{ source: 'fallback', urls: Array.from(seen) }], { + operation: 'read', + blockedRelays: E_TAG_FILTER_BLOCKED_RELAY_URLS, + applySocialKindBlockedFilter: false, + allowThirdPartyLocalRelays: true }) }