Browse Source

refactor feeds

imwald
Silberengel 1 month ago
parent
commit
a8b9b061b7
  1. 25
      src/components/Embedded/EmbeddedNote.tsx
  2. 111
      src/components/NoteList/index.tsx
  3. 9
      src/components/ReplyNoteList/index.tsx
  4. 5
      src/constants.ts
  5. 47
      src/features/feed/adapters.test.ts
  6. 281
      src/features/feed/adapters.ts
  7. 56
      src/features/feed/client-loader.test.ts
  8. 87
      src/features/feed/client-loader.ts
  9. 76
      src/features/feed/descriptor.test.ts
  10. 187
      src/features/feed/descriptor.ts
  11. 41
      src/features/feed/diagnostics.test.ts
  12. 56
      src/features/feed/diagnostics.ts
  13. 5
      src/features/feed/index.ts
  14. 61
      src/features/feed/note-list-requests.test.ts
  15. 66
      src/features/feed/note-list-requests.ts
  16. 63
      src/features/feed/relay-policy.test.ts
  17. 218
      src/features/feed/relay-policy.ts
  18. 99
      src/features/feed/runtime.test.ts
  19. 407
      src/features/feed/runtime.ts
  20. 38
      src/lib/favorites-feed-relays.ts
  21. 26
      src/lib/live-activities.ts
  22. 24
      src/lib/nostr-land-aggr.ts
  23. 14
      src/lib/profile-report-relay-urls.ts
  24. 16
      src/lib/relay-list-builder.ts
  25. 100
      src/lib/relay-url-priority.ts
  26. 16
      src/pages/primary/SpellsPage/fauxSpellFeeds.ts
  27. 37
      src/providers/FeedProvider.tsx
  28. 48
      src/services/client.service.ts
  29. 9
      src/services/note-stats.service.ts

25
src/components/Embedded/EmbeddedNote.tsx

@ -19,7 +19,7 @@ import indexedDb from '@/services/indexed-db.service'
import nip66Service from '@/services/nip66.service' import nip66Service from '@/services/nip66.service'
import { navigationEventStore } from '@/services/navigation-event-store' import { navigationEventStore } from '@/services/navigation-event-store'
import { useViewerInboxRelayUrlsAndAggrEligibility } from '@/hooks/useViewerInboxRelayUrlsAndAggr' 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 { useFavoriteRelays } from '@/providers/favorite-relays-context'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useReply } from '@/providers/ReplyProvider' import { useReply } from '@/providers/ReplyProvider'
@ -333,7 +333,14 @@ function EmbeddedNoteFetched({
if (cancelled || eventRef.current) return if (cancelled || eventRef.current) return
const wide0 = embedFetchCtxRef.current.wideRelaysStatic const wide0 = embedFetchCtxRef.current.wideRelaysStatic
const wideMerged = preferPublicIndexRelaysFirst(dedupeRelayUrls([...wide0, ...extra])) 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 if (cancelled || !ev) return
resolve(ev) resolve(ev)
})() })()
@ -519,8 +526,11 @@ function buildEmbedWideRelayUrlsStatic(
relayHintsFromParent: string[], relayHintsFromParent: string[],
viewerInboxRelayUrls: string[] viewerInboxRelayUrls: string[]
): string[] { ): string[] {
return ensureNostrLandAggrRelay( return feedRelayPolicyUrls(
preferPublicIndexRelaysFirst( [
{
source: 'fallback',
urls: preferPublicIndexRelaysFirst(
dedupeRelayUrls([ dedupeRelayUrls([
...relayHintsFromParent, ...relayHintsFromParent,
...viewerInboxRelayUrls, ...viewerInboxRelayUrls,
@ -532,6 +542,13 @@ function buildEmbedWideRelayUrlsStatic(
...menuRelayUrls ...menuRelayUrls
]) ])
) )
}
],
{
operation: 'read',
applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true
}
) )
} }

111
src/components/NoteList/index.tsx

