Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
d9e4b40a1b
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 7
      src/components/Explore/ExploreFavoriteRelays.tsx
  4. 16
      src/components/NormalFeed/index.tsx
  5. 20
      src/components/NoteList/index.tsx
  6. 2
      src/components/ReplyNoteList/index.tsx
  7. 14
      src/hooks/use-global-relay-bootstrap-defaults.ts
  8. 12
      src/hooks/useProfileAuthorFeedSubRequests.ts
  9. 4
      src/hooks/useProfilePins.tsx
  10. 10
      src/hooks/useProfileTimeline.tsx
  11. 14
      src/lib/account-list-relay-urls.ts
  12. 76
      src/lib/event-metadata.ts
  13. 52
      src/lib/favorites-feed-relays.ts
  14. 102
      src/lib/home-feed-relays.ts
  15. 49
      src/lib/live-activities.ts
  16. 66
      src/lib/relay-list-builder.ts
  17. 19
      src/lib/relay-url-priority.ts
  18. 54
      src/lib/viewer-relay-defaults.ts
  19. 41
      src/pages/primary/NoteListPage/RelaysFeed.tsx
  20. 7
      src/pages/primary/SpellsPage/CreateSpellDialog.tsx
  21. 8
      src/pages/primary/SpellsPage/index.tsx
  22. 17
      src/pages/secondary/NoteListPage/index.tsx
  23. 149
      src/providers/FavoriteRelaysActivityProvider.tsx
  24. 27
      src/providers/FavoriteRelaysProvider.tsx
  25. 47
      src/providers/FeedProvider.test.ts
  26. 42
      src/providers/FeedProvider.tsx
  27. 17
      src/providers/LiveActivitiesProvider.tsx
  28. 40
      src/providers/NostrProvider/index.tsx
  29. 68
      src/services/client.service.ts
  30. 7
      src/services/spell.service.ts

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.10.0", "version": "23.9.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.10.0", "version": "23.9.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.10.0", "version": "23.9.2",
"description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true, "private": true,
"type": "module", "type": "module",

7
src/components/Explore/ExploreFavoriteRelays.tsx

