diff --git a/src/lib/feed-relay-urls.ts b/src/lib/feed-relay-urls.ts index dc013db6..65a15798 100644 --- a/src/lib/feed-relay-urls.ts +++ b/src/lib/feed-relay-urls.ts @@ -65,3 +65,53 @@ export function pinHttpIndexRelaysInRelayCap( return out.slice(0, maxRelays) } + +/** + * Keep global mention / read aggregators in a capped stack (notifications `#p` REQs). + * Long NIP-65 lists otherwise fill {@link FAUX_SPELL_MAX_RELAYS} before index relays are reached. + */ +export function pinMentionRelaysInRelayCap( + capped: readonly string[], + mentionSources: readonly string[], + maxRelays: number, + minPinned: number +): string[] { + const pinKeys = new Set( + mentionSources + .slice(0, Math.max(0, minPinned)) + .map((u) => relayDedupeKey(u)) + .filter(Boolean) + ) + if (pinKeys.size === 0) return [...capped] + + const mentionKeySet = new Set(mentionSources.map((u) => relayDedupeKey(u)).filter(Boolean)) + const out = [...capped] + const outKeys = new Set(out.map(relayDedupeKey)) + + for (const raw of mentionSources) { + const key = relayDedupeKey(raw) + if (!key || outKeys.has(key)) continue + + while (out.length >= maxRelays) { + let dropped = false + for (let i = out.length - 1; i >= 0; i--) { + const candidate = out[i]! + const ck = relayDedupeKey(candidate) + if (pinKeys.has(ck) || mentionKeySet.has(ck)) continue + out.splice(i, 1) + outKeys.delete(ck) + dropped = true + break + } + if (!dropped) break + } + + if (out.length >= maxRelays) continue + out.push(raw) + outKeys.add(key) + pinKeys.add(key) + if ([...pinKeys].every((k) => outKeys.has(k))) break + } + + return out.slice(0, maxRelays) +} diff --git a/src/lib/metadata-policy-curated-relays.ts b/src/lib/metadata-policy-curated-relays.ts index 384fb6d5..93fa911a 100644 --- a/src/lib/metadata-policy-curated-relays.ts +++ b/src/lib/metadata-policy-curated-relays.ts @@ -1,6 +1,7 @@ import { BOOKSTR_RELAY_URLS, DOCUMENT_RELAY_URLS, + FAST_READ_RELAY_URLS, FOLLOWS_HISTORY_RELAY_URLS, GIF_RELAY_URLS, NIP42_POOL_AUTOMATIC_AUTH_RELAY_URLS, @@ -46,6 +47,7 @@ function relayKeyForCuratedSet(url: string): string { /** Relays grantable for the duration of an active read query/subscribe (not general feed widening). */ const METADATA_POLICY_ACTIVE_READ_GRANT_RELAY_LISTS: readonly (readonly string[])[] = [ ...METADATA_POLICY_OPERATION_SCOPED_RELAY_LISTS, + FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS, READ_ONLY_RELAY_URLS, NIP66_DISCOVERY_RELAY_URLS diff --git a/src/pages/primary/SpellsPage/fauxSpellFeeds.test.ts b/src/pages/primary/SpellsPage/fauxSpellFeeds.test.ts new file mode 100644 index 00000000..a2bbd7d1 --- /dev/null +++ b/src/pages/primary/SpellsPage/fauxSpellFeeds.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { + buildNotificationSpellRelayUrls, + FAUX_SPELL_MAX_RELAYS, + notificationMentionIndexRelayUrls +} from './fauxSpellFeeds' + +describe('buildNotificationSpellRelayUrls', () => { + it('pins mention index relays even when personal inbox fills the cap', () => { + const personal = Array.from({ length: 12 }, (_, i) => `wss://personal-inbox-${i}.example/`) + const out = buildNotificationSpellRelayUrls(personal) + expect(out.length).toBeLessThanOrEqual(FAUX_SPELL_MAX_RELAYS) + const mentionKeys = new Set( + notificationMentionIndexRelayUrls().map((u) => u.replace(/\/$/, '').toLowerCase()) + ) + const pinned = out.filter((u) => mentionKeys.has(u.replace(/\/$/, '').toLowerCase())) + expect(pinned.length).toBeGreaterThanOrEqual(3) + }) + + it('returns mention index relays when personal stack is empty', () => { + const out = buildNotificationSpellRelayUrls([]) + expect(out.length).toBeGreaterThan(0) + expect(out.some((u) => u.includes('theforest.nostr1.com') || u.includes('nostr.land'))).toBe(true) + }) +}) diff --git a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts index 1a3d3bce..926269cf 100644 --- a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts +++ b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts @@ -14,6 +14,8 @@ import { ExtendedKind, FAST_READ_RELAY_URLS, PROFILE_MEDIA_TAB_KINDS, + READ_ONLY_RELAY_URLS, + SEARCHABLE_RELAY_URLS } from '@/constants' import { RENDERABLE_NOTE_KINDS_SORTED } from '@/lib/note-renderable-kinds' import { buildProfileAugmentedReadRelayUrls } from '@/lib/favorites-feed-relays' @@ -29,7 +31,7 @@ import { parseThreadWatchListRefs } from '@/lib/notification-thread-watch' import { userIdToPubkey } from '@/lib/pubkey' -import { pinHttpIndexRelaysInRelayCap } from '@/lib/feed-relay-urls' +import { pinHttpIndexRelaysInRelayCap, pinMentionRelaysInRelayCap } from '@/lib/feed-relay-urls' import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import type { TFeedSubRequest } from '@/types' import { type Event, type Filter } from 'nostr-tools' @@ -38,6 +40,54 @@ import { type Event, type Filter } from 'nostr-tools' export const FAUX_SPELL_MAX_RELAYS = 10 export const FAUX_SPELL_EVENT_LIMIT = 200 +/** Minimum global mention/index relays pinned for the notifications spell (`#p` REQ). */ +export const NOTIFICATION_MENTION_RELAY_PIN_COUNT = 5 + +/** Relays that index `#p` mentions and recent social events for the notifications spell. */ +export function notificationMentionIndexRelayUrls(): string[] { + return dedupeNormalizeRelayUrlsOrdered([ + ...FAST_READ_RELAY_URLS, + ...SEARCHABLE_RELAY_URLS, + ...READ_ONLY_RELAY_URLS + ]) +} + +/** + * Notifications need global mention aggregators, not only the viewer's NIP-65 inbox (which may not store `#p`). + * Pins {@link NOTIFICATION_MENTION_RELAY_PIN_COUNT} index relays under {@link FAUX_SPELL_MAX_RELAYS}. + */ +export function buildNotificationSpellRelayUrls( + personalUrls: readonly string[], + blockedRelays: readonly string[] = [] +): string[] { + const blocked = new Set( + blockedRelays + .map((b) => (normalizeAnyRelayUrl(b) || b.trim()).toLowerCase()) + .filter(Boolean) + ) + const allow = (u: string) => !blocked.has((normalizeAnyRelayUrl(u) || u.trim()).toLowerCase()) + const mentionIndex = notificationMentionIndexRelayUrls().filter(allow) + const personal = dedupeNormalizeRelayUrlsOrdered([...personalUrls]).filter(allow) + const capped = feedRelayPolicyUrls( + [ + { source: 'search', urls: mentionIndex }, + { source: 'viewer-read', urls: personal } + ], + { + operation: 'read', + maxRelays: FAUX_SPELL_MAX_RELAYS, + applySocialKindBlockedFilter: false, + allowThirdPartyLocalRelays: true + } + ) + return pinMentionRelaysInRelayCap( + capped, + mentionIndex, + FAUX_SPELL_MAX_RELAYS, + Math.min(NOTIFICATION_MENTION_RELAY_PIN_COUNT, mentionIndex.length) + ) +} + /** Profile Media tab: single REQ `limit` (matches merged cap in NoteList one-shot). */ export const PROFILE_MEDIA_REQ_LIMIT = 200 diff --git a/src/pages/primary/SpellsPage/useSpellsPageFeed.ts b/src/pages/primary/SpellsPage/useSpellsPageFeed.ts index 705e8255..e9acb469 100644 --- a/src/pages/primary/SpellsPage/useSpellsPageFeed.ts +++ b/src/pages/primary/SpellsPage/useSpellsPageFeed.ts @@ -41,6 +41,7 @@ import { MEDIA_SPELL_KINDS, NOTIFICATION_SPELL_KINDS, applyFauxSpellCapsToSubRequests, + buildNotificationSpellRelayUrls, ensureFauxSpellRelayStackTouchesFastRead } from './fauxSpellFeeds' import { getRelaysForSpell, spellEventToFilter } from '@/services/spell.service' @@ -414,8 +415,11 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { ) if (selectedFauxSpell === 'notifications') { - if (!notificationsFeedPubkey || !feedUrls.length) return [] - const notificationUrls = appendMoneroNostrRelays(feedUrls) + if (!notificationsFeedPubkey) return [] + const notificationUrls = appendMoneroNostrRelays( + buildNotificationSpellRelayUrls(feedUrls, blockedRelays) + ) + if (!notificationUrls.length) return [] const base = buildNotificationsSpellSubRequests(notificationUrls, notificationsFeedPubkey) const extra = buildNotificationsFollowedThreadSubRequests( notificationUrls,