@ -20,11 +20,10 @@ import {
import { shouldFilterEvent } from '@/lib/event-filtering' import { shouldFilterEvent } from '@/lib/event-filtering'
import { import {
isRelayUrlStrictSupersetIdentityKey, isRelayUrlStrictSupersetIdentityKey,
isSpellSubRequestsSameFiltersDifferentRelays, isSpellSubRequestsSameFiltersDifferentRelays
stableSpellFeedFilterKey
} from '@/lib/spell-feed-request-identity' } from '@/lib/spell-feed-request-identity'
import logger from '@/lib/logger' 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 { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter'
import { shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata' import { shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata'
import { isTouchDevice } from '@/lib/utils' import { isTouchDevice } from '@/lib/utils'
@ -91,6 +90,12 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
import MediaGridItem from '../MediaGridItem' 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 LIMIT = 150 // Per-shard REQ limit for timeline + loadMore (larger batches = fewer round-trips)
const ALGO_LIMIT = 200 // Increased from 500 for algorithm feeds 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 // Memoize subRequests serialization to avoid expensive JSON.stringify on every render
const subRequestsKey = useMemo(() => { const subRequestsKey = useMemo(() => legacyFeedSubscriptionKey(subRequests), [subRequests])
return JSON.stringify(
subRequests.map((req) => ({
urls: [...req.urls].map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean).sort(),
filter: stableSpellFeedFilterKey(req.filter)
}))
)
}, [subRequests])
const followingFeedDeltaSubRequestsKey = useMemo( const followingFeedDeltaSubRequestsKey = useMemo(
() => () =>
JSON.stringify( legacyFeedSubscriptionKey(followingFeedDeltaSubRequests ?? []),
(followingFeedDeltaSubRequests ?? []).map((req) => ({
urls: [...req.urls].map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean).sort(),
filter: stableSpellFeedFilterKey(req.filter)
}))
),
[followingFeedDeltaSubRequests] [followingFeedDeltaSubRequests]
) )
@ -962,51 +955,16 @@ const NoteList = forwardRef(
const mapLiveSubRequestsForTimeline = useCallback( const mapLiveSubRequestsForTimeline = useCallback(
(requests: TFeedSubRequest[]) => { (requests: TFeedSubRequest[]) => {
const defaultKinds = effectiveShowKinds.length > 0 ? effectiveShowKinds : [kinds.ShortTextNote] const defaultKinds = effectiveShowKinds.length > 0 ? effectiveShowKinds : [kinds.ShortTextNote]
const seeAllNoSpell = seeAllFeedEvents && !useFilterAsIs return mapNoteListSubRequestsForTimeline(requests, {
return requests.map(({ urls, filter }) => { defaultKinds,
const baseLimit = filter.limit ?? (areAlgoRelays ? ALGO_LIMIT : LIMIT) seeAllFeedEvents,
if (useFilterAsIs) { useFilterAsIs,
const hasKindsInRequest = Array.isArray(filter.kinds) && filter.kinds.length > 0 areAlgoRelays,
if (allowKindlessRelayExplore && urls.length === 1 && !hasKindsInRequest) { allowKindlessRelayExplore,
const finalFilter: Filter = { clientSideKindFilter,
...filter, limit: LIMIT,
limit: filter.limit ?? RELAY_EXPLORE_LIMIT algoLimit: ALGO_LIMIT,
} relayExploreLimit: 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
}
}
}) })
}, },
[ [
@ -1118,12 +1076,7 @@ const NoteList = forwardRef(
const subRequestsRef = useRef(subRequests) const subRequestsRef = useRef(subRequests)
subRequestsRef.current = subRequests subRequestsRef.current = subRequests
// Stable key for kind filter so subscription effect doesn't re-run on parent re-renders with same kinds const showKindsKey = useMemo(() => stableFeedKindKey(effectiveShowKinds), [effectiveShowKinds])
// 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])
/** /**
* Session snapshot identity: feed + kind UI toggles that affect **REQ** / merged rows. * Session snapshot identity: feed + kind UI toggles that affect **REQ** / merged rows.
@ -1132,18 +1085,16 @@ const NoteList = forwardRef(
*/ */
const sessionSnapshotIdentityKey = useMemo( const sessionSnapshotIdentityKey = useMemo(
() => () =>
JSON.stringify({ buildFeedSessionSnapshotKey({
feed: timelineSubscriptionKey, feedKey: timelineSubscriptionKey,
...(homeFeedListMode ? { homeSurface: homeFeedListMode } : {}), homeSurface: homeFeedListMode,
...(allowKindlessRelayExplore allowKindlessRelayExplore,
? { relayKindless: true, showAllKinds } showAllKinds,
: { kindsKey: showKindsKey,
kinds: showKindsKey, showKind1OPs,
op: showKind1OPs, showKind1Replies,
rep: showKind1Replies, showKind1111,
c1111: showKind1111, seeAllFeedEvents
seeAll: seeAllFeedEvents
})
}), }),
[ [
timelineSubscriptionKey, timelineSubscriptionKey,

9
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 discussionFeedCache from '@/services/discussion-feed-cache.service'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder' 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 { eventReferencesThreadTarget } from '@/lib/op-reference-tags'
import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match' import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match'
import { import {
@ -1224,8 +1224,11 @@ function ReplyNoteList({
filters.push(...buildRssArticleUrlThreadInteractionFilters(rootInfo.id, LIMIT)) filters.push(...buildRssArticleUrlThreadInteractionFilters(rootInfo.id, LIMIT))
} }
const relayUrlsForThreadReq = ensureNostrLandAggrRelay(finalRelayUrls, { const relayUrlsForThreadReq = feedRelayPolicyUrls([{ source: 'fallback', urls: finalRelayUrls }], {
blockedRelays: replyBlockedRelays operation: 'read',
blockedRelays: replyBlockedRelays,
applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true
}) })
// For URL threads: stream events as they arrive from each relay so replies appear // For URL threads: stream events as they arrive from each relay so replies appear

5
src/constants.ts

@ -114,9 +114,8 @@ export const TIMELINE_SHARD_SUBSCRIBE_CONCURRENCY = 5
export const MAX_PUBLISH_RELAYS = 20 export const MAX_PUBLISH_RELAYS = 20
/** /**
* Kind 24 / 31925: {@link mergeRelayPriorityLayers} used the full {@link MAX_PUBLISH_RELAYS} budget on the authors * Kind 24 / 31925: reserve space for organizer/recipient inboxes instead of letting the author's outboxes consume
* outbox list first, so recipient **read** inboxes were often never reached. This higher cap plus an author slice * the entire {@link MAX_PUBLISH_RELAYS} budget first.
* (see client.service) reserves space for organizer/recipient relays.
*/ */
export const PUBLIC_MESSAGE_RSVP_PUBLISH_MAX_RELAYS = 28 export const PUBLIC_MESSAGE_RSVP_PUBLISH_MAX_RELAYS = 28

47
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)
})
})

281
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)
}

56
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)
})
})

87
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<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
}
}
}