@ -1,6 +1,7 @@
import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '@/components/RelaySimpleInfo' import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '@/components/RelaySimpleInfo'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { DEFAULT_FAVORITE_RELAYS } from '@/constants' import { DEFAULT_FAVORITE_RELAYS } from '@/constants'
import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults'
import { useFetchRelayInfo } from '@/hooks' import { useFetchRelayInfo } from '@/hooks'
import { toRelay, toRelaySettings } from '@/lib/link' import { toRelay, toRelaySettings } from '@/lib/link'
import { normalizeUrl, simplifyUrl } from '@/lib/url' import { normalizeUrl, simplifyUrl } from '@/lib/url'
@ -61,6 +62,7 @@ export default function ExploreFavoriteRelays() {
const { navigate } = usePrimaryPage() const { navigate } = usePrimaryPage()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults()
const blockedSet = useMemo( const blockedSet = useMemo(
() => new Set(blockedRelays.map((b) => normalizeUrl(b) || b)), () => new Set(blockedRelays.map((b) => normalizeUrl(b) || b)),
@ -75,6 +77,9 @@ export default function ExploreFavoriteRelays() {
if (visible.length > 0) { if (visible.length > 0) {
return { urls: visible, usingDefaults: false } return { urls: visible, usingDefaults: false }
} }
if (!useGlobalRelayBootstrap) {
return { urls: [], usingDefaults: false }
}
const defaultsFiltered = DEFAULT_FAVORITE_RELAYS.filter((r) => { const defaultsFiltered = DEFAULT_FAVORITE_RELAYS.filter((r) => {
const k = normalizeUrl(r) || r const k = normalizeUrl(r) || r
return k && !blockedSet.has(k) return k && !blockedSet.has(k)
@ -83,7 +88,7 @@ export default function ExploreFavoriteRelays() {
urls: defaultsFiltered.length > 0 ? defaultsFiltered : DEFAULT_FAVORITE_RELAYS, urls: defaultsFiltered.length > 0 ? defaultsFiltered : DEFAULT_FAVORITE_RELAYS,
usingDefaults: true usingDefaults: true
} }
}, [favoriteRelays, blockedSet]) }, [favoriteRelays, blockedSet, useGlobalRelayBootstrap])
if (urls.length === 0) return null if (urls.length === 0) return null

16
src/components/NormalFeed/index.tsx

@ -2,6 +2,7 @@ import storage from '@/services/local-storage.service'
import NoteList, { TNoteListRef } from '@/components/NoteList' import NoteList, { TNoteListRef } from '@/components/NoteList'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
import Tabs, { TabDefinition } from '@/components/Tabs' import Tabs, { TabDefinition } from '@/components/Tabs'
import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults'
import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
import { useUserTrust } from '@/contexts/user-trust-context' import { useUserTrust } from '@/contexts/user-trust-context'
import { PROFILE_MEDIA_TAB_KINDS, FAST_READ_RELAY_URLS } from '@/constants' import { PROFILE_MEDIA_TAB_KINDS, FAST_READ_RELAY_URLS } from '@/constants'
@ -27,7 +28,10 @@ import KindFilter from '../KindFilter'
* Home Gallery: favorites (or chip relays) first, then {@link FAST_READ_RELAY_URLS} so NIP-71 / picture / voice * Home Gallery: favorites (or chip relays) first, then {@link FAST_READ_RELAY_URLS} so NIP-71 / picture / voice
* events are not starved when the users relay set is mostly text timelines. Deduped by normalized URL. * events are not starved when the users relay set is mostly text timelines. Deduped by normalized URL.
*/ */
function galleryRelayUrlsMergedWithReadLayer(favoriteUrls: readonly string[]): string[] { function galleryRelayUrlsMergedWithReadLayer(
favoriteUrls: readonly string[],
mergeGlobalFastRead: boolean
): string[] {
const seen = new Set<string>() const seen = new Set<string>()
const out: string[] = [] const out: string[] = []
const add = (raw: string) => { const add = (raw: string) => {
@ -39,7 +43,9 @@ function galleryRelayUrlsMergedWithReadLayer(favoriteUrls: readonly string[]): s
out.push(n) out.push(n)
} }
for (const u of favoriteUrls) add(u) for (const u of favoriteUrls) add(u)
for (const u of FAST_READ_RELAY_URLS) add(u) if (mergeGlobalFastRead) {
for (const u of FAST_READ_RELAY_URLS) add(u)
}
return out return out
} }
@ -155,6 +161,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
ref ref
) { ) {
const { hideUntrustedNotes } = useUserTrust() const { hideUntrustedNotes } = useUserTrust()
const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults()
const { showKinds, showKind1OPs, showKind1Replies, showKind1111, feedKindFilterBypass } = const { showKinds, showKind1OPs, showKind1Replies, showKind1111, feedKindFilterBypass } =
useKindFilterOrDefaults() useKindFilterOrDefaults()
const [listMode, setListMode] = useState<TNoteListMode>(() => { const [listMode, setListMode] = useState<TNoteListMode>(() => {
@ -219,7 +226,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
isMainFeed && mainFeedGalleryRelayUrls && mainFeedGalleryRelayUrls.length > 0 isMainFeed && mainFeedGalleryRelayUrls && mainFeedGalleryRelayUrls.length > 0
? mainFeedGalleryRelayUrls ? mainFeedGalleryRelayUrls
: isMainFeed && widenMainGalleryRelays : isMainFeed && widenMainGalleryRelays
? galleryRelayUrlsMergedWithReadLayer(req.urls) ? galleryRelayUrlsMergedWithReadLayer(req.urls, useGlobalRelayBootstrap)
: req.urls, : req.urls,
filter: { ...req.filter, kinds: MEDIA_KINDS } filter: { ...req.filter, kinds: MEDIA_KINDS }
})) }))
@ -230,7 +237,8 @@ const NormalFeed = forwardRef<TNoteListRef, {
MEDIA_KINDS, MEDIA_KINDS,
isMainFeed, isMainFeed,
widenMainGalleryRelays, widenMainGalleryRelays,
mainFeedGalleryRelayUrls mainFeedGalleryRelayUrls,
useGlobalRelayBootstrap
]) ])
const noteListExtraShouldHide = useMemo(() => { const noteListExtraShouldHide = useMemo(() => {

20
src/components/NoteList/index.tsx

@ -100,6 +100,7 @@ import {
stableFeedKindKey stableFeedKindKey
} from '@/features/feed/descriptor' } from '@/features/feed/descriptor'
import { mapNoteListSubRequestsForTimeline } from '@/features/feed/note-list-requests' import { mapNoteListSubRequestsForTimeline } from '@/features/feed/note-list-requests'
import { stripNostrLandAggrFromTimelineSubRequests } from '@/lib/home-feed-relays'
import { createFetchEventsFeedRuntimeLoader } from '@/features/feed/client-loader' import { createFetchEventsFeedRuntimeLoader } from '@/features/feed/client-loader'
import { FeedRuntime } from '@/features/feed/runtime' import { FeedRuntime } from '@/features/feed/runtime'
import { buildFeedDiagnosticsSnapshot, logFeedDiagnostics } from '@/features/feed/diagnostics' import { buildFeedDiagnosticsSnapshot, logFeedDiagnostics } from '@/features/feed/diagnostics'
@ -1893,7 +1894,10 @@ const NoteList = forwardRef(
let diskPrimeCancelled = false let diskPrimeCancelled = false
const primeDiskWhileAwaitingRelayProbe = async () => { const primeDiskWhileAwaitingRelayProbe = async () => {
try { try {
const mapped = mapLiveSubRequestsForTimeline(subRequestsRef.current) const mapped = stripNostrLandAggrFromTimelineSubRequests(
feedSubscriptionKey,
mapLiveSubRequestsForTimeline(subRequestsRef.current)
)
.map((req) => .map((req) =>
isOfflineRef.current isOfflineRef.current
? { ...req, urls: req.urls.filter((u) => isLocalNetworkUrl(u)) } ? { ...req, urls: req.urls.filter((u) => isLocalNetworkUrl(u)) }
@ -1975,7 +1979,10 @@ const NoteList = forwardRef(
const seeAllNoSpell = seeAllFeedEventsRef.current && !useFilterAsIsRef.current const seeAllNoSpell = seeAllFeedEventsRef.current && !useFilterAsIsRef.current
const mappedSubRequests = mapLiveSubRequestsForTimeline(subRequestsRef.current) const mappedSubRequests = stripNostrLandAggrFromTimelineSubRequests(
feedSubscriptionKey,
mapLiveSubRequestsForTimeline(subRequestsRef.current)
)
.map((req) => .map((req) =>
isOfflineRef.current isOfflineRef.current
? { ...req, urls: req.urls.filter((u) => isLocalNetworkUrl(u)) } ? { ...req, urls: req.urls.filter((u) => isLocalNetworkUrl(u)) }
@ -3064,6 +3071,7 @@ const NoteList = forwardRef(
} }
}, [ }, [
timelineSubscriptionKey, timelineSubscriptionKey,
feedSubscriptionKey,
sessionSnapshotIdentityKey, sessionSnapshotIdentityKey,
subRequestsKey, subRequestsKey,
preserveTimelineOnSubRequestsChange, preserveTimelineOnSubRequestsChange,
@ -3104,7 +3112,10 @@ const NoteList = forwardRef(
if (!tk) return if (!tk) return
let deltaActive = true let deltaActive = true
const mappedDelta = mapLiveSubRequestsForTimeline(deltas) const mappedDelta = stripNostrLandAggrFromTimelineSubRequests(
feedSubscriptionKey,
mapLiveSubRequestsForTimeline(deltas)
)
const seeAllNoSpellDelta = seeAllFeedEventsRef.current && !useFilterAsIsRef.current const seeAllNoSpellDelta = seeAllFeedEventsRef.current && !useFilterAsIsRef.current
const filterMissingKindsDelta = (f: Filter) => !f.kinds || f.kinds.length === 0 const filterMissingKindsDelta = (f: Filter) => !f.kinds || f.kinds.length === 0
const invalidDelta = mappedDelta.filter(({ urls, filter: f }) => { const invalidDelta = mappedDelta.filter(({ urls, filter: f }) => {
@ -3335,6 +3346,7 @@ const NoteList = forwardRef(
followingFeedDeltaSubRequestsKey, followingFeedDeltaSubRequestsKey,
timelineKey, timelineKey,
oneShotFetch, oneShotFetch,
feedSubscriptionKey,
mapLiveSubRequestsForTimeline, mapLiveSubRequestsForTimeline,
areAlgoRelays, areAlgoRelays,
allowKindlessRelayExplore, allowKindlessRelayExplore,
@ -3511,6 +3523,7 @@ const NoteList = forwardRef(
useEffect(() => { useEffect(() => {
if (!timelinePublicReadFallback) return if (!timelinePublicReadFallback) return
if (feedSubscriptionKey === 'home-all-favorites') return
if (oneShotFetch || areAlgoRelays) return if (oneShotFetch || areAlgoRelays) return
if (!navigator.onLine) return if (!navigator.onLine) return
if (feedFullSearchEvents !== null) return if (feedFullSearchEvents !== null) return
@ -3587,6 +3600,7 @@ const NoteList = forwardRef(
})() })()
}, [ }, [
timelinePublicReadFallback, timelinePublicReadFallback,
feedSubscriptionKey,
oneShotFetch, oneShotFetch,
areAlgoRelays, areAlgoRelays,
progressiveWarmupQuery, progressiveWarmupQuery,

2
src/components/ReplyNoteList/index.tsx

@ -1084,7 +1084,7 @@ function ReplyNoteList({
if (!rootInfo) return // Type guard if (!rootInfo) return // Type guard
try { try {
// READ from: FAST_READ_RELAY_URLS + user's inboxes + local relays + OP author's outboxes // READ from: thread hints, author/user NIP-65, favorites, cache — then DEFAULT_FAVORITE_RELAYS fallback.
const opAuthorPubkey = rootInfo.type === 'E' || rootInfo.type === 'A' ? rootInfo.pubkey : undefined const opAuthorPubkey = rootInfo.type === 'E' || rootInfo.type === 'A' ? rootInfo.pubkey : undefined
const seenOn = client.getSeenEventRelayUrls(event.id).map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) const seenOn = client.getSeenEventRelayUrls(event.id).map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean)
const fromBrowsingFeed = browsingRelayUrls.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) const fromBrowsingFeed = browsingRelayUrls.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean)

14
src/hooks/use-global-relay-bootstrap-defaults.ts

@ -0,0 +1,14 @@
import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
/** @returns true when the app may merge FAST_READ / FAST_WRITE / DEFAULT_FAVORITE bootstrap relays. */
export function useGlobalRelayBootstrapDefaults(): boolean {
const { pubkey, relayList } = useNostr()
const { favoriteRelays } = useFavoriteRelays()
return viewerUsesGlobalRelayDefaults({
viewerPubkey: pubkey,
favoriteRelayUrls: favoriteRelays,
relayList
})
}

12
src/hooks/useProfileAuthorFeedSubRequests.ts

@ -1,4 +1,6 @@
import { buildProfileAuthorSubRequestsFromUrlGroups } from '@/lib/profile-author-subrequests' import { buildProfileAuthorSubRequestsFromUrlGroups } from '@/lib/profile-author-subrequests'
import { isSocialKindBlockedKind } from '@/constants'
import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults'
import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays' import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays'
import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey'
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
@ -6,7 +8,6 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostrOptional } from '@/providers/nostr-context' import { useNostrOptional } from '@/providers/nostr-context'
import client from '@/services/client.service' import client from '@/services/client.service'
import type { TFeedSubRequest } from '@/types' import type { TFeedSubRequest } from '@/types'
import { isSocialKindBlockedKind } from '@/constants'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
function relayListsContentKey(favoriteRelays: string[], blockedRelays: string[]): string { function relayListsContentKey(favoriteRelays: string[], blockedRelays: string[]): string {
@ -41,6 +42,7 @@ export function useProfileAuthorFeedSubRequests({
} { } {
const nostr = useNostrOptional() const nostr = useNostrOptional()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults()
const includeAuthorLocalRelays = useMemo(() => { const includeAuthorLocalRelays = useMemo(() => {
const me = nostr?.pubkey?.trim() const me = nostr?.pubkey?.trim()
@ -80,7 +82,8 @@ export function useProfileAuthorFeedSubRequests({
emptyAuthor, emptyAuthor,
socialKinds, socialKinds,
includeAuthorLocalRelays, includeAuthorLocalRelays,
kinds kinds,
useGlobalRelayBootstrap
) )
if (!cancelled) { if (!cancelled) {
setProvisionalUrls(provisional) setProvisionalUrls(provisional)
@ -98,7 +101,8 @@ export function useProfileAuthorFeedSubRequests({
authorRl, authorRl,
socialKinds, socialKinds,
includeAuthorLocalRelays, includeAuthorLocalRelays,
kinds kinds,
useGlobalRelayBootstrap
) )
setFullUrls(full) setFullUrls(full)
}) })
@ -109,7 +113,7 @@ export function useProfileAuthorFeedSubRequests({
// `relayListsKey` already fingerprints `favoriteRelays` + `blockedRelays` by sorted URL content. // `relayListsKey` already fingerprints `favoriteRelays` + `blockedRelays` by sorted URL content.
// Do not list those arrays here: the provider often hands new `[]` references each render and would // Do not list those arrays here: the provider often hands new `[]` references each render and would
// retrigger this effect forever (setState → re-render → new refs → effect → …). // retrigger this effect forever (setState → re-render → new refs → effect → …).
}, [pubkey, relayListsKey, kindsKey, kinds, refreshToken, includeAuthorLocalRelays]) }, [pubkey, relayListsKey, kindsKey, kinds, refreshToken, includeAuthorLocalRelays, useGlobalRelayBootstrap])
const activeUrls = fullUrls?.length ? fullUrls : provisionalUrls const activeUrls = fullUrls?.length ? fullUrls : provisionalUrls

4
src/hooks/useProfilePins.tsx

@ -1,4 +1,5 @@
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults'
import { import {
buildAuthorInboxOutboxRelayUrls, buildAuthorInboxOutboxRelayUrls,
buildProfileAugmentedReadRelayUrls, buildProfileAugmentedReadRelayUrls,
@ -79,6 +80,7 @@ function blockedRelaysContentKey(blockedRelays: string[]): string {
export function useProfilePins(pubkey: string | undefined) { export function useProfilePins(pubkey: string | undefined) {
const nostr = useNostrOptional() const nostr = useNostrOptional()
const { blockedRelays } = useFavoriteRelays() const { blockedRelays } = useFavoriteRelays()
const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults()
const blockedKey = useMemo(() => blockedRelaysContentKey(blockedRelays), [blockedRelays]) const blockedKey = useMemo(() => blockedRelaysContentKey(blockedRelays), [blockedRelays])
const includeAuthorLocalRelays = useMemo(() => { const includeAuthorLocalRelays = useMemo(() => {
const me = nostr?.pubkey?.trim() const me = nostr?.pubkey?.trim()
@ -179,7 +181,7 @@ export function useProfilePins(pubkey: string | undefined) {
client.fetchPinListEvent(pk).catch(() => undefined) client.fetchPinListEvent(pk).catch(() => undefined)
]) ])
const authorRelays = buildAuthorInboxOutboxRelayUrls(authorRl, blockedRelays, includeAuthorLocalRelays) const authorRelays = buildAuthorInboxOutboxRelayUrls(authorRl, blockedRelays, includeAuthorLocalRelays)
const pinsResolveRelays = buildProfileAugmentedReadRelayUrls(authorRelays, blockedRelays) const pinsResolveRelays = buildProfileAugmentedReadRelayUrls(authorRelays, blockedRelays, 16, useGlobalRelayBootstrap)
if (!pinsResolveRelays.length) { if (!pinsResolveRelays.length) {
if (!paintedLocalPins) setPinEvents([]) if (!paintedLocalPins) setPinEvents([])
return return

10
src/hooks/useProfileTimeline.tsx

@ -3,6 +3,7 @@ import client, { eventService } from '@/services/client.service'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Event, kinds as nostrKinds, type Filter } from 'nostr-tools' import { Event, kinds as nostrKinds, type Filter } from 'nostr-tools'
import { CALENDAR_EVENT_KINDS, ExtendedKind, isDocumentRelayKind, isSocialKindBlockedKind } from '@/constants' import { CALENDAR_EVENT_KINDS, ExtendedKind, isDocumentRelayKind, isSocialKindBlockedKind } from '@/constants'
import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults'
import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays' import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays'
import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey'
import { normalizeAnyRelayUrl, subtractNormalizedRelayUrls } from '@/lib/url' import { normalizeAnyRelayUrl, subtractNormalizedRelayUrls } from '@/lib/url'
@ -130,6 +131,7 @@ export function useProfileTimeline({
}: UseProfileTimelineOptions): UseProfileTimelineResult { }: UseProfileTimelineOptions): UseProfileTimelineResult {
const nostr = useNostrOptional() const nostr = useNostrOptional()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults()
const includeAuthorLocalRelays = useMemo(() => { const includeAuthorLocalRelays = useMemo(() => {
const me = nostr?.pubkey?.trim() const me = nostr?.pubkey?.trim()
if (!me) return false if (!me) return false
@ -311,7 +313,8 @@ export function useProfileTimeline({
emptyAuthor, emptyAuthor,
socialKinds, socialKinds,
includeAuthorLocalRelays, includeAuthorLocalRelays,
kinds kinds,
useGlobalRelayBootstrap
) )
const startWave = async (subRequests: ReturnType<typeof buildSubRequests>) => { const startWave = async (subRequests: ReturnType<typeof buildSubRequests>) => {
@ -406,7 +409,8 @@ export function useProfileTimeline({
authorRl, authorRl,
socialKinds, socialKinds,
includeAuthorLocalRelays, includeAuthorLocalRelays,
kinds kinds,
useGlobalRelayBootstrap
) )
const deltaUrls = subtractNormalizedRelayUrls(fullFeedUrls, provisionalFeedUrls) const deltaUrls = subtractNormalizedRelayUrls(fullFeedUrls, provisionalFeedUrls)
if (cancelled || deltaUrls.length === 0) return if (cancelled || deltaUrls.length === 0) return
@ -439,7 +443,7 @@ export function useProfileTimeline({
subscriptionRef.current() subscriptionRef.current()
subscriptionRef.current = () => {} subscriptionRef.current = () => {}
} }
}, [pubkey, cacheKey, JSON.stringify(kinds), limit, refreshToken, relayListsKey, includeAuthorLocalRelays]) }, [pubkey, cacheKey, JSON.stringify(kinds), limit, refreshToken, relayListsKey, includeAuthorLocalRelays, useGlobalRelayBootstrap])
const refresh = useCallback(() => { const refresh = useCallback(() => {
subscriptionRef.current() subscriptionRef.current()

14
src/lib/account-list-relay-urls.ts

@ -1,6 +1,7 @@
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { buildPrioritizedReadRelayUrls, buildPrioritizedWriteRelayUrls } from '@/lib/relay-url-priority' import { buildPrioritizedReadRelayUrls, buildPrioritizedWriteRelayUrls } from '@/lib/relay-url-priority'
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import client from '@/services/client.service' import client from '@/services/client.service'
/** /**
@ -14,21 +15,28 @@ export async function buildAccountListRelayUrlsForMerge(options: {
}): Promise<string[]> { }): Promise<string[]> {
const { accountPubkey, favoriteRelays, blockedRelays } = options const { accountPubkey, favoriteRelays, blockedRelays } = options
const myRelayList = await client.fetchRelayList(accountPubkey) const myRelayList = await client.fetchRelayList(accountPubkey)
const favoritesTier = getFavoritesFeedRelayUrls(favoriteRelays ?? [], blockedRelays) const useGlobal = viewerUsesGlobalRelayDefaults({
viewerPubkey: accountPubkey,
favoriteRelayUrls: favoriteRelays ?? [],
relayList: myRelayList
})
const favoritesTier = getFavoritesFeedRelayUrls(favoriteRelays ?? [], blockedRelays, useGlobal)
const read = buildPrioritizedReadRelayUrls({ const read = buildPrioritizedReadRelayUrls({
userReadRelays: myRelayList.read ?? [], userReadRelays: myRelayList.read ?? [],
userWriteRelays: myRelayList.write ?? [], userWriteRelays: myRelayList.write ?? [],
favoriteRelays: favoritesTier, favoriteRelays: favoritesTier,
blockedRelays, blockedRelays,
maxRelays: 100, maxRelays: 100,
applySocialKindBlockedFilter: false applySocialKindBlockedFilter: false,
includeGlobalFastRead: useGlobal
}) })
const write = buildPrioritizedWriteRelayUrls({ const write = buildPrioritizedWriteRelayUrls({
userWriteRelays: myRelayList.write ?? [], userWriteRelays: myRelayList.write ?? [],
favoriteRelays: favoritesTier, favoriteRelays: favoritesTier,
blockedRelays, blockedRelays,
maxRelays: 100, maxRelays: 100,
applySocialKindBlockedFilter: false applySocialKindBlockedFilter: false,
includeGlobalFastWriteReadTails: useGlobal
}) })
const merged = [...read, ...write] const merged = [...read, ...write]
return [...new Set(merged.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean))] return [...new Set(merged.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean))]

76
src/lib/event-metadata.ts

@ -16,8 +16,30 @@ const emptyHttpRelayListFields = {
httpOriginalRelays: [] as TMailboxRelay[] httpOriginalRelays: [] as TMailboxRelay[]
} }
export function getRelayListFromEvent(event?: Event | null, blockedRelays?: string[]) { export type GetRelayListFromEventOptions = {
/**
* When false, never substitute {@link FAST_READ_RELAY_URLS} / {@link FAST_WRITE_RELAY_URLS} for missing or
* oversized lists (use `[]` or the first 8 entries instead). Default true for anonymous / bootstrap callers.
*/
globalReadWriteFallback?: boolean
}
export function getRelayListFromEvent(
event?: Event | null,
blockedRelays?: string[],
options?: GetRelayListFromEventOptions
) {
const globalFb = options?.globalReadWriteFallback !== false
if (!event) { if (!event) {
if (!globalFb) {
return {
write: [] as string[],
read: [] as string[],
originalRelays: [] as TRelayList['originalRelays'],
...emptyHttpRelayListFields
}
}
return { return {
write: FAST_WRITE_RELAY_URLS, write: FAST_WRITE_RELAY_URLS,
read: FAST_READ_RELAY_URLS, read: FAST_READ_RELAY_URLS,
@ -61,14 +83,62 @@ export function getRelayListFromEvent(event?: Event | null, blockedRelays?: stri
// If there are too many relays, use the default inbox/outbox relays. // If there are too many relays, use the default inbox/outbox relays.
// Because they don't know anything about relays, their settings cannot be trusted // Because they don't know anything about relays, their settings cannot be trusted
const readOut =
relayList.read.length && relayList.read.length <= 8
? relayList.read
: globalFb
? FAST_READ_RELAY_URLS
: relayList.read.slice(0, 8)
const writeOut =
relayList.write.length && relayList.write.length <= 8
? relayList.write
: globalFb
? FAST_WRITE_RELAY_URLS
: relayList.write.slice(0, 8)
return { return {
write: relayList.write.length && relayList.write.length <= 8 ? relayList.write : FAST_WRITE_RELAY_URLS, write: writeOut,
read: relayList.read.length && relayList.read.length <= 8 ? relayList.read : FAST_READ_RELAY_URLS, read: readOut,
originalRelays: relayList.originalRelays, originalRelays: relayList.originalRelays,
...emptyHttpRelayListFields ...emptyHttpRelayListFields
} }
} }
/**
* Read-side `r` tags from a relay list event (e.g. kind 10012) without {@link FAST_READ_RELAY_URLS} fallback
* when the list is empty or oversized for strict viewer-owned REQ stacks (relay pulse).
*/
export function getRelayListReadFromEventNoFastFallback(
event: Event | null | undefined,
blockedRelays?: string[]
): string[] {
if (!event) return []
const torBrowserDetected = isTorBrowser()
const normalizedBlockedRelays = (blockedRelays || []).map((url) => normalizeUrl(url) || url)
const read: string[] = []
event.tags.filter(tagNameEquals('r')).forEach(([, url, type]) => {
if (!url || typeof url !== 'string' || url.trim() === '' || url === 'ws://' || url === 'wss://') return
if (!isWebsocketUrl(url)) return
const normalizedUrl = normalizeUrl(url)
if (!normalizedUrl) return
if (normalizedBlockedRelays.includes(normalizedUrl)) return
if (normalizedUrl.endsWith('.onion/') && !torBrowserDetected) return
if (type === 'write') return
if (type === 'read') {
read.push(normalizedUrl)
} else {
read.push(normalizedUrl)
}
})
if (read.length === 0) return []
if (read.length <= 8) return read
return read.slice(0, 8)
}
/** Kind 10243: `r` tags with http(s) URLs only; same read/write/both semantics as NIP-65. */ /** Kind 10243: `r` tags with http(s) URLs only; same read/write/both semantics as NIP-65. */
export function getHttpRelayListFromEvent(event?: Event | null, blockedRelays?: string[]) { export function getHttpRelayListFromEvent(event?: Event | null, blockedRelays?: string[]) {
const out = { const out = {

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

@ -18,6 +18,7 @@ import {
} from '@/lib/relay-url-priority' } from '@/lib/relay-url-priority'
import { feedRelayPolicyUrls, type FeedRelayLayer } from '@/features/feed/relay-policy' import { feedRelayPolicyUrls, type FeedRelayLayer } from '@/features/feed/relay-policy'
import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize' import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize'
import { profileFetchRelayUrlsWithoutFastReadLayer } from '@/lib/viewer-relay-defaults'
const blockedSet = (blockedRelays: string[]) => const blockedSet = (blockedRelays: string[]) =>
new Set(blockedRelays.map((b) => normalizeAnyRelayUrl(b) || b)) new Set(blockedRelays.map((b) => normalizeAnyRelayUrl(b) || b))
@ -25,8 +26,8 @@ const blockedSet = (blockedRelays: string[]) =>
/** /**
* Logged-in users favorite relays (kind 10012 `relay` tags via {@link useFavoriteRelays}, plus bootstrap defaults * Logged-in users favorite relays (kind 10012 `relay` tags via {@link useFavoriteRelays}, plus bootstrap defaults
* when the event is missing): drop blocked, dedupe, normalize. If no non-blocked entries remain, use * when the event is missing): drop blocked, dedupe, normalize. If no non-blocked entries remain, use
* {@link DEFAULT_FAVORITE_RELAYS}. Same list drives the favorites tier in REQ/publish prioritization and the * {@link DEFAULT_FAVORITE_RELAYS} only when `useGlobalFavoriteDefaults` is true (signed-out or no NIP-65 and no favorites).
* all-favorites home feed. * Same list drives the favorites tier in REQ/publish prioritization and the all-favorites home feed.
*/ */
/** /**
* NIP-65 `read` plus HTTP index inboxes (kind 10243) for feed REQ / query URL lists. * NIP-65 `read` plus HTTP index inboxes (kind 10243) for feed REQ / query URL lists.
@ -41,14 +42,15 @@ export function userReadRelaysWithHttp(
export function getFavoritesFeedRelayUrls( export function getFavoritesFeedRelayUrls(
favoriteRelays: string[], favoriteRelays: string[],
blockedRelays: string[] blockedRelays: string[],
useGlobalFavoriteDefaults = true
): string[] { ): string[] {
const blocked = blockedSet(blockedRelays) const blocked = blockedSet(blockedRelays)
const visible = favoriteRelays.filter((r) => { const visible = favoriteRelays.filter((r) => {
const k = normalizeAnyRelayUrl(r) || r const k = normalizeAnyRelayUrl(r) || r
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 : useGlobalFavoriteDefaults ? DEFAULT_FAVORITE_RELAYS : []
return feedRelayPolicyUrls( return feedRelayPolicyUrls(
[{ source: 'favorites', urls: base }], [{ source: 'favorites', urls: base }],
{ {
@ -107,10 +109,14 @@ const PROFILE_AUGMENTED_READ_MAX_RELAYS = 16
export function buildProfileAugmentedReadRelayUrls( export function buildProfileAugmentedReadRelayUrls(
authorRelayUrls: string[], authorRelayUrls: string[],
blockedRelays: string[], blockedRelays: string[],
maxRelays: number = PROFILE_AUGMENTED_READ_MAX_RELAYS maxRelays: number = PROFILE_AUGMENTED_READ_MAX_RELAYS,
useGlobalRelayBootstrap = true
): string[] { ): string[] {
const readOnlyLayer = READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) const readOnlyLayer = READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean)
const fastReadLayer = FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) const fastReadLayer =
useGlobalRelayBootstrap
? (FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[])
: []
const merged = mergeRelayUrlLayers([authorRelayUrls, readOnlyLayer, fastReadLayer], blockedRelays) const merged = mergeRelayUrlLayers([authorRelayUrls, readOnlyLayer, fastReadLayer], blockedRelays)
return merged.slice(0, maxRelays) return merged.slice(0, maxRelays)
} }
@ -127,6 +133,12 @@ export type ReadRelayPriorityOptions = {
* relays in `SOCIAL_KIND_BLOCKED_RELAY_URLS` before capping. * relays in `SOCIAL_KIND_BLOCKED_RELAY_URLS` before capping.
*/ */
applySocialKindBlockedFilter?: boolean applySocialKindBlockedFilter?: boolean
/**
* When false, empty favorites do not fall back to {@link DEFAULT_FAVORITE_RELAYS}. Default true.
*/
useGlobalFavoriteDefaults?: boolean
/** When false, omit the global FAST_READ tier. Default true. */
includeGlobalFastRead?: boolean
} }
/** /**
@ -138,7 +150,9 @@ export function getRelayUrlsWithFavoritesFastReadAndInbox(
userInboxReadRelays: string[], userInboxReadRelays: string[],
options?: ReadRelayPriorityOptions options?: ReadRelayPriorityOptions
): string[] { ): string[] {
const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) const useFavDefaults = options?.useGlobalFavoriteDefaults !== false
const includeFast = options?.includeGlobalFastRead !== false
const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useFavDefaults)
return buildPrioritizedReadRelayUrls({ return buildPrioritizedReadRelayUrls({
userReadRelays: userInboxReadRelays, userReadRelays: userInboxReadRelays,
userWriteRelays: options?.userWriteRelays ?? [], userWriteRelays: options?.userWriteRelays ?? [],
@ -146,7 +160,8 @@ export function getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays: favorites, favoriteRelays: favorites,
blockedRelays, blockedRelays,
maxRelays: options?.maxRelays, maxRelays: options?.maxRelays,
applySocialKindBlockedFilter: options?.applySocialKindBlockedFilter applySocialKindBlockedFilter: options?.applySocialKindBlockedFilter,
includeGlobalFastRead: includeFast
}) })
} }
@ -169,8 +184,11 @@ export function buildProfilePageReadRelayUrls(
kindsIncludeSocialBlockedKind: boolean, kindsIncludeSocialBlockedKind: boolean,
includeAuthorLocalRelays = false, includeAuthorLocalRelays = false,
/** When the timeline includes document kinds (30023, 30040, …), add document index relays and raise the cap. */ /** When the timeline includes document kinds (30023, 30040, …), add document index relays and raise the cap. */
profileKindsHint?: readonly number[] profileKindsHint?: readonly number[],
/** When false, omit global FAST_READ / profile-fetch widening for logged-in users with their own relay stack. */
useGlobalRelayBootstrap?: boolean
): string[] { ): string[] {
const useGlobal = useGlobalRelayBootstrap !== false
const wantsDocumentLayer = profileKindsHint?.some((k) => isDocumentRelayKind(k)) ?? false const wantsDocumentLayer = profileKindsHint?.some((k) => isDocumentRelayKind(k)) ?? false
const maxRelays = wantsDocumentLayer ? PROFILE_PAGE_DOCUMENT_FEED_MAX_RELAYS : PROFILE_PAGE_FEED_MAX_RELAYS const maxRelays = wantsDocumentLayer ? PROFILE_PAGE_DOCUMENT_FEED_MAX_RELAYS : PROFILE_PAGE_FEED_MAX_RELAYS
const list = includeAuthorLocalRelays const list = includeAuthorLocalRelays
@ -180,8 +198,10 @@ export function buildProfilePageReadRelayUrls(
const authorWrite = [...(list.httpWrite ?? []), ...(list.write ?? [])] const authorWrite = [...(list.httpWrite ?? []), ...(list.write ?? [])]
const authorHasNoNip65 = authorRead.length === 0 && authorWrite.length === 0 const authorHasNoNip65 = authorRead.length === 0 && authorWrite.length === 0
const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useGlobal)
const fastReadLayer = FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[] const fastReadLayer = useGlobal
? (FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[])
: []
const authorWriteLayer = relayUrlsLocalsFirst(authorWrite) const authorWriteLayer = relayUrlsLocalsFirst(authorWrite)
const authorReadLayer = relayUrlsLocalsFirst(authorRead) const authorReadLayer = relayUrlsLocalsFirst(authorRead)
const urls = feedRelayPolicyUrls( const urls = feedRelayPolicyUrls(
@ -202,7 +222,8 @@ export function buildProfilePageReadRelayUrls(
) )
/** Authors without kind 10002: widen REQ targets so notes/metadata are still discoverable on index relays. */ /** Authors without kind 10002: widen REQ targets so notes/metadata are still discoverable on index relays. */
if (authorHasNoNip65) { if (authorHasNoNip65) {
const profileFetchLayer = PROFILE_FETCH_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[] const profileSource = useGlobal ? PROFILE_FETCH_RELAY_URLS : profileFetchRelayUrlsWithoutFastReadLayer()
const profileFetchLayer = profileSource.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[]
return mergeRelayUrlLayers([urls, profileFetchLayer], blockedRelays).slice(0, maxRelays + 8) return mergeRelayUrlLayers([urls, profileFetchLayer], blockedRelays).slice(0, maxRelays + 8)
} }
if (wantsDocumentLayer) { if (wantsDocumentLayer) {
@ -235,7 +256,9 @@ export function augmentSubRequestsWithFavoritesFastReadAndInbox(
? options.applySocialKindBlockedFilter ? options.applySocialKindBlockedFilter
: relayFilterIncludesSocialKindBlockedKind(r.filter) : relayFilterIncludesSocialKindBlockedKind(r.filter)
const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) const useFavDefaults = options?.useGlobalFavoriteDefaults !== false
const includeFast = options?.includeGlobalFastRead !== false
const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useFavDefaults)
const authorOnly = dedupeNormalizeRelayUrlsOrdered(options?.authorWriteRelays ?? []) const authorOnly = dedupeNormalizeRelayUrlsOrdered(options?.authorWriteRelays ?? [])
@ -243,7 +266,8 @@ export function augmentSubRequestsWithFavoritesFastReadAndInbox(
userReadRelays: userInboxReadRelays, userReadRelays: userInboxReadRelays,
userWriteRelays: options?.userWriteRelays ?? [], userWriteRelays: options?.userWriteRelays ?? [],
authorWriteRelays: authorOnly, authorWriteRelays: authorOnly,
favoriteRelays: favorites favoriteRelays: favorites,
includeGlobalFastRead: includeFast
}) })
const layers = [relayUrlsLocalsFirst(r.urls), ...coreLayers] const layers = [relayUrlsLocalsFirst(r.urls), ...coreLayers]

102
src/lib/home-feed-relays.ts

@ -1,12 +1,24 @@
import { MAX_REQ_RELAY_URLS } from '@/constants'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { getHttpRelayListFromEvent, getRelayListReadFromEventNoFastFallback } from '@/lib/event-metadata'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr' import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr'
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import type { Event } from 'nostr-tools'
function relayUrlIsNostrLandAggr(url: string): boolean { function relayUrlIsNostrLandAggr(url: string): boolean {
const normalized = (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase() const raw = url.trim()
const aggr = (normalizeAnyRelayUrl(AGGR_NOSTR_LAND_WSS) || AGGR_NOSTR_LAND_WSS).toLowerCase() if (!raw) return false
return normalized === aggr const normalized = (normalizeAnyRelayUrl(raw) || raw).toLowerCase()
const aggrCanon = (normalizeAnyRelayUrl(AGGR_NOSTR_LAND_WSS) || AGGR_NOSTR_LAND_WSS).toLowerCase()
if (normalized === aggrCanon) return true
try {
const u = new URL(normalized)
return u.hostname.toLowerCase() === 'aggr.nostr.land'
} catch {
return /^wss:\/\/aggr\.nostr\.land\/?$/i.test(normalized)
}
} }
/** Drop nostr.land aggregate from REQ stacks where it must not appear (e.g. home feeds). */ /** Drop nostr.land aggregate from REQ stacks where it must not appear (e.g. home feeds). */
@ -14,15 +26,36 @@ export function stripNostrLandAggrFromRelayUrls(urls: readonly string[]): string
return urls.filter((url) => !relayUrlIsNostrLandAggr(url)) return urls.filter((url) => !relayUrlIsNostrLandAggr(url))
} }
/**
* Home Lieblings-Relays feed must never open timeline REQs to nostr.lands aggregate relay (reserved for
* threads / profiles / spells). Strips aggr from every shard after mapping, including trailing-slash variants.
*/
export function stripNostrLandAggrFromTimelineSubRequests<T extends { urls: string[] }>(
feedSubscriptionKey: string | undefined,
requests: readonly T[]
): T[] {
if (feedSubscriptionKey !== 'home-all-favorites') {
return requests.slice() as T[]
}
return requests.map((r) => ({
...r,
urls: stripNostrLandAggrFromRelayUrls(r.urls)
})) as T[]
}
export function buildAllFavoritesFeedRelayUrls( export function buildAllFavoritesFeedRelayUrls(
favoriteRelays: string[], favoriteRelays: string[],
blockedRelays: string[], blockedRelays: string[],
extraFeedRelayUrls: string[] extraFeedRelayUrls: string[],
useGlobalFavoriteDefaults = true
): string[] { ): string[] {
return stripNostrLandAggrFromRelayUrls( return stripNostrLandAggrFromRelayUrls(
feedRelayPolicyUrls( feedRelayPolicyUrls(
[ [
{ source: 'favorites', urls: getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) }, {
source: 'favorites',
urls: getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useGlobalFavoriteDefaults)
},
{ source: 'fallback', urls: extraFeedRelayUrls } { source: 'fallback', urls: extraFeedRelayUrls }
], ],
{ {
@ -35,3 +68,62 @@ export function buildAllFavoritesFeedRelayUrls(
) )
) )
} }
/**
* Relay pulse (sidebar active authors): only the viewers own stack favorites (+ relay sets),
* NIP-65 read, kind 10012 cache read, and HTTP index reads never the global fast-read layer.
*/
export function buildRelayPulseQueryRelayUrls(options: {
viewerPubkey: string | null | undefined
favoriteRelayUrls: string[]
blockedRelays: string[]
relayList: { read?: string[]; httpRead?: string[] } | null | undefined
cacheRelayListEvent: Event | null | undefined
httpRelayListEvent: Event | null | undefined
}): string[] {
const {
viewerPubkey,
favoriteRelayUrls,
blockedRelays,
relayList,
cacheRelayListEvent,
httpRelayListEvent
} = options
const useGlobalFavoriteDefaults = viewerUsesGlobalRelayDefaults({
viewerPubkey,
favoriteRelayUrls,
relayList
})
const primaryRelays = getFavoritesFeedRelayUrls(favoriteRelayUrls, blockedRelays, useGlobalFavoriteDefaults)
const inboxRelayUrls = relayList?.read?.length ? relayList.read : []
const cacheRelayUrls: string[] = []
if (cacheRelayListEvent) {
cacheRelayUrls.push(...getRelayListReadFromEventNoFastFallback(cacheRelayListEvent, blockedRelays))
}
const httpRelayUrls: string[] = [...(relayList?.httpRead ?? [])]
if (httpRelayListEvent) {
httpRelayUrls.push(...getHttpRelayListFromEvent(httpRelayListEvent, blockedRelays).httpRead)
}
return stripNostrLandAggrFromRelayUrls(
feedRelayPolicyUrls(
[
{ source: 'favorites', urls: primaryRelays },
{ source: 'viewer-read', urls: inboxRelayUrls },
{ source: 'cache', urls: cacheRelayUrls },
{ source: 'http-index', urls: httpRelayUrls }
],
{
operation: 'read',
blockedRelays,
nostrLandAggr: 'never',
applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true,
maxRelays: MAX_REQ_RELAY_URLS
}
)
)
}

49
src/lib/live-activities.ts

@ -662,21 +662,31 @@ export function buildLiveActivitiesRelayUrls(options: {
blockedRelays: string[] blockedRelays: string[]
relayListRead: string[] relayListRead: string[]
relayListWrite: string[] relayListWrite: string[]
/**
* When false for a logged-in viewer with their own relay stack, omit {@link FAST_READ_RELAY_URLS} and skip
* {@link DEFAULT_FAVORITE_RELAYS} when favorites are empty. Default true (signed-out / bootstrap).
*/
includeGlobalFastRead?: boolean
}): string[] { }): string[] {
const { loggedIn, favoriteRelays, blockedRelays, relayListRead, relayListWrite } = options const { loggedIn, favoriteRelays, blockedRelays, relayListRead, relayListWrite } = options
const includeFast = options.includeGlobalFastRead !== false
const useGlobalFavoriteDefaults = includeFast
if (loggedIn) { if (loggedIn) {
const fav = relayUrlsLocalsFirst(getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays)) const fav = relayUrlsLocalsFirst(
getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useGlobalFavoriteDefaults)
)
const read = relayUrlsLocalsFirst(relayListRead) const read = relayUrlsLocalsFirst(relayListRead)
const write = relayUrlsLocalsFirst(relayListWrite) const write = relayUrlsLocalsFirst(relayListWrite)
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 feedRelayPolicyUrls([ const layers = [
{ source: 'favorites', urls: fav }, { source: 'favorites' as const, urls: fav },
{ source: 'viewer-read', urls: read }, { source: 'viewer-read' as const, urls: read },
{ source: 'viewer-write', urls: write }, { source: 'viewer-write' as const, urls: write },
{ source: 'fast-read', urls: fast } ...(includeFast ? [{ source: 'fast-read' as const, urls: fast }] : [])
], { ]
return feedRelayPolicyUrls(layers, {
operation: 'read', operation: 'read',
blockedRelays, blockedRelays,
maxRelays: MAX_REQ_RELAY_URLS, maxRelays: MAX_REQ_RELAY_URLS,
@ -684,20 +694,23 @@ export function buildLiveActivitiesRelayUrls(options: {
allowThirdPartyLocalRelays: true allowThirdPartyLocalRelays: true
}) })
} }
const fav = relayUrlsLocalsFirst(getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays)) const fav = relayUrlsLocalsFirst(getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, true))
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 feedRelayPolicyUrls([ return feedRelayPolicyUrls(
{ source: 'favorites', urls: fav }, [
{ source: 'fast-read', urls: fast } { source: 'favorites', urls: fav },
], { { source: 'fast-read', urls: fast }
operation: 'read', ],
blockedRelays, {
maxRelays: MAX_REQ_RELAY_URLS, operation: 'read',
applySocialKindBlockedFilter: true, blockedRelays,
allowThirdPartyLocalRelays: true maxRelays: MAX_REQ_RELAY_URLS,
}) applySocialKindBlockedFilter: true,
allowThirdPartyLocalRelays: true
}
)
} }
/** Milliseconds until the next wall-clock quarter hour (:00, :15, :30, :45). */ /** Milliseconds until the next wall-clock quarter hour (:00, :15, :30, :45). */

66
src/lib/relay-list-builder.ts

@ -11,10 +11,11 @@
import { FAST_READ_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' import { FAST_READ_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { mergeRelayUrlLayers, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize' import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize'
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 { defaultFavoriteRelaysForViewer, viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import client from '@/services/client.service' import client from '@/services/client.service'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
@ -249,21 +250,38 @@ export async function buildExploreProfileAndUserRelayList(
if (!userPubkey) { if (!userPubkey) {
return boot return boot
} }
let useGlobal = true
try {
const [fav, peeked] = await Promise.all([
client.fetchFavoriteRelays(userPubkey).catch(() => [] as string[]),
client.peekRelayListFromStorage(userPubkey).catch(() => null)
])
useGlobal = viewerUsesGlobalRelayDefaults({
viewerPubkey: userPubkey,
favoriteRelayUrls: fav,
relayList: peeked ?? undefined
})
} catch {
useGlobal = true
}
try { try {
const built = await buildComprehensiveRelayList({ const built = await buildComprehensiveRelayList({
userPubkey, userPubkey,
includeUserOwnRelays: true, includeUserOwnRelays: true,
includeProfileFetchRelays: true, includeProfileFetchRelays: true,
includeFastReadRelays: true, includeFastReadRelays: useGlobal,
includeFavoriteRelays: false, includeFavoriteRelays: false,
includeLocalRelays: true, includeLocalRelays: true,
includeFastWriteRelays: false, includeFastWriteRelays: false,
includeSearchableRelays: false includeSearchableRelays: false
}) })
if (!useGlobal) {
return built
}
if (!built.length) return boot if (!built.length) return boot
return dedupeNormalizedRelayUrls([...boot, ...built]) return dedupeNormalizedRelayUrls([...boot, ...built])
} catch { } catch {
return boot return useGlobal ? boot : []
} }
} }
@ -330,6 +348,7 @@ export async function buildPollResultsReadRelayUrls(options: {
let authorReadSlice: string[] = [] let authorReadSlice: string[] = []
let viewerReadSlice: string[] = [] let viewerReadSlice: string[] = []
let useGlobalFastRead = true
try { try {
const [authorRl, viewerRl] = await Promise.all([ const [authorRl, viewerRl] = await Promise.all([
pollEvent.pubkey ? client.peekRelayListFromStorage(pollEvent.pubkey) : Promise.resolve(null), pollEvent.pubkey ? client.peekRelayListFromStorage(pollEvent.pubkey) : Promise.resolve(null),
@ -340,6 +359,11 @@ export async function buildPollResultsReadRelayUrls(options: {
} }
if (viewerRl) { if (viewerRl) {
viewerReadSlice = userReadRelaysWithHttp(viewerRl).slice(0, POLL_RESULTS_NIP65_READ_SLICE) viewerReadSlice = userReadRelaysWithHttp(viewerRl).slice(0, POLL_RESULTS_NIP65_READ_SLICE)
useGlobalFastRead = viewerUsesGlobalRelayDefaults({
viewerPubkey,
favoriteRelayUrls: viewerFavoriteRelayUrls,
relayList: viewerRl
})
} }
} catch { } catch {
/* ignore — poll results still use other layers */ /* ignore — poll results still use other layers */
@ -357,7 +381,9 @@ export async function buildPollResultsReadRelayUrls(options: {
} }
} }
pushLayer([...FAST_READ_RELAY_URLS]) if (useGlobalFastRead) {
pushLayer([...FAST_READ_RELAY_URLS])
}
pushLayer(authorReadSlice) pushLayer(authorReadSlice)
return feedRelayPolicyUrls([{ source: 'fallback', urls: ordered }], { return feedRelayPolicyUrls([{ source: 'fallback', urls: ordered }], {
@ -370,8 +396,8 @@ export async function buildPollResultsReadRelayUrls(options: {
} }
/** /**
* Build relay list for reading replies/comments * Build relay list for reading replies/comments: thread hints, author/user NIP-65, favorites, cache
* READ from: FAST_READ_RELAY_URLS + user's inboxes/outboxes + local relays + OP author's outboxes * then default favorite relays only when global bootstrap applies (signed-out or no configured stack).
*/ */
export async function buildReplyReadRelayList( export async function buildReplyReadRelayList(
opAuthorPubkey: string | undefined, opAuthorPubkey: string | undefined,
@ -379,18 +405,34 @@ export async function buildReplyReadRelayList(
blockedRelays: string[] = [], blockedRelays: string[] = [],
threadRelayHints: string[] = [] threadRelayHints: string[] = []
): Promise<string[]> { ): Promise<string[]> {
return buildComprehensiveRelayList({ let useGlobal = true
if (userPubkey) {
try {
const [fav, rl] = await Promise.all([
client.fetchFavoriteRelays(userPubkey).catch(() => [] as string[]),
client.peekRelayListFromStorage(userPubkey)
])
useGlobal = viewerUsesGlobalRelayDefaults({
viewerPubkey: userPubkey,
favoriteRelayUrls: fav,
relayList: rl ?? undefined
})
} catch {
useGlobal = true
}
}
const scoped = await buildComprehensiveRelayList({
authorPubkey: opAuthorPubkey, authorPubkey: opAuthorPubkey,
userPubkey, userPubkey,
relayHints: threadRelayHints, relayHints: threadRelayHints,
includeUserOwnRelays: Boolean(userPubkey), includeUserOwnRelays: Boolean(userPubkey),
includeFastReadRelays: true, includeFastReadRelays: useGlobal,
includeSearchableRelays: true, includeSearchableRelays: false,
includeLocalRelays: true, includeLocalRelays: true,
/** Same menu list as timelines — threads often opened from favorites. */
includeFavoriteRelays: Boolean(userPubkey), includeFavoriteRelays: Boolean(userPubkey),
/** FAST_READ + SEARCHABLE before author/user NIP-65 slices so broken personal relays do not starve thread REQ under the global connection cap. */ preferPublicReadRelaysEarly: false,
preferPublicReadRelaysEarly: true, includeProfileFetchRelays: useGlobal,
blockedRelays blockedRelays
}) })
return mergeRelayUrlLayers([scoped, defaultFavoriteRelaysForViewer(useGlobal)], blockedRelays)
} }

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

@ -71,6 +71,8 @@ export function buildReadRelayPriorityLayers(opts: {
userWriteRelays?: string[] userWriteRelays?: string[]
authorWriteRelays?: string[] authorWriteRelays?: string[]
favoriteRelays: string[] favoriteRelays: string[]
/** When false, omit the global FAST_READ tier (logged-in users with their own relay stack). Default true. */
includeGlobalFastRead?: boolean
}): string[][] { }): string[][] {
const userWrite = opts.userWriteRelays ?? [] const userWrite = opts.userWriteRelays ?? []
const writeLocals = userWrite.filter((u) => { const writeLocals = userWrite.filter((u) => {
@ -81,7 +83,7 @@ export function buildReadRelayPriorityLayers(opts: {
const tier1 = dedupeNormalizeRelayUrlsOrdered([...writeLocals, ...userReadOrdered]) const tier1 = dedupeNormalizeRelayUrlsOrdered([...writeLocals, ...userReadOrdered])
const tier2 = dedupeNormalizeRelayUrlsOrdered(opts.authorWriteRelays ?? []) const tier2 = dedupeNormalizeRelayUrlsOrdered(opts.authorWriteRelays ?? [])
const tier3 = dedupeNormalizeRelayUrlsOrdered(opts.favoriteRelays ?? []) const tier3 = dedupeNormalizeRelayUrlsOrdered(opts.favoriteRelays ?? [])
const tier4 = normFastRead() const tier4 = opts.includeGlobalFastRead === false ? [] : normFastRead()
return [tier1, tier2, tier3, tier4] return [tier1, tier2, tier3, tier4]
} }
@ -98,6 +100,8 @@ export function buildPrioritizedReadRelayUrls(opts: {
maxRelays?: number maxRelays?: number
/** Default true: strip {@link SOCIAL_KIND_BLOCKED_RELAY_URLS} for social-kind-heavy timelines. Set false for other queries. */ /** Default true: strip {@link SOCIAL_KIND_BLOCKED_RELAY_URLS} for social-kind-heavy timelines. Set false for other queries. */
applySocialKindBlockedFilter?: boolean applySocialKindBlockedFilter?: boolean
/** Default true: append global FAST_READ tier. */
includeGlobalFastRead?: boolean
}): string[] { }): string[] {
const max = opts.maxRelays ?? MAX_REQ_RELAY_URLS const max = opts.maxRelays ?? MAX_REQ_RELAY_URLS
const applySocial = opts.applySocialKindBlockedFilter !== false const applySocial = opts.applySocialKindBlockedFilter !== false
@ -110,7 +114,8 @@ export function buildPrioritizedReadRelayUrls(opts: {
userReadRelays: opts.userReadRelays, userReadRelays: opts.userReadRelays,
userWriteRelays: opts.userWriteRelays, userWriteRelays: opts.userWriteRelays,
authorWriteRelays: opts.authorWriteRelays, authorWriteRelays: opts.authorWriteRelays,
favoriteRelays: opts.favoriteRelays favoriteRelays: opts.favoriteRelays,
includeGlobalFastRead: opts.includeGlobalFastRead
}) })
const policyLayers: FeedRelayLayer[] = [ const policyLayers: FeedRelayLayer[] = [
{ source: 'viewer-read', urls: layers[0] ?? [] }, { source: 'viewer-read', urls: layers[0] ?? [] },
@ -136,11 +141,16 @@ function buildWriteRelayPriorityLayers(opts: {
authorReadRelays?: string[] authorReadRelays?: string[]
favoriteRelays?: string[] favoriteRelays?: string[]
extraRelays?: string[] extraRelays?: string[]
/** When false, omit global FAST_WRITE and FAST_READ tails. Default true. */
includeGlobalFastWriteReadTails?: boolean
}): string[][] { }): string[][] {
const tier1 = relayUrlsLocalsFirst(opts.userWriteRelays) const tier1 = relayUrlsLocalsFirst(opts.userWriteRelays)
const tier2 = filterContextAuthorReadRelaysForPublish(opts.authorReadRelays ?? []) const tier2 = filterContextAuthorReadRelaysForPublish(opts.authorReadRelays ?? [])
const tier3 = dedupeNormalizeRelayUrlsOrdered(opts.favoriteRelays ?? []) const tier3 = dedupeNormalizeRelayUrlsOrdered(opts.favoriteRelays ?? [])
const tier4 = dedupeNormalizeRelayUrlsOrdered(opts.extraRelays ?? []) const tier4 = dedupeNormalizeRelayUrlsOrdered(opts.extraRelays ?? [])
if (opts.includeGlobalFastWriteReadTails === false) {
return [tier1, tier2, tier3, tier4, [], []]
}
const tier5 = normFastWrite() const tier5 = normFastWrite()
const tier6 = normFastRead() const tier6 = normFastRead()
return [tier1, tier2, tier3, tier4, tier5, tier6] return [tier1, tier2, tier3, tier4, tier5, tier6]
@ -158,13 +168,16 @@ export function buildPrioritizedWriteRelayUrls(opts: {
maxRelays?: number maxRelays?: number
/** When true, strip {@link SOCIAL_KIND_BLOCKED_RELAY_URLS} before capping (social kinds). */ /** When true, strip {@link SOCIAL_KIND_BLOCKED_RELAY_URLS} before capping (social kinds). */
applySocialKindBlockedFilter?: boolean applySocialKindBlockedFilter?: boolean
/** Default true: append FAST_WRITE then FAST_READ tiers. */
includeGlobalFastWriteReadTails?: boolean
}): string[] { }): string[] {
const max = opts.maxRelays ?? MAX_PUBLISH_RELAYS const max = opts.maxRelays ?? MAX_PUBLISH_RELAYS
const layers = buildWriteRelayPriorityLayers({ const layers = buildWriteRelayPriorityLayers({
userWriteRelays: opts.userWriteRelays, userWriteRelays: opts.userWriteRelays,
authorReadRelays: opts.authorReadRelays, authorReadRelays: opts.authorReadRelays,
favoriteRelays: opts.favoriteRelays, favoriteRelays: opts.favoriteRelays,
extraRelays: opts.extraRelays extraRelays: opts.extraRelays,
includeGlobalFastWriteReadTails: opts.includeGlobalFastWriteReadTails
}) })
return feedRelayPolicyUrls([ return feedRelayPolicyUrls([
{ source: 'viewer-write', urls: layers[0] ?? [] }, { source: 'viewer-write', urls: layers[0] ?? [] },

54
src/lib/viewer-relay-defaults.ts

@ -0,0 +1,54 @@
import {
DEFAULT_FAVORITE_RELAYS,
FAST_READ_RELAY_URLS,
PROFILE_FETCH_RELAY_URLS
} from '@/constants'
import { normalizeUrl } from '@/lib/url'
export type ViewerRelayListLike = {
read?: string[] | null
write?: string[] | null
httpRead?: string[] | null
} | null | undefined
/**
* Use {@link DEFAULT_FAVORITE_RELAYS}, {@link FAST_READ_RELAY_URLS}, and {@link FAST_WRITE_RELAY_URLS} only when
* the user is not signed in, or when they are signed in but have configured neither favorite relays nor a NIP-65
* (kind 10002 / HTTP index) relay list. Otherwise REQ/publish stacks should stay on their own relays.
*/
export function viewerUsesGlobalRelayDefaults(args: {
viewerPubkey: string | null | undefined
favoriteRelayUrls: readonly string[]
relayList: ViewerRelayListLike
}): boolean {
if (!args.viewerPubkey?.trim()) return true
const hasFavorites = args.favoriteRelayUrls.some((u) => typeof u === 'string' && u.trim().length > 0)
const rl = args.relayList
const hasNip65 =
(rl?.read?.length ?? 0) > 0 ||
(rl?.write?.length ?? 0) > 0 ||
(rl?.httpRead?.length ?? 0) > 0
return !(hasFavorites || hasNip65)
}
const fastReadKeySet = (): Set<string> => {
const s = new Set<string>()
for (const u of FAST_READ_RELAY_URLS) {
const n = (normalizeUrl(u) || u).toLowerCase()
if (n) s.add(n)
}
return s
}
/** PROFILE_FETCH stack with {@link FAST_READ_RELAY_URLS} entries removed (order preserved). */
export function profileFetchRelayUrlsWithoutFastReadLayer(): string[] {
const drop = fastReadKeySet()
return PROFILE_FETCH_RELAY_URLS.filter((u) => {
const n = (normalizeUrl(u) || u).toLowerCase()
return n && !drop.has(n)
})
}
export function defaultFavoriteRelaysForViewer(useGlobalDefaults: boolean): string[] {
return useGlobalDefaults ? [...DEFAULT_FAVORITE_RELAYS] : []
}

41
src/pages/primary/NoteListPage/RelaysFeed.tsx

@ -1,21 +1,12 @@
import NormalFeed from '@/components/NormalFeed' import NormalFeed from '@/components/NormalFeed'
import type { TNoteListRef } from '@/components/NoteList' import type { TNoteListRef } from '@/components/NoteList'
import { isReplyNoteEvent } from '@/lib/event'
import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr'
import { checkAlgoRelay } from '@/lib/relay' import { checkAlgoRelay } from '@/lib/relay'
import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { useFeed } from '@/providers/feed-context' import { useFeed } from '@/providers/feed-context'
import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
import client from '@/services/client.service'
import relayInfoService from '@/services/relay-info.service' import relayInfoService from '@/services/relay-info.service'
import { kinds, type Event } from 'nostr-tools' import { kinds } from 'nostr-tools'
import React, { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' import React, { forwardRef, useEffect, useMemo, useState } from 'react'
const AGGR_RELAY_KEY = (normalizeAnyRelayUrl(AGGR_NOSTR_LAND_WSS) || AGGR_NOSTR_LAND_WSS).toLowerCase()
function relaySeenKey(url: string): string {
return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase()
}
const RelaysFeed = forwardRef< const RelaysFeed = forwardRef<
TNoteListRef, TNoteListRef,
@ -106,28 +97,6 @@ const RelaysFeed = forwardRef<
} }
] ]
}, [canRenderFeed, replyRelayUrls, relayUrls, defaultKinds]) }, [canRenderFeed, replyRelayUrls, relayUrls, defaultKinds])
const hideAggrOnlyMainFeedEvent = useCallback(
(event: Event) => {
const seenRelays = client.getSeenEventRelayUrls(event.id).map(relaySeenKey)
if (!seenRelays.includes(AGGR_RELAY_KEY)) return false
const allowedRelays = new Set(relayUrls.map(relaySeenKey))
return !seenRelays.some((relay) => relay !== AGGR_RELAY_KEY && allowedRelays.has(relay))
},
[relayUrls]
)
const hideAggrOnlyReplyGalleryStackEvent = useCallback(
(event: Event) => {
const seenRelays = client.getSeenEventRelayUrls(event.id).map(relaySeenKey)
if (!seenRelays.includes(AGGR_RELAY_KEY)) return false
const allowedRelays = new Set(replyRelayUrls.map(relaySeenKey))
return !seenRelays.some((relay) => relay !== AGGR_RELAY_KEY && allowedRelays.has(relay))
},
[replyRelayUrls]
)
const hideAggrOnlyNonReplyEvent = useCallback(
(event: Event) => hideAggrOnlyMainFeedEvent(event) && !isReplyNoteEvent(event),
[hideAggrOnlyMainFeedEvent]
)
if (!canRenderFeed) { if (!canRenderFeed) {
return null return null
@ -150,10 +119,6 @@ const RelaysFeed = forwardRef<
feedTimelineScopeKey="all-favorites" feedTimelineScopeKey="all-favorites"
showFeedClientFilter showFeedClientFilter
hostPrimaryPageName="feed" hostPrimaryPageName="feed"
extraShouldHideEvent={hideAggrOnlyMainFeedEvent}
extraShouldHideGalleryEvent={hideAggrOnlyReplyGalleryStackEvent}
extraShouldHideRepliesEvent={hideAggrOnlyNonReplyEvent}
timelinePublicReadFallback
/> />
) )
}) })

7
src/pages/primary/SpellsPage/CreateSpellDialog.tsx

@ -18,6 +18,7 @@ import {
dedupeAppendIds, dedupeAppendIds,
resolveSpellListATags resolveSpellListATags
} from '@/lib/spell-list-import' } from '@/lib/spell-list-import'
import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults'
import { useBookmarks } from '@/providers/bookmarks-context' import { useBookmarks } from '@/providers/bookmarks-context'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
@ -294,6 +295,7 @@ export default function CreateSpellDialog({
const { pubkey, publish, checkLogin, relayList } = useNostr() const { pubkey, publish, checkLogin, relayList } = useNostr()
const { addBookmark, removeBookmark } = useBookmarks() const { addBookmark, removeBookmark } = useBookmarks()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults()
const [form, setForm] = useState<TSpellDraftParams>(DEFAULT_PARAMS) const [form, setForm] = useState<TSpellDraftParams>(DEFAULT_PARAMS)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const scrollBodyRef = useRef<HTMLDivElement>(null) const scrollBodyRef = useRef<HTMLDivElement>(null)
@ -325,7 +327,8 @@ export default function CreateSpellDialog({
setForm(draft) setForm(draft)
setListImportNotices(notices) setListImportNotices(notices)
const urls = getRelaysForSpellCatalogSync(favoriteRelays, blockedRelays, userReadRelaysWithHttp(relayList), { const urls = getRelaysForSpellCatalogSync(favoriteRelays, blockedRelays, userReadRelaysWithHttp(relayList), {
userWriteRelays: relayList?.write ?? [] userWriteRelays: relayList?.write ?? [],
useGlobalRelayBootstrap
}) })
if (pendingATags.length === 0) return if (pendingATags.length === 0) return
void resolveSpellListATags(pendingATags, urls).then(({ ids, notices: extra }) => { void resolveSpellListATags(pendingATags, urls).then(({ ids, notices: extra }) => {
@ -335,7 +338,7 @@ export default function CreateSpellDialog({
if (extra.length) setListImportNotices((n) => [...n, ...extra]) if (extra.length) setListImportNotices((n) => [...n, ...extra])
}) })
}, },
[favoriteRelays, blockedRelays, relayList] [favoriteRelays, blockedRelays, relayList, useGlobalRelayBootstrap]
) )
const handleLoadManualList = useCallback(async () => { const handleLoadManualList = useCallback(async () => {

8
src/pages/primary/SpellsPage/index.tsx

@ -17,6 +17,7 @@ import {
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer'
import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults'
import PrimaryPageLayout, { type TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout, { type TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout'
import { usePrimaryPage } from '@/contexts/primary-page-context' import { usePrimaryPage } from '@/contexts/primary-page-context'
import logger from '@/lib/logger' import logger from '@/lib/logger'
@ -89,6 +90,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
const { hideUntrustedNotifications } = useUserTrust() const { hideUntrustedNotifications } = useUserTrust()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults()
const { const {
showKinds: kindFilterShowKinds, showKinds: kindFilterShowKinds,
showKind1OPs, showKind1OPs,
@ -369,7 +371,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
} }
const urls = getRelaysForSpellCatalogSync(favoriteRelays, blockedRelays, userReadRelaysWithHttp(relayList), { const urls = getRelaysForSpellCatalogSync(favoriteRelays, blockedRelays, userReadRelaysWithHttp(relayList), {
userWriteRelays: relayList?.write ?? [] userWriteRelays: relayList?.write ?? [],
useGlobalRelayBootstrap
}) })
const catalogAuthors = buildSpellCatalogAuthors(pubkey, contacts) const catalogAuthors = buildSpellCatalogAuthors(pubkey, contacts)
const authorAllowlist = new Set(catalogAuthors) const authorAllowlist = new Set(catalogAuthors)
@ -484,7 +487,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
relayMailboxStableKey, relayMailboxStableKey,
loadSpells, loadSpells,
contactsSyncKey, contactsSyncKey,
spellCatalogManualRefreshKey spellCatalogManualRefreshKey,
useGlobalRelayBootstrap
]) ])
useEffect(() => { useEffect(() => {

17
src/pages/secondary/NoteListPage/index.tsx

@ -14,6 +14,7 @@ import {
getRelayUrlsWithFavoritesFastReadAndInbox, getRelayUrlsWithFavoritesFastReadAndInbox,
userReadRelaysWithHttp userReadRelaysWithHttp
} from '@/lib/favorites-feed-relays' } from '@/lib/favorites-feed-relays'
import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { toProfileList } from '@/lib/link' import { toProfileList } from '@/lib/link'
import { import {
@ -47,6 +48,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { relayList, pubkey } = useNostr() const { relayList, pubkey } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults()
const interestList = useInterestListOptional() const interestList = useInterestListOptional()
const isSubscribed = interestList?.isSubscribed ?? (() => false) const isSubscribed = interestList?.isSubscribed ?? (() => false)
const subscribe = interestList?.subscribe ?? (async () => {}) const subscribe = interestList?.subscribe ?? (async () => {})
@ -109,7 +111,9 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
.filter((k) => !isNaN(k)) .filter((k) => !isNaN(k))
const readUrlOpts = { const readUrlOpts = {
userWriteRelays: relayList?.write ?? [], userWriteRelays: relayList?.write ?? [],
applySocialKindBlockedFilter: kinds.length === 0 || kinds.some(isSocialKindBlockedKind) applySocialKindBlockedFilter: kinds.length === 0 || kinds.some(isSocialKindBlockedKind),
useGlobalFavoriteDefaults: useGlobalRelayBootstrap,
includeGlobalFastRead: useGlobalRelayBootstrap
} }
const hashtag = searchParams.get('t') const hashtag = searchParams.get('t')
const searchFromUrl = searchParams.get('s') const searchFromUrl = searchParams.get('s')
@ -205,7 +209,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
userReadRelaysWithHttp(relayList), userReadRelaysWithHttp(relayList),
{ userWriteRelays: relayList?.write ?? [] } { userWriteRelays: relayList?.write ?? [], useGlobalFavoriteDefaults: useGlobalRelayBootstrap, includeGlobalFastRead: useGlobalRelayBootstrap }
) )
} }
]) ])
@ -237,7 +241,11 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
userReadRelaysWithHttp(relayList), userReadRelaysWithHttp(relayList),
{ userWriteRelays: relayList?.write ?? [] } {
userWriteRelays: relayList?.write ?? [],
useGlobalFavoriteDefaults: useGlobalRelayBootstrap,
includeGlobalFastRead: useGlobalRelayBootstrap
}
) )
) )
setControls( setControls(
@ -294,7 +302,8 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
t, t,
isSubscribed, isSubscribed,
subscribe, subscribe,
client client,
useGlobalRelayBootstrap
]) ])
// Initialize on mount // Initialize on mount

149
src/providers/FavoriteRelaysActivityProvider.tsx

@ -1,8 +1,7 @@
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { ExtendedKind, NIP71_VIDEO_KINDS } from '@/constants' import { ExtendedKind, NIP71_VIDEO_KINDS } from '@/constants'
import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { buildRelayPulseQueryRelayUrls } from '@/lib/home-feed-relays'
import { buildLiveActivitiesRelayUrls } from '@/lib/live-activities'
import { import {
readRelayPulseActiveNpubsCache, readRelayPulseActiveNpubsCache,
writeRelayPulseActiveNpubsCache writeRelayPulseActiveNpubsCache
@ -25,9 +24,14 @@ import {
const ACTIVE_WINDOW_SEC = 3600 const ACTIVE_WINDOW_SEC = 3600
/** Recent slice (seconds): newest notes dominate global REQ limits; a shorter window improves author diversity. */ /** Recent slice (seconds): newest notes dominate global REQ limits; a shorter window improves author diversity. */
const PULSE_RECENT_TAIL_SEC = 1200 const PULSE_RECENT_TAIL_SEC = 1200
/** Per-REQ event cap; two time slices run in parallel and merge (see {@link fetchRelayPulseNoteEvents}). */ /**
const PULSE_REQ_LIMIT_RECENT = 900 * Per-REQ event caps for the sidebar relay pulse. Keep small: each event is Schnorr-verified on the WebSocket
const PULSE_REQ_LIMIT_EARLIER = 1400 * thread in nostr-tools; limits of 900+1400 caused main-thread timeouts in verifyEvent when relays returned large batches.
*/
const PULSE_REQ_LIMIT_RECENT = 120
const PULSE_REQ_LIMIT_EARLIER = 160
/** Hard cap after merging two slices — enough for pubkey diversity without megabytes of verification work. */
const PULSE_MERGED_EVENT_CAP = 400
const FETCH_RETRY_DELAY_MS = 2500 const FETCH_RETRY_DELAY_MS = 2500
/** Wall-clock cadence while the tab is visible */ /** Wall-clock cadence while the tab is visible */
const POLL_INTERVAL_MS = 60 * 60 * 1000 const POLL_INTERVAL_MS = 60 * 60 * 1000
@ -94,7 +98,9 @@ async function fetchRelayPulseNoteEvents(
for (const r of settled) { for (const r of settled) {
if (r.status === 'fulfilled') merged.push(...r.value) if (r.status === 'fulfilled') merged.push(...r.value)
} }
return mergeRelayPulseEventsById(merged) const deduped = mergeRelayPulseEventsById(merged)
deduped.sort((a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id))
return deduped.slice(0, PULSE_MERGED_EVENT_CAP)
} }
function aggregatePubkeysByRecency(events: { pubkey: string; created_at: number }[]): string[] { function aggregatePubkeysByRecency(events: { pubkey: string; created_at: number }[]): string[] {
@ -139,8 +145,9 @@ function partitionByFollows(orderedPubkeys: string[], followings: string[]) {
} }
export function FavoriteRelaysActivityProvider({ children }: { children: React.ReactNode }) { export function FavoriteRelaysActivityProvider({ children }: { children: React.ReactNode }) {
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays, relaySets } = useFavoriteRelays()
const { pubkey: viewerPubkey, followListEvent, relayList } = useNostr() const { pubkey: viewerPubkey, followListEvent, relayList, cacheRelayListEvent, httpRelayListEvent } =
useNostr()
const followings = useMemo( const followings = useMemo(
() => (followListEvent ? getPubkeysFromPTags(followListEvent.tags) : []), () => (followListEvent ? getPubkeysFromPTags(followListEvent.tags) : []),
[followListEvent] [followListEvent]
@ -160,85 +167,87 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R
orderedPubkeysRef.current = orderedPubkeys orderedPubkeysRef.current = orderedPubkeys
/** After restoring from disk, ignore the first empty network result (timeouts / slow relays), then behave normally. */ /** After restoring from disk, ignore the first empty network result (timeouts / slow relays), then behave normally. */
const skipFirstEmptyNetworkOverwriteRef = useRef(false) const skipFirstEmptyNetworkOverwriteRef = useRef(false)
const favoriteRelayUrlsForPulse = useMemo(
() => [...favoriteRelays, ...relaySets.flatMap((rs) => rs.relayUrls)],
[favoriteRelays, relaySets]
)
const pulseQueryUrls = useMemo( const pulseQueryUrls = useMemo(
() => () =>
buildLiveActivitiesRelayUrls({ buildRelayPulseQueryRelayUrls({
loggedIn: !!viewerPubkey, viewerPubkey,
favoriteRelays, favoriteRelayUrls: favoriteRelayUrlsForPulse,
blockedRelays, blockedRelays,
relayListRead: userReadRelaysWithHttp(relayList), relayList,
relayListWrite: relayList?.write ?? [] cacheRelayListEvent,
httpRelayListEvent
}), }),
[viewerPubkey, favoriteRelays, blockedRelays, relayList] [
viewerPubkey,
favoriteRelayUrlsForPulse,
blockedRelays,
relayList,
cacheRelayListEvent,
httpRelayListEvent
]
) )
const relayKey = useMemo(() => pulseQueryUrls.join('\n'), [pulseQueryUrls]) const relayKey = useMemo(() => pulseQueryUrls.join('\n'), [pulseQueryUrls])
const fetchActive = useCallback( const fetchActive = useCallback(async () => {
async (useDefaultRelays = false) => { const cacheViewer = viewerPubkey ?? storage.getCurrentAccount()?.pubkey ?? null
const cacheViewer = viewerPubkey ?? storage.getCurrentAccount()?.pubkey ?? null const urls = pulseQueryUrls
const urls = useDefaultRelays if (urls.length === 0) {
? buildLiveActivitiesRelayUrls({ setLoading(false)
loggedIn: false, setRelayActivityReady(true)
favoriteRelays: [], const now = Date.now()
blockedRelays, setOrderedPubkeys([])
relayListRead: [], lastCompletedFetchAtRef.current = now
relayListWrite: [] setLastFetchedAtMs(now)
}) writeRelayPulseActiveNpubsCache({
: pulseQueryUrls relayKey,
if (urls.length === 0) { viewerPubkey: cacheViewer,
setLoading(false) orderedPubkeys: [],
setRelayActivityReady(true) lastFetchedAtMs: now
const now = Date.now() })
setOrderedPubkeys([]) return
}
setLoading(true)
const anchorSec = Math.floor(Date.now() / 1000)
try {
const events = await fetchRelayPulseNoteEvents(urls, anchorSec)
const now = Date.now()
const nextPubkeys = aggregatePubkeysByRecency(events)
const prev = orderedPubkeysRef.current
if (
skipFirstEmptyNetworkOverwriteRef.current &&
nextPubkeys.length === 0 &&
prev.length > 0
) {
skipFirstEmptyNetworkOverwriteRef.current = false
logger.debug('[FavoriteRelaysActivity] kept relay pulse from cache; first fetch returned empty')
} else {
skipFirstEmptyNetworkOverwriteRef.current = false
setOrderedPubkeys(nextPubkeys)
lastCompletedFetchAtRef.current = now lastCompletedFetchAtRef.current = now
setLastFetchedAtMs(now) setLastFetchedAtMs(now)
writeRelayPulseActiveNpubsCache({ writeRelayPulseActiveNpubsCache({
relayKey, relayKey,
viewerPubkey: cacheViewer, viewerPubkey: cacheViewer,
orderedPubkeys: [], orderedPubkeys: nextPubkeys,
lastFetchedAtMs: now lastFetchedAtMs: now
}) })
return
} }
setLoading(true) } catch (error) {
const anchorSec = Math.floor(Date.now() / 1000) logger.debug('[FavoriteRelaysActivity] fetch failed', { error })
try { if (pulseQueryUrls.length > 0) {
const events = await fetchRelayPulseNoteEvents(urls, anchorSec) setTimeout(() => void fetchRef.current(), FETCH_RETRY_DELAY_MS)
const now = Date.now()
const nextPubkeys = aggregatePubkeysByRecency(events)
const prev = orderedPubkeysRef.current
if (
skipFirstEmptyNetworkOverwriteRef.current &&
nextPubkeys.length === 0 &&
prev.length > 0
) {
skipFirstEmptyNetworkOverwriteRef.current = false
logger.debug('[FavoriteRelaysActivity] kept relay pulse from cache; first fetch returned empty')
} else {
skipFirstEmptyNetworkOverwriteRef.current = false
setOrderedPubkeys(nextPubkeys)
lastCompletedFetchAtRef.current = now
setLastFetchedAtMs(now)
writeRelayPulseActiveNpubsCache({
relayKey,
viewerPubkey: cacheViewer,
orderedPubkeys: nextPubkeys,
lastFetchedAtMs: now
})
}
} catch (error) {
logger.debug('[FavoriteRelaysActivity] fetch failed', { error, useDefaultRelays })
if (!useDefaultRelays && favoriteRelays.length > 0) {
setTimeout(() => void fetchRef.current(true), FETCH_RETRY_DELAY_MS)
}
} finally {
setLoading(false)
setRelayActivityReady(true)
} }
}, } finally {
[favoriteRelays, blockedRelays, relayKey, viewerPubkey, pulseQueryUrls] setLoading(false)
) setRelayActivityReady(true)
}
}, [relayKey, viewerPubkey, pulseQueryUrls])
const fetchRef = useRef(fetchActive) const fetchRef = useRef(fetchActive)
fetchRef.current = fetchActive fetchRef.current = fetchActive

27
src/providers/FavoriteRelaysProvider.tsx

@ -1,4 +1,5 @@
import { FAST_READ_RELAY_URLS, DEFAULT_FAVORITE_RELAYS } from '@/constants' import { FAST_READ_RELAY_URLS, DEFAULT_FAVORITE_RELAYS } from '@/constants'
import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { createFavoriteRelaysDraftEvent, createBlockedRelaysDraftEvent, createRelaySetDraftEvent } from '@/lib/draft-event' import { createFavoriteRelaysDraftEvent, createBlockedRelaysDraftEvent, createRelaySetDraftEvent } from '@/lib/draft-event'
import { getReplaceableEventIdentifier } from '@/lib/event' import { getReplaceableEventIdentifier } from '@/lib/event'
@ -25,11 +26,9 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
useEffect(() => { useEffect(() => {
if (!favoriteRelaysEvent) { if (!favoriteRelaysEvent) {
/** Curated app defaults for the home feed — same for anonymous and logged-in users until kind 10012 loads. */ let favoriteRelays: string[] = []
const favoriteRelays: string[] = [...DEFAULT_FAVORITE_RELAYS]
if (pubkey) { if (pubkey) {
// Only add stored relay sets if user is logged in
const storedRelaySets = storage.getRelaySets() const storedRelaySets = storage.getRelaySets()
storedRelaySets.forEach(({ relayUrls }) => { storedRelaySets.forEach(({ relayUrls }) => {
relayUrls.forEach((url) => { relayUrls.forEach((url) => {
@ -40,6 +39,15 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
}) })
} }
const useGlobal = viewerUsesGlobalRelayDefaults({
viewerPubkey: pubkey,
favoriteRelayUrls: favoriteRelays,
relayList
})
if (favoriteRelays.length === 0 && useGlobal) {
favoriteRelays = [...DEFAULT_FAVORITE_RELAYS]
}
setFavoriteRelays(favoriteRelays) setFavoriteRelays(favoriteRelays)
setRelaySetEvents([]) setRelaySetEvents([])
return return
@ -82,9 +90,16 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
) )
setRelaySetEvents(storedRelaySetEvents.filter(Boolean) as Event[]) setRelaySetEvents(storedRelaySetEvents.filter(Boolean) as Event[])
const relaySetDiscoverGlobal = viewerUsesGlobalRelayDefaults({
viewerPubkey: pubkey,
favoriteRelayUrls: relays,
relayList
})
const normalizedRelays = [ const normalizedRelays = [
...(relayList?.write ?? []).map(url => normalizeAnyRelayUrl(url) || url), ...(relayList?.write ?? []).map((url) => normalizeAnyRelayUrl(url) || url),
...FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url) ...(relaySetDiscoverGlobal
? FAST_READ_RELAY_URLS.map((url) => normalizeUrl(url) || url)
: [])
] ]
const newRelaySetEvents = await queryService.fetchEvents( const newRelaySetEvents = await queryService.fetchEvents(
Array.from(new Set(normalizedRelays)).slice(0, 5), Array.from(new Set(normalizedRelays)).slice(0, 5),
@ -121,7 +136,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
) )
} }
init() init()
}, [favoriteRelaysEvent, pubkey]) }, [favoriteRelaysEvent, pubkey, relayList])
useEffect(() => { useEffect(() => {
if (!blockedRelaysEvent) { if (!blockedRelaysEvent) {

47
src/providers/FeedProvider.test.ts

@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { FAST_READ_RELAY_URLS } from '@/constants'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr' import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr'
import { buildRelayPulseQueryRelayUrls, buildAllFavoritesFeedRelayUrls, stripNostrLandAggrFromRelayUrls } from '@/lib/home-feed-relays'
import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
import { buildAllFavoritesFeedRelayUrls, stripNostrLandAggrFromRelayUrls } from '@/lib/home-feed-relays' import type { Event } from 'nostr-tools'
describe('home feed relay policy', () => { describe('home feed relay policy', () => {
it('keeps aggr.nostr.land out of the main home feed', () => { it('keeps aggr.nostr.land out of the main home feed', () => {
@ -38,4 +40,47 @@ describe('home feed relay policy', () => {
expect(merged).toContain('wss://relay.example/') expect(merged).toContain('wss://relay.example/')
expect(merged).toContain('wss://inbox.example/') expect(merged).toContain('wss://inbox.example/')
}) })
it('stripNostrLandAggrFromRelayUrls removes aggr with trailing slash and hostname variants', () => {
const stripped = stripNostrLandAggrFromRelayUrls([
'wss://relay.example/',
'wss://aggr.nostr.land/',
AGGR_NOSTR_LAND_WSS,
'wss://AGGR.nostr.land'
])
expect(stripped).toEqual(['wss://relay.example/'])
})
it('relay pulse stack excludes global fast-read and aggr', () => {
const nineReadTags: string[][] = Array.from({ length: 9 }, (_, i) => [
'r',
`wss://many-${i}.example/`,
'read'
])
const oversizedCacheList = {
kind: 10012,
tags: [...nineReadTags],
content: '',
created_at: 0,
pubkey: 'a'.repeat(64),
id: 'b'.repeat(64),
sig: 'c'.repeat(128)
} satisfies Event
const urls = buildRelayPulseQueryRelayUrls({
viewerPubkey: 'd'.repeat(64),
favoriteRelayUrls: ['wss://fav.example/'],
blockedRelays: [],
relayList: { read: ['wss://nip65.example/'], httpRead: ['https://http-index.example/'] },
cacheRelayListEvent: oversizedCacheList,
httpRelayListEvent: null
})
for (const u of FAST_READ_RELAY_URLS) {
expect(urls).not.toContain(u)
}
expect(urls).not.toContain(AGGR_NOSTR_LAND_WSS)
expect(urls).not.toContain('wss://aggr.nostr.land/')
expect(urls.filter((u) => u.startsWith('wss://many-')).length).toBe(8)
})
}) })

42
src/providers/FeedProvider.tsx

@ -1,10 +1,11 @@
import { FAST_READ_RELAY_URLS } from '@/constants' import { DEFAULT_FAVORITE_RELAYS } from '@/constants'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { getRelayListFromEvent, getHttpRelayListFromEvent } from '@/lib/event-metadata' import { getRelayListFromEvent, getHttpRelayListFromEvent } from '@/lib/event-metadata'
import { buildAllFavoritesFeedRelayUrls, stripNostrLandAggrFromRelayUrls } from '@/lib/home-feed-relays' import { buildAllFavoritesFeedRelayUrls, stripNostrLandAggrFromRelayUrls } from '@/lib/home-feed-relays'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { syncViewerRelayStackNostrLandAggrEligible } from '@/lib/nostr-land-relay-eligibility' import { syncViewerRelayStackNostrLandAggrEligible } from '@/lib/nostr-land-relay-eligibility'
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
import { useEffect, useMemo, useState, useCallback, useRef } from 'react' import { useEffect, useMemo, useState, useCallback, useRef } from 'react'
import type { Dispatch, ReactNode, SetStateAction } from 'react' import type { Dispatch, ReactNode, SetStateAction } from 'react'
@ -50,9 +51,19 @@ function buildHomeReplyFeedRelayUrls(
} }
export function FeedProvider({ children }: { children: ReactNode }) { export function FeedProvider({ children }: { children: ReactNode }) {
const { isInitialized, relayList, cacheRelayListEvent, httpRelayListEvent } = useNostr() const { isInitialized, relayList, cacheRelayListEvent, httpRelayListEvent, pubkey } = useNostr()
const { favoriteRelays, blockedRelays, relaySets } = useFavoriteRelays() const { favoriteRelays, blockedRelays, relaySets } = useFavoriteRelays()
const useGlobalRelayDefaults = useMemo(
() =>
viewerUsesGlobalRelayDefaults({
viewerPubkey: pubkey,
favoriteRelayUrls: [...favoriteRelays, ...relaySets.flatMap((relaySet) => relaySet.relayUrls)],
relayList
}),
[pubkey, favoriteRelays, relaySets, relayList]
)
const favoriteFeedRelayUrls = useMemo( const favoriteFeedRelayUrls = useMemo(
() => [...favoriteRelays, ...relaySets.flatMap((relaySet) => relaySet.relayUrls)], () => [...favoriteRelays, ...relaySets.flatMap((relaySet) => relaySet.relayUrls)],
[favoriteRelays, relaySets] [favoriteRelays, relaySets]
@ -68,7 +79,9 @@ export function FeedProvider({ children }: { children: ReactNode }) {
const replyExtraRelayLayers = useMemo(() => { const replyExtraRelayLayers = useMemo(() => {
const cacheRelayUrls: string[] = [] const cacheRelayUrls: string[] = []
if (cacheRelayListEvent) { if (cacheRelayListEvent) {
const list = getRelayListFromEvent(cacheRelayListEvent, blockedRelays) const list = getRelayListFromEvent(cacheRelayListEvent, blockedRelays, {
globalReadWriteFallback: useGlobalRelayDefaults
})
cacheRelayUrls.push(...list.read) cacheRelayUrls.push(...list.read)
} }
@ -79,12 +92,20 @@ export function FeedProvider({ children }: { children: ReactNode }) {
} }
return { return {
inboxRelayUrls: relayList?.read?.length ? relayList.read : FAST_READ_RELAY_URLS, inboxRelayUrls: relayList?.read?.length
outboxRelayUrls: relayList?.write?.length ? relayList.write : FAST_READ_RELAY_URLS, ? relayList.read
: useGlobalRelayDefaults
? DEFAULT_FAVORITE_RELAYS
: [],
outboxRelayUrls: relayList?.write?.length
? relayList.write
: useGlobalRelayDefaults
? DEFAULT_FAVORITE_RELAYS
: [],
cacheRelayUrls, cacheRelayUrls,
httpRelayUrls httpRelayUrls
} }
}, [relayList, cacheRelayListEvent, httpRelayListEvent, blockedRelays]) }, [relayList, cacheRelayListEvent, httpRelayListEvent, blockedRelays, useGlobalRelayDefaults])
/** 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[]>(() =>
@ -124,7 +145,12 @@ export function FeedProvider({ children }: { children: ReactNode }) {
const lastHomeFeedUrlLogRef = useRef({ primary: '', reply: '' }) const lastHomeFeedUrlLogRef = useRef({ primary: '', reply: '' })
const updateFeedRelayUrls = useCallback(() => { const updateFeedRelayUrls = useCallback(() => {
const primaryRelays = buildAllFavoritesFeedRelayUrls(favoriteFeedRelayUrls, blockedRelays, primaryExtraRelayUrls) const primaryRelays = buildAllFavoritesFeedRelayUrls(
favoriteFeedRelayUrls,
blockedRelays,
primaryExtraRelayUrls,
useGlobalRelayDefaults
)
const replyRelays = buildHomeReplyFeedRelayUrls( const replyRelays = buildHomeReplyFeedRelayUrls(
primaryRelays, primaryRelays,
replyExtraRelayLayers.inboxRelayUrls, replyExtraRelayLayers.inboxRelayUrls,
@ -144,7 +170,7 @@ export function FeedProvider({ children }: { children: ReactNode }) {
} }
setUrlStateIfChanged(setRelayUrls, primaryRelays) setUrlStateIfChanged(setRelayUrls, primaryRelays)
setUrlStateIfChanged(setReplyRelayUrls, replyRelays) setUrlStateIfChanged(setReplyRelayUrls, replyRelays)
}, [favoriteFeedRelayUrls, blockedRelays, primaryExtraRelayUrls, replyExtraRelayLayers, setUrlStateIfChanged]) }, [favoriteFeedRelayUrls, blockedRelays, primaryExtraRelayUrls, replyExtraRelayLayers, setUrlStateIfChanged, useGlobalRelayDefaults])
const favoriteRelaysIdentity = useMemo( const favoriteRelaysIdentity = useMemo(
() => () =>

17
src/providers/LiveActivitiesProvider.tsx

@ -10,6 +10,7 @@ import {
} from '@/lib/live-activities' } from '@/lib/live-activities'
import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import client from '@/services/client.service' import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { registerSessionInteractivePrewarmListener } from '@/services/session-interactive-prewarm-bridge' import { registerSessionInteractivePrewarmListener } from '@/services/session-interactive-prewarm-bridge'
@ -29,6 +30,16 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
const showLiveActivitiesBanner = const showLiveActivitiesBanner =
userPrefs?.showLiveActivitiesBanner ?? storage.getShowLiveActivitiesBanner() userPrefs?.showLiveActivitiesBanner ?? storage.getShowLiveActivitiesBanner()
const useGlobalBootstrap = useMemo(
() =>
viewerUsesGlobalRelayDefaults({
viewerPubkey: pubkey,
favoriteRelayUrls: favoriteRelays,
relayList
}),
[pubkey, favoriteRelays, relayList]
)
const [items, setItems] = useState<TLiveActivityItem[]>([]) const [items, setItems] = useState<TLiveActivityItem[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [carouselHiddenAddresses, setCarouselHiddenAddresses] = useState<ReadonlySet<string>>(() => new Set()) const [carouselHiddenAddresses, setCarouselHiddenAddresses] = useState<ReadonlySet<string>>(() => new Set())
@ -50,7 +61,8 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
relayListRead: relayRead, relayListRead: relayRead,
relayListWrite: relayWrite relayListWrite: relayWrite,
includeGlobalFastRead: useGlobalBootstrap
}) })
if (urls.length === 0) { if (urls.length === 0) {
rawItemsRef.current = [] rawItemsRef.current = []
@ -91,7 +103,8 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
blockedRelays, blockedRelays,
relayRead, relayRead,
relayWrite, relayWrite,
followings followings,
useGlobalBootstrap
]) ])
const toggleLiveActivityCarouselHidden = useCallback(async (address: string) => { const toggleLiveActivityCarouselHidden = useCallback(async (address: string) => {

40
src/providers/NostrProvider/index.tsx

@ -25,6 +25,7 @@ import { getLatestEvent, minePow } from '@/lib/event'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { getHttpRelayListFromEvent, getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import { getHttpRelayListFromEvent, getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import { LoginRequiredError } from '@/lib/nostr-errors' import { LoginRequiredError } from '@/lib/nostr-errors'
import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
@ -66,18 +67,33 @@ export { useNostr } from '@/providers/nostr-context'
export type { TNostrContext } from '@/providers/nostr-context' export type { TNostrContext } from '@/providers/nostr-context'
/** Kind 10012 `relay` tags for publish / target-relay prioritization. */ /** Kind 10012 `relay` tags for publish / target-relay prioritization. */
function favoriteRelayUrlsForPublish(favoriteRelaysEvent: Event | null, pubkey: string | null): string[] { function favoriteRelayUrlsForPublish(
if (!favoriteRelaysEvent) { favoriteRelaysEvent: Event | null,
return pubkey ? [...DEFAULT_FAVORITE_RELAYS] : [] pubkey: string | null,
relayList: TRelayList | null | undefined
): string[] {
const urlsFromEvent = (): string[] => {
const urls: string[] = []
if (!favoriteRelaysEvent) return urls
favoriteRelaysEvent.tags.forEach(([name, v]) => {
if (name === 'relay' && v) {
const n = normalizeAnyRelayUrl(v) || v
if (n && !urls.includes(n)) urls.push(n)
}
})
return urls
} }
const urls: string[] = [] const fromEvent = urlsFromEvent()
favoriteRelaysEvent.tags.forEach(([name, v]) => { const useGlobal = viewerUsesGlobalRelayDefaults({
if (name === 'relay' && v) { viewerPubkey: pubkey,
const n = normalizeAnyRelayUrl(v) || v favoriteRelayUrls: fromEvent,
if (n && !urls.includes(n)) urls.push(n) relayList
}
}) })
return urls.length > 0 ? urls : pubkey ? [...DEFAULT_FAVORITE_RELAYS] : [] if (!favoriteRelaysEvent) {
return useGlobal && pubkey ? [...DEFAULT_FAVORITE_RELAYS] : []
}
if (fromEvent.length > 0) return fromEvent
return useGlobal && pubkey ? [...DEFAULT_FAVORITE_RELAYS] : []
} }
function blockedRelayUrlsFromEvent(blockedRelaysEvent: Event | null): string[] { function blockedRelayUrlsFromEvent(blockedRelaysEvent: Event | null): string[] {
@ -1561,7 +1577,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
noteStatsService.beginPublishPriority() noteStatsService.beginPublishPriority()
try { try {
logger.debug('[Publish] Determining target relays...', { kind: event.kind, pubkey: event.pubkey?.substring(0, 8) }) logger.debug('[Publish] Determining target relays...', { kind: event.kind, pubkey: event.pubkey?.substring(0, 8) })
const favoriteRelayUrls = favoriteRelayUrlsForPublish(favoriteRelaysEvent, account.pubkey) const favoriteRelayUrls = favoriteRelayUrlsForPublish(favoriteRelaysEvent, account.pubkey, relayList)
const relays = await client.determineTargetRelays(event, { const relays = await client.determineTargetRelays(event, {
...options, ...options,
favoriteRelayUrls, favoriteRelayUrls,
@ -1686,7 +1702,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
client.interruptBackgroundQueries() client.interruptBackgroundQueries()
// Privacy: Only use user's own relays, never connect to "seen on" relays // Privacy: Only use user's own relays, never connect to "seen on" relays
const favUrls = favoriteRelayUrlsForPublish(favoriteRelaysEvent, account?.pubkey ?? null) const favUrls = favoriteRelayUrlsForPublish(favoriteRelaysEvent, account?.pubkey ?? null, relayList)
const relays = await client.determineTargetRelays(targetEvent, { const relays = await client.determineTargetRelays(targetEvent, {
favoriteRelayUrls: favUrls, favoriteRelayUrls: favUrls,
blockedRelayUrls: blockedRelayUrlsFromEvent(blockedRelaysEvent) blockedRelayUrls: blockedRelayUrlsFromEvent(blockedRelaysEvent)

68
src/services/client.service.ts

@ -36,6 +36,8 @@ import {
SEARCHABLE_RELAY_URLS SEARCHABLE_RELAY_URLS
} from '@/constants' } from '@/constants'
import { profileFetchRelayUrlsWithoutFastReadLayer, viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
/** NIP-01 filter keys only; NIP-50 adds `search` which non-searchable relays reject. */ /** NIP-01 filter keys only; NIP-50 adds `search` which non-searchable relays reject. */
function filterForRelay(f: Filter, relaySupportsSearch: boolean): Filter { function filterForRelay(f: Filter, relaySupportsSearch: boolean): Filter {
if (relaySupportsSearch) return f if (relaySupportsSearch) return f
@ -986,6 +988,14 @@ class ClientService extends EventTarget {
blockedRelays: blockedRelayUrls, blockedRelays: blockedRelayUrls,
applySocialKindBlockedFilter: isSocialKindBlockedKind(event.kind) applySocialKindBlockedFilter: isSocialKindBlockedKind(event.kind)
} }
const policyRelayList = await this.peekRelayListFromStorage(event.pubkey).catch(() =>
this.emptyRelayListForPublish()
)
const useGlobalRelayDefaults = viewerUsesGlobalRelayDefaults({
viewerPubkey: event.pubkey,
favoriteRelayUrls: favoriteRelayUrls ?? [],
relayList: policyRelayList
})
if (event.kind === kinds.RelayList) { if (event.kind === kinds.RelayList) {
logger.info('[DetermineTargetRelays] Determining target relays for relay list event', { logger.info('[DetermineTargetRelays] Determining target relays for relay list event', {
pubkey: event.pubkey, pubkey: event.pubkey,
@ -1023,11 +1033,24 @@ class ClientService extends EventTarget {
} }
if (userWriteRelays.length === 0 && seenRelays.length === 0) { if (userWriteRelays.length === 0 && seenRelays.length === 0) {
if (!useGlobalRelayDefaults) {
return this.filterPublishingRelays(
buildPrioritizedWriteRelayUrls({
userWriteRelays: [],
favoriteRelays: favoriteRelayUrls ?? [],
maxRelays: MAX_PUBLISH_RELAYS,
includeGlobalFastWriteReadTails: false,
...writeRelayPubOpts
}),
event
)
}
return this.filterPublishingRelays( return this.filterPublishingRelays(
buildPrioritizedWriteRelayUrls({ buildPrioritizedWriteRelayUrls({
userWriteRelays: [...FAST_WRITE_RELAY_URLS], userWriteRelays: [...FAST_WRITE_RELAY_URLS],
favoriteRelays: favoriteRelayUrls ?? [], favoriteRelays: favoriteRelayUrls ?? [],
maxRelays: MAX_PUBLISH_RELAYS, maxRelays: MAX_PUBLISH_RELAYS,
includeGlobalFastWriteReadTails: false,
...writeRelayPubOpts ...writeRelayPubOpts
}), }),
event event
@ -1040,6 +1063,7 @@ class ClientService extends EventTarget {
favoriteRelays: favoriteRelayUrls ?? [], favoriteRelays: favoriteRelayUrls ?? [],
extraRelays: seenRelays, extraRelays: seenRelays,
maxRelays: MAX_PUBLISH_RELAYS, maxRelays: MAX_PUBLISH_RELAYS,
includeGlobalFastWriteReadTails: useGlobalRelayDefaults,
...writeRelayPubOpts ...writeRelayPubOpts
}), }),
event event
@ -1073,7 +1097,7 @@ class ClientService extends EventTarget {
.filter((url): url is string => !!url) .filter((url): url is string => !!url)
let authorWrite = dedupeNormalizeRelayUrlsOrdered([...authorHttpWrites, ...authorWsWrites]) let authorWrite = dedupeNormalizeRelayUrlsOrdered([...authorHttpWrites, ...authorWsWrites])
if (authorWrite.length === 0) { if (authorWrite.length === 0) {
authorWrite = [...FAST_WRITE_RELAY_URLS] authorWrite = useGlobalRelayDefaults ? [...FAST_WRITE_RELAY_URLS] : []
} }
let recipientRead: string[] = [] let recipientRead: string[] = []
recipientRead = recipientRelayLists.flatMap((rl) => [ recipientRead = recipientRelayLists.flatMap((rl) => [
@ -1112,6 +1136,9 @@ class ClientService extends EventTarget {
recipientReadCount: recipientRead.length recipientReadCount: recipientRead.length
}) })
if (pubRelays.length > 0) return pubRelays if (pubRelays.length > 0) return pubRelays
if (!useGlobalRelayDefaults) {
return this.filterPublishingRelays([], event)
}
return this.filterPublishingRelays( return this.filterPublishingRelays(
feedRelayPolicyUrls([{ source: 'fast-write', urls: relayUrlsLocalsFirst([...FAST_WRITE_RELAY_URLS]) }], { feedRelayPolicyUrls([{ source: 'fast-write', urls: relayUrlsLocalsFirst([...FAST_WRITE_RELAY_URLS]) }], {
operation: 'write', operation: 'write',
@ -1161,10 +1188,14 @@ class ClientService extends EventTarget {
userWriteRelays: userWriteRelays:
spellWriteFiltered.length > 0 spellWriteFiltered.length > 0
? spellWriteFiltered ? spellWriteFiltered
: dedupeNormalizeRelayUrlsOrdered(FAST_WRITE_RELAY_URLS), : useGlobalRelayDefaults
? dedupeNormalizeRelayUrlsOrdered(FAST_WRITE_RELAY_URLS)
: [],
favoriteRelays: favoriteRelayUrls ?? [], favoriteRelays: favoriteRelayUrls ?? [],
extraRelays: [], extraRelays: [],
maxRelays: MAX_PUBLISH_RELAYS, maxRelays: MAX_PUBLISH_RELAYS,
includeGlobalFastWriteReadTails:
spellWriteFiltered.length > 0 ? useGlobalRelayDefaults : false,
...writeRelayPubOpts ...writeRelayPubOpts
}), }),
event event
@ -1203,23 +1234,33 @@ class ClientService extends EventTarget {
ExtendedKind.RELAY_REVIEW ExtendedKind.RELAY_REVIEW
].includes(event.kind) ].includes(event.kind)
) { ) {
bootstrapExtras.push(...PROFILE_FETCH_RELAY_URLS) bootstrapExtras.push(
...(useGlobalRelayDefaults ? PROFILE_FETCH_RELAY_URLS : profileFetchRelayUrlsWithoutFastReadLayer())
)
logger.debug('[DetermineTargetRelays] Relay list event detected, adding PROFILE_FETCH_RELAY_URLS', { logger.debug('[DetermineTargetRelays] Relay list event detected, adding PROFILE_FETCH_RELAY_URLS', {
kind: event.kind, kind: event.kind,
profileFetchRelays: PROFILE_FETCH_RELAY_URLS, profileFetchRelays: useGlobalRelayDefaults
? PROFILE_FETCH_RELAY_URLS
: profileFetchRelayUrlsWithoutFastReadLayer(),
additionalRelayCount: bootstrapExtras.length additionalRelayCount: bootstrapExtras.length
}) })
} else if (event.kind === ExtendedKind.FAVORITE_RELAYS || event.kind === kinds.Relaysets) { } else if (event.kind === ExtendedKind.FAVORITE_RELAYS || event.kind === kinds.Relaysets) {
// Use fast write relays for favorite-relays and kind 30002 relay-set replaceables to avoid // Use fast write relays for favorite-relays and kind 30002 relay-set replaceables to avoid
// timeouts and auth-only relays dominating the attempt list. // timeouts and auth-only relays dominating the attempt list.
bootstrapExtras.push(...FAST_WRITE_RELAY_URLS) if (useGlobalRelayDefaults) {
bootstrapExtras.push(...FAST_WRITE_RELAY_URLS)
}
logger.debug('[DetermineTargetRelays] Favorite relays or relay set event, adding FAST_WRITE_RELAY_URLS', { logger.debug('[DetermineTargetRelays] Favorite relays or relay set event, adding FAST_WRITE_RELAY_URLS', {
kind: event.kind, kind: event.kind,
fastWriteRelays: FAST_WRITE_RELAY_URLS, fastWriteRelays: FAST_WRITE_RELAY_URLS,
additionalRelayCount: bootstrapExtras.length additionalRelayCount: bootstrapExtras.length
}) })
} else if (event.kind === ExtendedKind.RSS_FEED_LIST) { } else if (event.kind === ExtendedKind.RSS_FEED_LIST) {
bootstrapExtras.push(...FAST_WRITE_RELAY_URLS, ...PROFILE_FETCH_RELAY_URLS) if (useGlobalRelayDefaults) {
bootstrapExtras.push(...FAST_WRITE_RELAY_URLS, ...PROFILE_FETCH_RELAY_URLS)
} else {
bootstrapExtras.push(...profileFetchRelayUrlsWithoutFastReadLayer())
}
} }
if (isDocumentRelayKind(event.kind)) { if (isDocumentRelayKind(event.kind)) {
bootstrapExtras.push(...DOCUMENT_RELAY_URLS) bootstrapExtras.push(...DOCUMENT_RELAY_URLS)
@ -1253,6 +1294,7 @@ class ClientService extends EventTarget {
favoriteRelays: favoriteRelayUrls ?? [], favoriteRelays: favoriteRelayUrls ?? [],
extraRelays: bootstrapExtras, extraRelays: bootstrapExtras,
maxRelays: MAX_PUBLISH_RELAYS, maxRelays: MAX_PUBLISH_RELAYS,
includeGlobalFastWriteReadTails: useGlobalRelayDefaults,
...writeRelayPubOpts ...writeRelayPubOpts
}), }),
event event
@ -1275,12 +1317,14 @@ class ClientService extends EventTarget {
// Fallback for all publishing when no relays (e.g. after cache clear or fetch failure). // Fallback for all publishing when no relays (e.g. after cache clear or fetch failure).
// Use FAST_WRITE_RELAY_URLS so writes always have known-good write relays. // Use FAST_WRITE_RELAY_URLS so writes always have known-good write relays.
if (!relays.length) { if (!relays.length) {
relays = isDocumentRelayKind(event.kind) if (useGlobalRelayDefaults) {
? dedupeNormalizeRelayUrlsOrdered([...FAST_WRITE_RELAY_URLS, ...DOCUMENT_RELAY_URLS]) relays = isDocumentRelayKind(event.kind)
: [...FAST_WRITE_RELAY_URLS] ? dedupeNormalizeRelayUrlsOrdered([...FAST_WRITE_RELAY_URLS, ...DOCUMENT_RELAY_URLS])
logger.info('[DetermineTargetRelays] Using default write relays (no user/extra relays)', { : [...FAST_WRITE_RELAY_URLS]
count: relays.length logger.info('[DetermineTargetRelays] Using default write relays (no user/extra relays)', {
}) count: relays.length
})
}
} }
relays = this.filterPublishingRelays(relays, event) relays = this.filterPublishingRelays(relays, event)

7
src/services/spell.service.ts

@ -87,11 +87,14 @@ export function getRelaysForSpellCatalogSync(
favoriteRelays: string[], favoriteRelays: string[],
blockedRelays: string[], blockedRelays: string[],
userInboxReadRelays: string[], userInboxReadRelays: string[],
options?: { userWriteRelays?: string[] } options?: { userWriteRelays?: string[]; useGlobalRelayBootstrap?: boolean }
): string[] { ): string[] {
const g = options?.useGlobalRelayBootstrap !== false
return getRelayUrlsWithFavoritesFastReadAndInbox(favoriteRelays, blockedRelays, userInboxReadRelays, { return getRelayUrlsWithFavoritesFastReadAndInbox(favoriteRelays, blockedRelays, userInboxReadRelays, {
userWriteRelays: options?.userWriteRelays ?? [], userWriteRelays: options?.userWriteRelays ?? [],
applySocialKindBlockedFilter: false applySocialKindBlockedFilter: false,
useGlobalFavoriteDefaults: g,
includeGlobalFastRead: g
}) })
} }

Loading…
Cancel
Save