76
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
})
)
})
})

187
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<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
})
})
}

41
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)
})
})

56
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)
}

5
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'

61
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 })
})
})

66
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
}
}
})
}

63
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'
})
)
})
})

218
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<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
}

99
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)
})
})

407
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<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
}
}

38
src/lib/favorites-feed-relays.ts

@ -14,10 +14,10 @@ import {
buildReadRelayPriorityLayers, buildReadRelayPriorityLayers,
dedupeNormalizeRelayUrlsOrdered, dedupeNormalizeRelayUrlsOrdered,
MAX_REQ_RELAY_URLS, MAX_REQ_RELAY_URLS,
mergeRelayPriorityLayers,
relayUrlsLocalsFirst relayUrlsLocalsFirst
} from '@/lib/relay-url-priority' } 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' import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize'
const blockedSet = (blockedRelays: string[]) => const blockedSet = (blockedRelays: string[]) =>
@ -50,15 +50,16 @@ export function getFavoritesFeedRelayUrls(
return k && !blocked.has(k) return k && !blocked.has(k)
}) })
const base = visible.length > 0 ? visible : DEFAULT_FAVORITE_RELAYS const base = visible.length > 0 ? visible : DEFAULT_FAVORITE_RELAYS
const seen = new Set<string>() return feedRelayPolicyUrls(
const out: string[] = [] [{ source: 'favorites', urls: stripNostrLandAggrRelay(base) }],
for (const u of base) { {
const k = normalizeAnyRelayUrl(u) || u operation: 'favorites-feed',
if (!k || seen.has(k)) continue blockedRelays,
seen.add(k) nostrLandAggr: 'never',
out.push(k) applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true
} }
return stripNostrLandAggrRelay(out) )
} }
/** /**
@ -271,15 +272,20 @@ export function augmentSubRequestsWithFavoritesFastReadAndInbox(
const layers = foldIntoAuthor ? coreLayers : [relayUrlsLocalsFirst(r.urls), ...coreLayers] 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 { return {
...r, ...r,
urls: ensureNostrLandAggrRelay( urls: feedRelayPolicyUrls(policyLayers, {
mergeRelayPriorityLayers(layers, blockedRelays, max, { operation: 'read',
blockedRelays,
maxRelays: max,
applySocialKindBlockedFilter: applySocial, applySocialKindBlockedFilter: applySocial,
exemptNormUrlsFromSocialKindBlock: userReadSocialExempt socialKindBlockedExemptRelays: [...userReadSocialExempt],
}), allowThirdPartyLocalRelays: true
{ blockedRelays, maxRelays: max } })
)
} }
}) })
} }

26
src/lib/live-activities.ts

@ -1,10 +1,10 @@
import { FAST_READ_RELAY_URLS } from '@/constants' import { FAST_READ_RELAY_URLS } from '@/constants'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { isAudio, isHlsPlaylistUrl, isVideo } from '@/lib/url' import { isAudio, isHlsPlaylistUrl, isVideo } from '@/lib/url'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { import {
dedupeNormalizeRelayUrlsOrdered, dedupeNormalizeRelayUrlsOrdered,
MAX_REQ_RELAY_URLS, MAX_REQ_RELAY_URLS,
mergeRelayPriorityLayers,
relayUrlsLocalsFirst relayUrlsLocalsFirst
} from '@/lib/relay-url-priority' } from '@/lib/relay-url-priority'
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
@ -683,16 +683,32 @@ export function buildLiveActivitiesRelayUrls(options: {
const fast = dedupeNormalizeRelayUrlsOrdered( const fast = dedupeNormalizeRelayUrlsOrdered(
FAST_READ_RELAY_URLS.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) FAST_READ_RELAY_URLS.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean)
) )
return mergeRelayPriorityLayers([fav, read, write, fast], blockedRelays, MAX_REQ_RELAY_URLS, { return feedRelayPolicyUrls([
applySocialKindBlockedFilter: true { 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 fav = relayUrlsLocalsFirst(getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays))
const fast = dedupeNormalizeRelayUrlsOrdered( const fast = dedupeNormalizeRelayUrlsOrdered(
FAST_READ_RELAY_URLS.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) FAST_READ_RELAY_URLS.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean)
) )
return mergeRelayPriorityLayers([fav, fast], blockedRelays, MAX_REQ_RELAY_URLS, { return feedRelayPolicyUrls([
applySocialKindBlockedFilter: true { source: 'favorites', urls: fav },
{ source: 'fast-read', urls: fast }
], {
operation: 'read',
blockedRelays,
maxRelays: MAX_REQ_RELAY_URLS,
applySocialKindBlockedFilter: true,
allowThirdPartyLocalRelays: true
}) })
} }

24
src/lib/nostr-land-aggr.ts

@ -91,27 +91,3 @@ export function stripNostrLandAggrRelay(urls: readonly string[]): string[] {
} }
return out 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<string>()
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
}

14
src/lib/profile-report-relay-urls.ts

@ -3,8 +3,9 @@
* relays no profile outboxes or global read mirrors, to limit abusive report spam. * 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 { 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 { normalizeUrl } from '@/lib/url'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -21,7 +22,14 @@ export async function buildProfileReportRelayUrls(options: {
.map((u) => normalizeUrl(u) || u) .map((u) => normalizeUrl(u) || u)
.filter(Boolean) as string[] .filter(Boolean) as string[]
const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays)
return mergeRelayPriorityLayers([favorites, inbox], blockedRelays, MAX_PROFILE_REPORT_RELAYS, { return feedRelayPolicyUrls([
applySocialKindBlockedFilter: false { source: 'favorites', urls: favorites },
{ source: 'viewer-read', urls: inbox }
], {
operation: 'read',
blockedRelays,
maxRelays: MAX_PROFILE_REPORT_RELAYS,
applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true
}) })
} }

16
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 { 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 { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import { ensureNostrLandAggrRelay } from '@/lib/nostr-land-aggr'
import { isHttpRelayUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { isHttpRelayUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { getCacheRelayUrls } from './private-relays' import { getCacheRelayUrls } from './private-relays'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -246,7 +246,12 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
} }
const merged = Array.from(relayUrls) 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([...FAST_READ_RELAY_URLS])
pushLayer(authorReadSlice) pushLayer(authorReadSlice)
return ensureNostrLandAggrRelay(ordered.slice(0, POLL_RESULTS_MAX_RELAYS), { return feedRelayPolicyUrls([{ source: 'fallback', urls: ordered }], {
operation: 'read',
blockedRelays, blockedRelays,
maxRelays: POLL_RESULTS_MAX_RELAYS maxRelays: POLL_RESULTS_MAX_RELAYS,
applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true
}) })
} }

100
src/lib/relay-url-priority.ts

@ -1,11 +1,10 @@
import { import {
FAST_READ_RELAY_URLS, FAST_READ_RELAY_URLS,
FAST_WRITE_RELAY_URLS, FAST_WRITE_RELAY_URLS,
SOCIAL_KIND_BLOCKED_RELAY_URLS,
MAX_PUBLISH_RELAYS, MAX_PUBLISH_RELAYS,
MAX_REQ_RELAY_URLS MAX_REQ_RELAY_URLS
} from '@/constants' } 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' import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
export { MAX_REQ_RELAY_URLS } export { MAX_REQ_RELAY_URLS }
@ -54,68 +53,6 @@ export function relayUrlsLocalsFirst(urls: string[]): string[] {
return dedupeNormalizeRelayUrlsOrdered([...local, ...remote]) return dedupeNormalizeRelayUrlsOrdered([...local, ...remote])
} }
function blockedNormSet(blockedRelays: string[] | undefined): Set<string> {
return new Set(
(blockedRelays ?? []).map((b) => normalizeAnyRelayUrl(b) || b.trim()).filter(Boolean)
)
}
let socialKindBlockedNormCache: Set<string> | undefined
function socialKindBlockedNormSet(): Set<string> {
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
* users 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<string>
}
/**
* 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<string>()
const socialExempt = mergeOpts?.exemptNormUrlsFromSocialKindBlock
const seen = new Set<string>()
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[] => const normFastRead = (): string[] =>
dedupeNormalizeRelayUrlsOrdered( dedupeNormalizeRelayUrlsOrdered(
FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[] FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[]
@ -175,13 +112,20 @@ export function buildPrioritizedReadRelayUrls(opts: {
authorWriteRelays: opts.authorWriteRelays, authorWriteRelays: opts.authorWriteRelays,
favoriteRelays: opts.favoriteRelays favoriteRelays: opts.favoriteRelays
}) })
return ensureNostrLandAggrRelay( const policyLayers: FeedRelayLayer[] = [
mergeRelayPriorityLayers(layers, opts.blockedRelays, max, { { 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, applySocialKindBlockedFilter: applySocial,
exemptNormUrlsFromSocialKindBlock: exemptFromSocial socialKindBlockedExemptRelays: [...exemptFromSocial],
}), allowThirdPartyLocalRelays: true
{ blockedRelays: opts.blockedRelays, maxRelays: max } })
)
} }
/** /**
@ -222,7 +166,19 @@ export function buildPrioritizedWriteRelayUrls(opts: {
favoriteRelays: opts.favoriteRelays, favoriteRelays: opts.favoriteRelays,
extraRelays: opts.extraRelays extraRelays: opts.extraRelays
}) })
return mergeRelayPriorityLayers(layers, opts.blockedRelays, max, { return feedRelayPolicyUrls([
applySocialKindBlockedFilter: opts.applySocialKindBlockedFilter === true { 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
}) })
} }

16
src/pages/primary/SpellsPage/fauxSpellFeeds.ts

@ -18,7 +18,7 @@ import {
} from '@/constants' } from '@/constants'
import { RENDERABLE_NOTE_KINDS_SORTED } from '@/lib/note-renderable-kinds' import { RENDERABLE_NOTE_KINDS_SORTED } from '@/lib/note-renderable-kinds'
import { buildProfileAugmentedReadRelayUrls } from '@/lib/favorites-feed-relays' 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 { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority'
import { normalizeTopic } from '@/lib/discussion-topics' import { normalizeTopic } from '@/lib/discussion-topics'
import { userIdToPubkey } from '@/lib/pubkey' import { userIdToPubkey } from '@/lib/pubkey'
@ -94,8 +94,11 @@ export function ensureFauxSpellRelayStackTouchesFastRead(urls: string[]): string
const n = normalizeAnyRelayUrl(u) || u.trim() const n = normalizeAnyRelayUrl(u) || u.trim()
if (n) fastNormSet.add(n) if (n) fastNormSet.add(n)
} }
const out = ensureNostrLandAggrRelay(dedupeNormalizeRelayUrlsOrdered(urls), { const out = feedRelayPolicyUrls([{ source: 'fallback', urls: dedupeNormalizeRelayUrlsOrdered(urls) }], {
maxRelays: FAUX_SPELL_MAX_RELAYS operation: 'read',
maxRelays: FAUX_SPELL_MAX_RELAYS,
applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true
}) })
if (!out.length) return fast.slice(0, FAUX_SPELL_MAX_RELAYS) if (!out.length) return fast.slice(0, FAUX_SPELL_MAX_RELAYS)
@ -128,8 +131,11 @@ export function ensureFauxSpellRelayStackTouchesFastRead(urls: string[]): string
} }
if (!addedOne) break if (!addedOne) break
} }
return ensureNostrLandAggrRelay(dedupeNormalizeRelayUrlsOrdered(out), { return feedRelayPolicyUrls([{ source: 'fallback', urls: dedupeNormalizeRelayUrlsOrdered(out) }], {
maxRelays: FAUX_SPELL_MAX_RELAYS operation: 'read',
maxRelays: FAUX_SPELL_MAX_RELAYS,
applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true
}) })
} }

37
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 { getRelaySetFromEvent, getRelayListFromEvent, getHttpRelayListFromEvent } from '@/lib/event-metadata'
import { stripNostrLandAggrRelay } from '@/lib/nostr-land-aggr'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl } from '@/lib/url' import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl } from '@/lib/url'
import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
@ -24,6 +24,23 @@ function relayUrlListIdentity(urls: string[]): string {
.join('\n') .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 }) { export function FeedProvider({ children }: { children: React.ReactNode }) {
const { pubkey, isInitialized, cacheRelayListEvent, httpRelayListEvent } = useNostr() const { pubkey, isInitialized, cacheRelayListEvent, httpRelayListEvent } = useNostr()
const { relaySets, favoriteRelays, blockedRelays } = useFavoriteRelays() const { relaySets, favoriteRelays, blockedRelays } = useFavoriteRelays()
@ -48,16 +65,14 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
}, [cacheRelayListEvent, httpRelayListEvent]) }, [cacheRelayListEvent, httpRelayListEvent])
/** Default relays immediately so feeds / sidebar REQ never wait on Nostr session restore. */ /** Default relays immediately so feeds / sidebar REQ never wait on Nostr session restore. */
const [relayUrls, setRelayUrls] = useState<string[]>(() => const [relayUrls, setRelayUrls] = useState<string[]>(() =>
stripNostrLandAggrRelay( buildAllFavoritesFeedRelayUrls([], [], [buildWispTrendingNotesRelayUrl()])
mergeRelayUrlLayers([getFavoritesFeedRelayUrls([], []), [buildWispTrendingNotesRelayUrl()]], [])
)
) )
const [isReady, setIsReady] = useState(true) const [isReady, setIsReady] = useState(true)
const [feedInfo, setFeedInfo] = useState<TFeedInfo>({ const [feedInfo, setFeedInfo] = useState<TFeedInfo>({
feedType: 'all-favorites' feedType: 'all-favorites'
}) })
const feedInfoRef = useRef<TFeedInfo>(feedInfo) const feedInfoRef = useRef<TFeedInfo>(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[]) => { const setRelayUrlsIfChanged = useCallback((next: string[]) => {
setRelayUrls((prev) => { setRelayUrls((prev) => {
if (relayUrlListIdentity(prev) === relayUrlListIdentity(next)) return prev if (relayUrlListIdentity(prev) === relayUrlListIdentity(next)) return prev
@ -140,10 +155,7 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
return return
} }
if (feedType === 'all-favorites') { if (feedType === 'all-favorites') {
const baseRelays = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) const finalRelays = buildAllFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, extraFeedRelayUrls)
const finalRelays = stripNostrLandAggrRelay(
mergeRelayUrlLayers([baseRelays, extraFeedRelayUrls], blockedRelays)
)
logger.debug('Switching to all-favorites, finalRelays:', finalRelays) logger.debug('Switching to all-favorites, finalRelays:', finalRelays)
const newFeedInfo = { feedType } const newFeedInfo = { feedType }
setFeedInfo(newFeedInfo) 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 // Update relay URLs when favoriteRelays, blocked, or extra relay lists change while in all-favorites mode
useEffect(() => { useEffect(() => {
if (feedInfo.feedType !== 'all-favorites') return if (feedInfo.feedType !== 'all-favorites') return
const baseRelays = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) const finalRelays = buildAllFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, extraFeedRelayUrls)
const finalRelays = stripNostrLandAggrRelay(
mergeRelayUrlLayers([baseRelays, extraFeedRelayUrls], blockedRelays)
)
logger.debug('Updating relay URLs for all-favorites:', finalRelays) 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 // 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. // feed consumers (RelaysFeed → NoteList relay subscription) do not re-enter effects in a tight loop.

48
src/services/client.service.ts

@ -111,7 +111,6 @@ import {
buildPrioritizedWriteRelayUrls, buildPrioritizedWriteRelayUrls,
dedupeNormalizeRelayUrlsOrdered, dedupeNormalizeRelayUrlsOrdered,
filterContextAuthorReadRelaysForPublish, filterContextAuthorReadRelaysForPublish,
mergeRelayPriorityLayers,
relayUrlsLocalsFirst relayUrlsLocalsFirst
} from '@/lib/relay-url-priority' } from '@/lib/relay-url-priority'
import { import {
@ -138,6 +137,8 @@ import {
normalizeUrl, normalizeUrl,
simplifyUrl simplifyUrl
} from '@/lib/url' } from '@/lib/url'
import { canonicalFeedFilter, canonicalRelayUrls } from '@/features/feed/descriptor'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { isSafari } from '@/lib/utils' import { isSafari } from '@/lib/utils'
import { import {
ISigner, ISigner,
@ -926,12 +927,18 @@ class ClientService extends EventTarget {
const authorOverflow = authorWriteOrdered.slice(authorTier1Cap) const authorOverflow = authorWriteOrdered.slice(authorTier1Cap)
const publishCap = const publishCap =
recipientReadDeduped.length > 0 ? PUBLIC_MESSAGE_RSVP_PUBLISH_MAX_RELAYS : MAX_PUBLISH_RELAYS recipientReadDeduped.length > 0 ? PUBLIC_MESSAGE_RSVP_PUBLISH_MAX_RELAYS : MAX_PUBLISH_RELAYS
let pubRelays = mergeRelayPriorityLayers( let pubRelays = feedRelayPolicyUrls([
[authorPrimary, recipientReadDeduped, authorOverflow], { source: 'viewer-write', urls: authorPrimary },
blockedRelayUrls, { source: 'author-read', urls: recipientReadDeduped },
publishCap, { source: 'viewer-write', urls: authorOverflow }
{ applySocialKindBlockedFilter: false } ], {
) operation: 'write',
blockedRelays: blockedRelayUrls,
maxRelays: publishCap,
nostrLandAggr: 'never',
applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true
})
pubRelays = this.filterPublishingRelays(pubRelays, event) pubRelays = this.filterPublishingRelays(pubRelays, event)
logger.debug('[DetermineTargetRelays] Public message / calendar RSVP: author outbox + recipient inboxes only', { logger.debug('[DetermineTargetRelays] Public message / calendar RSVP: author outbox + recipient inboxes only', {
kind: event.kind, kind: event.kind,
@ -941,12 +948,14 @@ class ClientService extends EventTarget {
}) })
if (pubRelays.length > 0) return pubRelays if (pubRelays.length > 0) return pubRelays
return this.filterPublishingRelays( return this.filterPublishingRelays(
mergeRelayPriorityLayers( feedRelayPolicyUrls([{ source: 'fast-write', urls: relayUrlsLocalsFirst([...FAST_WRITE_RELAY_URLS]) }], {
[relayUrlsLocalsFirst([...FAST_WRITE_RELAY_URLS])], operation: 'write',
blockedRelayUrls, blockedRelays: blockedRelayUrls,
MAX_PUBLISH_RELAYS, maxRelays: MAX_PUBLISH_RELAYS,
{ applySocialKindBlockedFilter: false } nostrLandAggr: 'never',
), applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true
}),
event event
) )
} }
@ -1774,18 +1783,9 @@ class ClientService extends EventTarget {
} }
private generateTimelineKey(urls: string[], filter: Filter) { 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({ const paramsStr = JSON.stringify({
urls: [...urls].sort(), urls: canonicalRelayUrls(urls),
filter: stableFilter filter: canonicalFeedFilter(filter)
}) })
const encoder = new TextEncoder() const encoder = new TextEncoder()
const data = encoder.encode(paramsStr) const data = encoder.encode(paramsStr)

9
src/services/note-stats.service.ts

@ -27,8 +27,8 @@ import {
getWebExternalReactionTargetUrl, getWebExternalReactionTargetUrl,
rssArticleStableEventId rssArticleStableEventId
} from '@/lib/rss-article' } from '@/lib/rss-article'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import { ensureNostrLandAggrRelay } from '@/lib/nostr-land-aggr'
import { getEmojiInfosFromEmojiTags, getFirstHexEventIdFromETags, tagNameEquals } from '@/lib/tag' import { getEmojiInfosFromEmojiTags, getFirstHexEventIdFromETags, tagNameEquals } from '@/lib/tag'
import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import client, { eventService } from '@/services/client.service' import client, { eventService } from '@/services/client.service'
@ -541,8 +541,11 @@ class NoteStatsService {
// ignore // ignore
} }
return ensureNostrLandAggrRelay(Array.from(seen), { return feedRelayPolicyUrls([{ source: 'fallback', urls: Array.from(seen) }], {
blockedRelays: E_TAG_FILTER_BLOCKED_RELAY_URLS operation: 'read',
blockedRelays: E_TAG_FILTER_BLOCKED_RELAY_URLS,
applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true
}) })
} }

Loading…
Cancel
Save