From c1f952ed46cb03f3a5e49e734d25ea7367750a02 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 26 May 2026 07:45:24 +0200 Subject: [PATCH] integrate http relays into inboxes and outboxes --- .../Explore/ExploreRelayDirectory.tsx | 11 +- .../Explore/ExploreRelayReviews.tsx | 22 +-- src/components/GifPicker/index.tsx | 8 +- src/components/MemePicker/index.tsx | 10 +- .../PostEditor/PostRelaySelector.tsx | 49 ++--- .../RelayInfo/RelayReviewsPreview.tsx | 9 +- .../RssArticleWebBookmarks/index.tsx | 11 +- src/components/SearchResult/index.tsx | 10 +- .../Sidebar/SidebarCalendarWeekWidget.tsx | 8 +- src/features/feed/relay-policy.ts | 4 +- src/hooks/useFetchCalendarRsvps.tsx | 22 +-- src/hooks/useUserMailboxRelayUrls.ts | 21 ++ src/hooks/useViewerInboxRelayUrls.ts | 7 +- src/lib/account-list-relay-urls.ts | 14 +- src/lib/draft-event.ts | 6 +- src/lib/favorites-feed-relays.ts | 44 ++++- src/lib/private-relays.ts | 76 +++----- src/lib/profile-reports-relays.ts | 3 +- src/lib/public-message-publish-relays.ts | 21 +- src/lib/tombstone-events.ts | 23 +-- src/lib/viewer-read-inboxes.test.ts | 54 ++++++ src/lib/viewer-read-inboxes.ts | 88 +++++++++ src/lib/viewer-write-outboxes.test.ts | 44 +++++ src/lib/viewer-write-outboxes.ts | 77 ++++++++ src/pages/primary/CalendarPrimaryPage.tsx | 8 +- .../primary/SpellsPage/CreateSpellDialog.tsx | 8 +- .../SpellsPage/ProfileInteractionsMap.tsx | 11 +- .../primary/SpellsPage/RelayThreadHeatMap.tsx | 8 +- .../SpellsPage/TopicKeywordHeatMap.tsx | 8 +- src/pages/primary/SpellsPage/index.tsx | 12 +- .../primary/SpellsPage/useSpellsPageFeed.ts | 36 ++-- .../secondary/EmojiSetsSettingsPage/index.tsx | 13 +- .../FollowSetsSettingsPage/index.tsx | 11 +- src/pages/secondary/NoteListPage/index.tsx | 21 +- .../secondary/RelayReviewsPage/index.tsx | 11 +- src/providers/FeedProvider.tsx | 42 ++-- src/providers/LiveActivitiesProvider.tsx | 14 +- src/providers/NostrProvider/index.tsx | 1 + src/services/client.service.ts | 179 ++++++++---------- src/services/relay-selection.service.ts | 101 +++++----- 40 files changed, 672 insertions(+), 454 deletions(-) create mode 100644 src/hooks/useUserMailboxRelayUrls.ts create mode 100644 src/lib/viewer-read-inboxes.test.ts create mode 100644 src/lib/viewer-read-inboxes.ts create mode 100644 src/lib/viewer-write-outboxes.test.ts create mode 100644 src/lib/viewer-write-outboxes.ts diff --git a/src/components/Explore/ExploreRelayDirectory.tsx b/src/components/Explore/ExploreRelayDirectory.tsx index 97b1a06c..c7de7e29 100644 --- a/src/components/Explore/ExploreRelayDirectory.tsx +++ b/src/components/Explore/ExploreRelayDirectory.tsx @@ -15,10 +15,7 @@ import { loadCachedRelayReviews } from '@/lib/explore-relay-reviews' import { getRelayUrlFromRelayReviewEvent } from '@/lib/event-metadata' -import { - getRelayUrlsWithFavoritesFastReadAndInbox, - userReadRelaysWithHttp -} from '@/lib/favorites-feed-relays' +import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' import { toRelay } from '@/lib/link' import { normalizeAnyRelayUrl } from '@/lib/url' import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds' @@ -114,7 +111,7 @@ function ExploreRelayDirectoryCard({ entry }: { entry: ExploreRelayEntry }) { export default function ExploreRelayDirectory({ listFilter = '' }: { listFilter?: string }) { const { t } = useTranslation() - const { pubkey, relayList } = useNostr() + const { pubkey, relayList, cacheRelayListEvent } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const relayInputsKey = useMemo( @@ -127,9 +124,9 @@ export default function ExploreRelayDirectory({ listFilter = '' }: { listFilter? getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - userReadRelaysWithHttp(relayList), + userReadInboxUrls(relayList, cacheRelayListEvent), { - userWriteRelays: relayList?.write ?? [], + userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent), maxRelays: EXPLORE_REVIEWS_MAX_RELAYS, applySocialKindBlockedFilter: false } diff --git a/src/components/Explore/ExploreRelayReviews.tsx b/src/components/Explore/ExploreRelayReviews.tsx index b849458d..69804ac2 100644 --- a/src/components/Explore/ExploreRelayReviews.tsx +++ b/src/components/Explore/ExploreRelayReviews.tsx @@ -8,10 +8,7 @@ import { dedupeRelayReviewsNewestFirst, loadCachedRelayReviews } from '@/lib/explore-relay-reviews' -import { - getRelayUrlsWithFavoritesFastReadAndInbox, - userReadRelaysWithHttp -} from '@/lib/favorites-feed-relays' +import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' import { toRelay } from '@/lib/link' import { isExploreBrowsableRelayUrl } from '@/lib/explore-popular-relays' import { normalizeAnyRelayUrl } from '@/lib/url' @@ -59,7 +56,8 @@ const EXPLORE_REVIEWS_EOSE_TAIL_MS = 4500 function stableRelayInputsKey( favoriteRelays: string[], blockedRelays: string[], - relayList: { read?: string[]; write?: string[]; httpRead?: string[] } | null | undefined + relayList: { read?: string[]; write?: string[]; httpRead?: string[] } | null | undefined, + cacheRelayListEvent: Event | null | undefined ): string { const normSortJoin = (urls: string[]) => [...urls] @@ -70,19 +68,19 @@ function stableRelayInputsKey( return [ normSortJoin(favoriteRelays), normSortJoin(blockedRelays), - normSortJoin([...(relayList?.httpRead ?? []), ...(relayList?.read ?? [])]), - normSortJoin(relayList?.write ?? []) + normSortJoin(userReadInboxUrls(relayList, cacheRelayListEvent)), + normSortJoin(userWriteOutboxUrls(relayList, cacheRelayListEvent)) ].join('::') } export default function ExploreRelayReviews() { const { t } = useTranslation() const { favoriteRelays, blockedRelays } = useFavoriteRelays() - const { relayList } = useNostr() + const { relayList, cacheRelayListEvent } = useNostr() const relayInputsKey = useMemo( - () => stableRelayInputsKey(favoriteRelays, blockedRelays, relayList), - [favoriteRelays, blockedRelays, relayList] + () => stableRelayInputsKey(favoriteRelays, blockedRelays, relayList, cacheRelayListEvent), + [favoriteRelays, blockedRelays, relayList, cacheRelayListEvent] ) const relayUrls = useMemo(() => { @@ -90,9 +88,9 @@ export default function ExploreRelayReviews() { getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - userReadRelaysWithHttp(relayList), + userReadInboxUrls(relayList, cacheRelayListEvent), { - userWriteRelays: relayList?.write ?? [], + userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent), maxRelays: EXPLORE_REVIEWS_MAX_RELAYS, applySocialKindBlockedFilter: false } diff --git a/src/components/GifPicker/index.tsx b/src/components/GifPicker/index.tsx index d03cdb28..3829ec16 100644 --- a/src/components/GifPicker/index.tsx +++ b/src/components/GifPicker/index.tsx @@ -10,7 +10,7 @@ import { Label } from '@/components/ui/label' import { ScrollArea } from '@/components/ui/scroll-area' import { Skeleton } from '@/components/ui/skeleton' import { useScreenSize } from '@/providers/ScreenSizeProvider' -import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' +import { useUserReadInboxUrls, useUserWriteOutboxUrls } from '@/hooks/useUserMailboxRelayUrls' import { useNostr } from '@/providers/NostrProvider' import { ExtendedKind, FAST_WRITE_RELAY_URLS, GIF_RELAY_URLS } from '@/constants' import { cn } from '@/lib/utils' @@ -50,7 +50,7 @@ export default function GifPicker({ }) { const { t } = useTranslation() const { isSmallScreen } = useScreenSize() - const { publish, pubkey, relayList } = useNostr() + const { publish, pubkey } = useNostr() const [open, setOpen] = useState(false) const [searchInput, setSearchInput] = useState('') // Initialise from the module-level session cache so re-opens are instant @@ -70,8 +70,8 @@ export default function GifPicker({ const fileInputRef = useRef(null) const gifbuddyPopupRef = useRef(null) - const userReadRelays = useMemo(() => userReadRelaysWithHttp(relayList), [relayList]) - const userWriteRelays = relayList?.write ?? [] + const userReadRelays = useUserReadInboxUrls() + const userWriteRelays = useUserWriteOutboxUrls() /** Paste / upload: GIF discovery relays + user writes (unchanged). */ const gifPublishRelayUrls = useMemo(() => { diff --git a/src/components/MemePicker/index.tsx b/src/components/MemePicker/index.tsx index 2d899abb..77dae9c8 100644 --- a/src/components/MemePicker/index.tsx +++ b/src/components/MemePicker/index.tsx @@ -10,7 +10,7 @@ import { Label } from '@/components/ui/label' import { ScrollArea } from '@/components/ui/scroll-area' import { Skeleton } from '@/components/ui/skeleton' import { useScreenSize } from '@/providers/ScreenSizeProvider' -import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' +import { useUserReadInboxUrls, useUserWriteOutboxUrls } from '@/hooks/useUserMailboxRelayUrls' import { useNostr } from '@/providers/NostrProvider' import { ExtendedKind, GIF_RELAY_URLS } from '@/constants' import { normalizeUrl } from '@/lib/url' @@ -24,7 +24,7 @@ import { } from '@/services/meme.service' import mediaUpload from '@/services/media-upload.service' import { ExternalLink, X } from 'lucide-react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' /** In-session cache: survives Drawer/Dropdown open↔close without a relay re-fetch. */ let _sessionMemes: MemeMetadata[] = [] @@ -68,7 +68,7 @@ export default function MemePicker({ }) { const { t } = useTranslation() const { isSmallScreen } = useScreenSize() - const { publish, pubkey, relayList } = useNostr() + const { publish, pubkey } = useNostr() const [open, setOpen] = useState(false) const [query, setQuery] = useState('') const [searchInput, setSearchInput] = useState('') @@ -86,8 +86,8 @@ export default function MemePicker({ const fileInputRef = useRef(null) const memeamigoPopupRef = useRef(null) - const userReadRelays = useMemo(() => userReadRelaysWithHttp(relayList), [relayList]) - const userWriteRelays = relayList?.write ?? [] + const userReadRelays = useUserReadInboxUrls() + const userWriteRelays = useUserWriteOutboxUrls() /** Keep memesRef, session cache, and React state in sync. */ const setMemes = useCallback((newMemes: MemeMetadata[], isSearch = false) => { diff --git a/src/components/PostEditor/PostRelaySelector.tsx b/src/components/PostEditor/PostRelaySelector.tsx index 24eee9f9..566d1cbf 100644 --- a/src/components/PostEditor/PostRelaySelector.tsx +++ b/src/components/PostEditor/PostRelaySelector.tsx @@ -1,13 +1,12 @@ -import { ExtendedKind, isSocialKindBlockedKind, MAX_PUBLISH_RELAYS, SOCIAL_KIND_BLOCKED_RELAY_URLS } from '@/constants' +import { isSocialKindBlockedKind, MAX_PUBLISH_RELAYS, SOCIAL_KIND_BLOCKED_RELAY_URLS } from '@/constants' import { NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX } from '@/lib/content-patterns' -import { simplifyUrl, isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' +import { simplifyUrl, isLocalNetworkUrl, normalizeRelayUrlByScheme } from '@/lib/url' +import { collectViewerWriteOutboxUrls } from '@/lib/viewer-write-outboxes' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useNostr } from '@/providers/NostrProvider' -import { getRelayListFromEvent } from '@/lib/event-metadata' -import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' -import indexedDb from '@/services/indexed-db.service' +import { userReadInboxUrls } from '@/lib/favorites-feed-relays' import { Check, ChevronDown, Server } from 'lucide-react' import { NostrEvent } from 'nostr-tools' import { Dispatch, SetStateAction, useCallback, useEffect, useState, useMemo, useRef } from 'react' @@ -25,7 +24,7 @@ const NO_MENTIONS: string[] = [] /** Keep auto-selection within {@link MAX_PUBLISH_RELAYS}, preserving {@link selectableRelaysOrder} (top of list first). */ function capAutoSelectedRelays(selectableRelaysOrder: string[], selectedWithCache: string[]): string[] { - const norm = (u: string) => normalizeAnyRelayUrl(u) || u + const norm = (u: string) => normalizeRelayUrlByScheme(u) || u const selectedNormSet = new Set(selectedWithCache.map(norm)) const ordered: string[] = [] for (const url of selectableRelaysOrder) { @@ -71,8 +70,11 @@ export default function PostRelaySelector({ const { isSmallScreen } = useScreenSize() useCurrentRelays() // Keep this hook call for any side effects const { relaySets, favoriteRelays, blockedRelays } = useFavoriteRelays() - const { pubkey, relayList } = useNostr() - const userReadRelaysForSelection = useMemo(() => userReadRelaysWithHttp(relayList), [relayList]) + const { pubkey, relayList, cacheRelayListEvent } = useNostr() + const userReadRelaysForSelection = useMemo( + () => userReadInboxUrls(relayList, cacheRelayListEvent), + [relayList, cacheRelayListEvent] + ) const [selectedRelayUrls, setSelectedRelayUrls] = useState([]) const [selectableRelays, setSelectableRelays] = useState([]) const [relayTypes, setRelayTypes] = useState>({}) @@ -165,32 +167,9 @@ export default function PostRelaySelector({ const updateRelaySelection = async () => { setIsLoading(true) try { - let userWriteRelays = relayList?.write || [] - if (pubkey) { - try { - const cacheRelayListEvent = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS) - if (cacheRelayListEvent) { - const cacheRelayList = getRelayListFromEvent(cacheRelayListEvent) - const cacheRelays = [ - ...cacheRelayList.write, - ...cacheRelayList.originalRelays - .filter(relay => (relay.scope === 'both' || relay.scope === 'write') && isLocalNetworkUrl(relay.url)) - .map(relay => relay.url) - ].filter(url => { - if (!url || typeof url !== 'string' || url.trim() === '' || url === 'ws://' || url === 'wss://') return false - return isLocalNetworkUrl(url) - }) - const existingUrls = new Set(userWriteRelays.map(url => normalizeUrl(url) || url)) - const newCacheRelays = cacheRelays - .map(url => normalizeUrl(url) || url) - .filter((url): url is string => !!url && !existingUrls.has(url)) - if (newCacheRelays.length > 0) { - userWriteRelays = [...newCacheRelays, ...userWriteRelays] - } - } - } catch (error) { - logger.warn('Failed to get cache relays from IndexedDB', { error, pubkey }) - } + let userWriteRelays: string[] = [] + if (pubkey && relayList) { + userWriteRelays = await collectViewerWriteOutboxUrls(pubkey, relayList) } const result = await relaySelectionService.selectRelays({ @@ -269,7 +248,7 @@ export default function PostRelaySelector({ useEffect(() => { // An event is "protected" if we have selected relays that aren't the default user write relays const defaultUserWriteRelays = [...(relayList?.httpWrite ?? []), ...(relayList?.write || [])] - const normW = (u: string) => normalizeAnyRelayUrl(u) || u + const normW = (u: string) => normalizeRelayUrlByScheme(u) || u const defaultNorm = new Set(defaultUserWriteRelays.map(normW)) const isProtectedEvent = selectedRelayUrls.length > 0 && diff --git a/src/components/RelayInfo/RelayReviewsPreview.tsx b/src/components/RelayInfo/RelayReviewsPreview.tsx index 48b5180e..00a3a804 100644 --- a/src/components/RelayInfo/RelayReviewsPreview.tsx +++ b/src/components/RelayInfo/RelayReviewsPreview.tsx @@ -8,10 +8,7 @@ import { CarouselPrevious } from '@/components/ui/carousel' import { ExtendedKind } from '@/constants' -import { - getRelayUrlsWithFavoritesFastReadAndInbox, - userReadRelaysWithHttp -} from '@/lib/favorites-feed-relays' +import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls } from '@/lib/favorites-feed-relays' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { compareEvents } from '@/lib/event' import { getStarsFromRelayReviewEvent } from '@/lib/event-metadata' @@ -39,7 +36,7 @@ import ReviewEditor from './ReviewEditor' export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string }) { const { t } = useTranslation() const { push } = useSecondaryPage() - const { pubkey, checkLogin, relayList } = useNostr() + const { pubkey, checkLogin, relayList, cacheRelayListEvent } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { mutePubkeySet } = useMuteList() const [showEditor, setShowEditor] = useState(false) @@ -117,7 +114,7 @@ export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string }) const base = getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - userReadRelaysWithHttp(relayList) + userReadInboxUrls(relayList, cacheRelayListEvent) ) const uniqueUrls = [...new Set([normalizedTarget, ...base])] diff --git a/src/components/RssArticleWebBookmarks/index.tsx b/src/components/RssArticleWebBookmarks/index.tsx index 37bdc68e..43d3d604 100644 --- a/src/components/RssArticleWebBookmarks/index.tsx +++ b/src/components/RssArticleWebBookmarks/index.tsx @@ -5,10 +5,7 @@ import { Separator } from '@/components/ui/separator' import { Textarea } from '@/components/ui/textarea' import { ExtendedKind } from '@/constants' import { createWebBookmarkDraftEvent } from '@/lib/draft-event' -import { - getRelayUrlsWithFavoritesFastReadAndInbox, - userReadRelaysWithHttp -} from '@/lib/favorites-feed-relays' +import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls } from '@/lib/favorites-feed-relays' import logger from '@/lib/logger' import { showPublishingError } from '@/lib/publishing-feedback' import { @@ -34,7 +31,7 @@ import { useTranslation } from 'react-i18next' export default function RssArticleWebBookmarks({ articleUrl }: { articleUrl: string }) { const { t } = useTranslation() const { favoriteRelays, blockedRelays } = useFavoriteRelays() - const { pubkey, publish, attemptDelete, relayList, account } = useNostr() + const { pubkey, publish, attemptDelete, relayList, cacheRelayListEvent, account } = useNostr() const canonical = useMemo(() => canonicalizeRssArticleUrl(articleUrl), [articleUrl]) const iVals = useMemo(() => { @@ -43,11 +40,11 @@ export default function RssArticleWebBookmarks({ articleUrl }: { articleUrl: str }, [canonical]) const relayUrls = useMemo(() => { - const read = userReadRelaysWithHttp(relayList) + const read = userReadInboxUrls(relayList, cacheRelayListEvent) const base = getRelayUrlsWithFavoritesFastReadAndInbox(favoriteRelays, blockedRelays, read, {}) if (!base.length) return [] return appendCuratedReadOnlyRelays(base, blockedRelays) - }, [favoriteRelays, blockedRelays, relayList]) + }, [favoriteRelays, blockedRelays, relayList, cacheRelayListEvent]) const [mine, setMine] = useState([]) const [loading, setLoading] = useState(false) diff --git a/src/components/SearchResult/index.tsx b/src/components/SearchResult/index.tsx index 4e8263ce..3ddf1381 100644 --- a/src/components/SearchResult/index.tsx +++ b/src/components/SearchResult/index.tsx @@ -8,6 +8,7 @@ import Relay from '../Relay' import { useNostr } from '@/providers/NostrProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import client from '@/services/client.service' +import { userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' import { normalizeUrl } from '@/lib/url' import { buildAlexandriaEventsSearchUrlForTSearchParams } from '@/lib/alexandria-events-search-url' import { useLayoutEffect, useMemo } from 'react' @@ -17,7 +18,7 @@ function relayDedupeKey(url: string): string { } export default function SearchResult({ searchParams }: { searchParams: TSearchParams | null }) { - const { pubkey, relayList } = useNostr() + const { relayList, cacheRelayListEvent } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() /** @@ -54,7 +55,10 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa const relays: string[] = [] if (relayList) { - relays.push(...(relayList.read || []), ...(relayList.write || [])) + relays.push( + ...userReadInboxUrls(relayList, cacheRelayListEvent), + ...userWriteOutboxUrls(relayList, cacheRelayListEvent) + ) } relays.push(...(favoriteRelays || [])) @@ -75,7 +79,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa const n = normalizeUrl(relay) || relay return !blockedSet.has(n) }) - }, [pubkey, relayList, favoriteRelays, blockedRelays]) + }, [relayList, cacheRelayListEvent, favoriteRelays, blockedRelays]) const nonSearchableRelays = useMemo( () => combinedRelays.filter((u) => !searchableKeySet.has(relayDedupeKey(u))), diff --git a/src/components/Sidebar/SidebarCalendarWeekWidget.tsx b/src/components/Sidebar/SidebarCalendarWeekWidget.tsx index a5b0ed7a..abd24be6 100644 --- a/src/components/Sidebar/SidebarCalendarWeekWidget.tsx +++ b/src/components/Sidebar/SidebarCalendarWeekWidget.tsx @@ -7,7 +7,7 @@ import { getCalendarOccurrenceWindowMs, getLocalMondayWeekBounds } from '@/lib/calendar-event' -import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' +import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' import { replaceableEventDedupeKey } from '@/lib/event' import { toNote } from '@/lib/link' import { cn } from '@/lib/utils' @@ -41,7 +41,7 @@ const SESSION_CALENDAR_MERGE_CAP = 1200 export default function SidebarCalendarWeekWidget() { const { t } = useTranslation() - const { relayList, pubkey } = useNostr() + const { relayList, cacheRelayListEvent, pubkey } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const followList = useFollowListOptional() const { navigateToNote } = useSmartNoteNavigation() @@ -62,9 +62,9 @@ export default function SidebarCalendarWeekWidget() { const base = getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - userReadRelaysWithHttp(relayList), + userReadInboxUrls(relayList, cacheRelayListEvent), { - userWriteRelays: relayList?.write ?? [], + userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent), applySocialKindBlockedFilter: false } ) diff --git a/src/features/feed/relay-policy.ts b/src/features/feed/relay-policy.ts index d51fb6ce..0ee1bf00 100644 --- a/src/features/feed/relay-policy.ts +++ b/src/features/feed/relay-policy.ts @@ -11,7 +11,7 @@ import { relayUrlsStripExtendedTagReqBlocked } from '@/lib/relay-extended-tag-req-blocks' import { isRelayBlockedByUser } from '@/lib/relay-blocked' -import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeHttpRelayUrl } from '@/lib/url' +import { isLocalNetworkUrl, normalizeHttpRelayUrl, normalizeRelayUrlByScheme } from '@/lib/url' import type { TSubRequestFilter } from '@/types' export type FeedRelayOperation = 'read' | 'write' | 'publish-picker' | 'favorites-feed' @@ -98,7 +98,7 @@ function canonicalRelayUrl(url: string | undefined | null, layerSource?: FeedRel function normalizedRelayUrl(url: string, layerSource?: FeedRelayLayerSource | string): string { if (layerSource === 'http-index') return normalizeHttpRelayUrl(url) || url.trim() - return normalizeAnyRelayUrl(url) || url.trim() + return normalizeRelayUrlByScheme(url) || url.trim() } function normalizedSet(urls: readonly string[] | undefined): Set { diff --git a/src/hooks/useFetchCalendarRsvps.tsx b/src/hooks/useFetchCalendarRsvps.tsx index ea0beccd..5f72e5ef 100644 --- a/src/hooks/useFetchCalendarRsvps.tsx +++ b/src/hooks/useFetchCalendarRsvps.tsx @@ -12,23 +12,9 @@ import { Event } from 'nostr-tools' import { useEffect, useState } from 'react' import { normalizeAnyRelayUrl } from '@/lib/url' import { FAST_READ_RELAY_URLS } from '@/constants' -import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' +import { userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' import { tagNameEquals } from '@/lib/tag' -/** NIP-65 inboxes only — calendar RSVPs are published to the author’s outboxes, so REQ must include those too. */ -function userWriteRelaysForQuery( - relayList: { write?: string[]; httpWrite?: string[] } | null | undefined -): string[] { - if (!relayList) return [] - const ws = (relayList.write ?? []) - .map((url) => normalizeAnyRelayUrl(url) || url) - .filter(Boolean) as string[] - const http = (relayList.httpWrite ?? []) - .map((url) => normalizeAnyRelayUrl(url) || url) - .filter(Boolean) as string[] - return [...http, ...ws] -} - function getRsvpStatus(rsvp: Event): 'accepted' | 'tentative' | 'declined' | undefined { const status = rsvp.tags.find(tagNameEquals('status'))?.[1] if (status === 'accepted' || status === 'tentative' || status === 'declined') return status @@ -53,7 +39,7 @@ function mergeRsvpList(events: Event[]): Event[] { } export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { - const { relayList } = useNostr() + const { relayList, cacheRelayListEvent } = useNostr() const [rsvps, setRsvps] = useState([]) const [isFetching, setIsFetching] = useState(false) @@ -69,8 +55,8 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { const coordinate = normalizeReplaceableCoordinateString( getReplaceableCoordinateFromEvent(calendarEvent) ) - const userRead = userReadRelaysWithHttp(relayList) - const userWrite = userWriteRelaysForQuery(relayList) + const userRead = userReadInboxUrls(relayList, cacheRelayListEvent) + const userWrite = userWriteOutboxUrls(relayList, cacheRelayListEvent) void (async () => { const fromSession = client.getSessionCalendarRsvpsForCalendarEvent(calendarEvent) diff --git a/src/hooks/useUserMailboxRelayUrls.ts b/src/hooks/useUserMailboxRelayUrls.ts new file mode 100644 index 00000000..5afefb3b --- /dev/null +++ b/src/hooks/useUserMailboxRelayUrls.ts @@ -0,0 +1,21 @@ +import { userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' +import { useNostrOptional } from '@/providers/nostr-context' +import { useMemo } from 'react' + +/** Viewer read inbox: kind 10432 cache + kind 10243 HTTP + kind 10002 WS. */ +export function useUserReadInboxUrls(): string[] { + const nostr = useNostrOptional() + return useMemo( + () => userReadInboxUrls(nostr?.relayList, nostr?.cacheRelayListEvent), + [nostr?.relayList, nostr?.cacheRelayListEvent] + ) +} + +/** Viewer write outbox: kind 10432 cache + kind 10243 HTTP + kind 10002 WS. */ +export function useUserWriteOutboxUrls(): string[] { + const nostr = useNostrOptional() + return useMemo( + () => userWriteOutboxUrls(nostr?.relayList, nostr?.cacheRelayListEvent), + [nostr?.relayList, nostr?.cacheRelayListEvent] + ) +} diff --git a/src/hooks/useViewerInboxRelayUrls.ts b/src/hooks/useViewerInboxRelayUrls.ts index 8ace95e5..bc6bbb92 100644 --- a/src/hooks/useViewerInboxRelayUrls.ts +++ b/src/hooks/useViewerInboxRelayUrls.ts @@ -1,4 +1,4 @@ -import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' +import { collectViewerReadInboxUrls } from '@/lib/viewer-read-inboxes' import { useNostrOptional } from '@/providers/nostr-context' import client from '@/services/client.service' import { useEffect, useState } from 'react' @@ -19,7 +19,10 @@ export function useViewerInboxRelayUrls(): { let cancelled = false void client.peekRelayListFromStorage(pk).then((rl) => { if (cancelled) return - setInboxRelayUrls(userReadRelaysWithHttp(rl).slice(0, 14)) + void collectViewerReadInboxUrls(pk, rl).then((urls) => { + if (cancelled) return + setInboxRelayUrls(urls.slice(0, 14)) + }) }) return () => { cancelled = true diff --git a/src/lib/account-list-relay-urls.ts b/src/lib/account-list-relay-urls.ts index f3f80a75..b1e15d36 100644 --- a/src/lib/account-list-relay-urls.ts +++ b/src/lib/account-list-relay-urls.ts @@ -1,6 +1,8 @@ import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' import { buildPrioritizedReadRelayUrls, buildPrioritizedWriteRelayUrls } from '@/lib/relay-url-priority' -import { normalizeAnyRelayUrl } from '@/lib/url' +import { normalizeRelayUrlByScheme } from '@/lib/url' +import { collectViewerReadInboxUrls } from '@/lib/viewer-read-inboxes' +import { collectViewerWriteOutboxUrls } from '@/lib/viewer-write-outboxes' import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import client from '@/services/client.service' @@ -21,9 +23,11 @@ export async function buildAccountListRelayUrlsForMerge(options: { relayList: myRelayList }) const favoritesTier = getFavoritesFeedRelayUrls(favoriteRelays ?? [], blockedRelays, useGlobal) + const writeOutboxes = await collectViewerWriteOutboxUrls(accountPubkey, myRelayList) + const readInboxes = await collectViewerReadInboxUrls(accountPubkey, myRelayList) const read = buildPrioritizedReadRelayUrls({ - userReadRelays: myRelayList.read ?? [], - userWriteRelays: myRelayList.write ?? [], + userReadRelays: readInboxes, + userWriteRelays: writeOutboxes, favoriteRelays: favoritesTier, blockedRelays, maxRelays: 100, @@ -31,7 +35,7 @@ export async function buildAccountListRelayUrlsForMerge(options: { includeGlobalFastRead: useGlobal }) const write = buildPrioritizedWriteRelayUrls({ - userWriteRelays: myRelayList.write ?? [], + userWriteRelays: writeOutboxes, favoriteRelays: favoritesTier, blockedRelays, maxRelays: 100, @@ -39,5 +43,5 @@ export async function buildAccountListRelayUrlsForMerge(options: { includeGlobalFastWriteReadTails: useGlobal }) const merged = [...read, ...write] - return [...new Set(merged.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean))] + return [...new Set(merged.map((u) => normalizeRelayUrlByScheme(u) || u).filter(Boolean))] } diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index 8af4d801..5e619b6f 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -33,6 +33,7 @@ import { } from '@/lib/rss-article' import { EMOJI_SHORT_CODE_REGEX } from '@/lib/content-patterns' import { blossomSha256FromBlobUrl, cleanUrl, isBlossomBudBlobUrl } from '@/lib/url' +import { collectReadInboxUrlsFromRelayList } from '@/lib/viewer-read-inboxes' import { urlToWebBookmarkDTag } from '@/lib/web-bookmark-nip' import { randomString } from './random' import { generateBech32IdFromETag, getImetaInfoFromImetaTag, tagNameEquals } from './tag' @@ -1169,10 +1170,7 @@ export async function createPollDraftEvent( relays.forEach((relay) => tags.push(buildRelayTag(relay))) } else { const relayList = await client.fetchRelayList(author) - const readHints = [ - ...(relayList.httpRead || []).slice(0, 4), - ...(relayList.read || []).slice(0, 4) - ].slice(0, 4) + const readHints = collectReadInboxUrlsFromRelayList(relayList).slice(0, 4) readHints.forEach((relay) => { tags.push(buildRelayTag(relay)) }) diff --git a/src/lib/favorites-feed-relays.ts b/src/lib/favorites-feed-relays.ts index ff6bc6e7..9b12774c 100644 --- a/src/lib/favorites-feed-relays.ts +++ b/src/lib/favorites-feed-relays.ts @@ -20,6 +20,10 @@ import { feedRelayPolicyUrls, type FeedRelayLayer } from '@/features/feed/relay- import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize' import { relaySessionStrikes } from '@/lib/relay-strikes' import { profileFetchRelayUrlsWithoutFastReadLayer } from '@/lib/viewer-relay-defaults' +import { getCacheRelayUrlsFromEvent } from '@/lib/private-relays' +import { collectUserReadInboxUrls } from '@/lib/viewer-read-inboxes' +import { collectUserWriteOutboxUrls } from '@/lib/viewer-write-outboxes' +import type { Event } from 'nostr-tools' function isBlockedRelay(url: string, blockedRelays: string[]): boolean { return isRelayBlockedByUser(url, blockedRelays) @@ -32,14 +36,36 @@ function isBlockedRelay(url: string, blockedRelays: string[]): boolean { * 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. + * Logged-in user's read inbox: kind 10432 cache + kind 10243 HTTP + kind 10002 WS. + * Pass `cacheRelayListEvent` (or `cacheUrls`) when kind 10432 is not merged into `relayList.read`. */ +export function userReadInboxUrls( + relayList: { read?: string[]; httpRead?: string[] } | undefined | null, + cacheRelayListEvent?: Event | null, + cacheUrls?: readonly string[] +): string[] { + const cache = cacheUrls ?? getCacheRelayUrlsFromEvent(cacheRelayListEvent) + return collectUserReadInboxUrls(relayList, cache) +} + +/** + * Logged-in user's write outbox: kind 10432 cache + kind 10243 HTTP + kind 10002 WS. + */ +export function userWriteOutboxUrls( + relayList: { write?: string[]; httpWrite?: string[] } | undefined | null, + cacheRelayListEvent?: Event | null, + cacheUrls?: readonly string[] +): string[] { + const cache = cacheUrls ?? getCacheRelayUrlsFromEvent(cacheRelayListEvent) + return collectUserWriteOutboxUrls(relayList, cache) +} + +/** @deprecated use {@link userReadInboxUrls} */ export function userReadRelaysWithHttp( - relayList: { read?: string[]; httpRead?: string[] } | undefined | null + relayList: { read?: string[]; httpRead?: string[] } | undefined | null, + cacheRelayListEvent?: Event | null ): string[] { - const http = relayList?.httpRead ?? [] - const read = relayList?.read ?? [] - return dedupeNormalizeRelayUrlsOrdered([...http, ...read]) + return userReadInboxUrls(relayList, cacheRelayListEvent) } export function getFavoritesFeedRelayUrls( @@ -98,8 +124,8 @@ export function buildAuthorInboxOutboxRelayUrls( const list = includeAuthorLocalRelays ? authorRelayList : stripMailboxLocalUrlsForRemoteViewers(authorRelayList) - const inboxLayer = relayUrlsLocalsFirst([...(list.httpRead ?? []), ...(list.read ?? [])]) - const outboxLayer = relayUrlsLocalsFirst([...(list.httpWrite ?? []), ...(list.write ?? [])]) + const inboxLayer = relayUrlsLocalsFirst(collectUserReadInboxUrls(list)) + const outboxLayer = relayUrlsLocalsFirst(collectUserWriteOutboxUrls(list)) return mergeRelayUrlLayers([inboxLayer, outboxLayer], blockedRelays) } @@ -213,8 +239,8 @@ export function buildProfilePageReadRelayUrls( const list = includeAuthorLocalRelays ? authorRelayList : stripMailboxLocalUrlsForRemoteViewers(authorRelayList) - const authorRead = [...(list.httpRead ?? []), ...(list.read ?? [])] - const authorWrite = [...(list.httpWrite ?? []), ...(list.write ?? [])] + const authorRead = collectUserReadInboxUrls(list) + const authorWrite = collectUserWriteOutboxUrls(list) const authorHasNoNip65 = authorRead.length === 0 && authorWrite.length === 0 const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useGlobal) diff --git a/src/lib/private-relays.ts b/src/lib/private-relays.ts index c92347d6..f677ee46 100644 --- a/src/lib/private-relays.ts +++ b/src/lib/private-relays.ts @@ -1,78 +1,46 @@ import client from '@/services/client.service' +import { + collectViewerWriteOutboxUrls, + viewerHasWriteOutboxes +} from '@/lib/viewer-write-outboxes' import indexedDb from '@/services/indexed-db.service' import { ExtendedKind } from '@/constants' +import type { Event } from 'nostr-tools' + +/** Kind 10432 relay tag URLs from an in-memory event (sync). */ +export function getCacheRelayUrlsFromEvent(event: Event | null | undefined): string[] { + if (!event) return [] + const relayUrls: string[] = [] + event.tags.forEach((tag) => { + if (tag[0] === 'relay' && tag[1]) { + relayUrls.push(tag[1]) + } + }) + return Array.from(new Set(relayUrls)) +} /** * Check if user has private relays available (outbox relays or cache relays) - * @param pubkey - User's public key - * @returns Promise - true if user has at least one private relay available */ export async function hasPrivateRelays(pubkey: string): Promise { - // Check for outbox relays (kind 10002) — IndexedDB merge only; no network wait. const relayList = await client.peekRelayListFromStorage(pubkey) - if (relayList.write && relayList.write.length > 0) { - return true - } - - // Check for cache relays (kind 10432) - const cacheRelayEvent = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS) - if (cacheRelayEvent) { - // Check if cache relay event has any relays - const hasRelays = cacheRelayEvent.tags.some(tag => tag[0] === 'relay' && tag[1]) - if (hasRelays) { - return true - } - } - - return false + return viewerHasWriteOutboxes(pubkey, relayList) } /** - * Get private relay URLs (outbox + cache relays) - * @param pubkey - User's public key - * @returns Promise - Array of relay URLs + * Get private relay URLs (kind 10002 WS + kind 10243 HTTP + kind 10432 cache write outboxes) */ export async function getPrivateRelayUrls(pubkey: string): Promise { - const relayUrls: string[] = [] - - // Get outbox relays (kind 10002) — storage-first; cache rows below still augment. const relayList = await client.peekRelayListFromStorage(pubkey) - if (relayList.write) { - relayUrls.push(...relayList.write) - } - - // Get cache relays (kind 10432) - const cacheRelayEvent = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS) - if (cacheRelayEvent) { - cacheRelayEvent.tags.forEach(tag => { - if (tag[0] === 'relay' && tag[1]) { - relayUrls.push(tag[1]) - } - }) - } - - // Deduplicate - return Array.from(new Set(relayUrls)) + return collectViewerWriteOutboxUrls(pubkey, relayList) } /** - * Get cache relay URLs only + * Get cache relay URLs only (kind 10432) * @param pubkey - User's public key * @returns Promise - Array of cache relay URLs */ export async function getCacheRelayUrls(pubkey: string): Promise { - const relayUrls: string[] = [] - - // Get cache relays (kind 10432) const cacheRelayEvent = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS) - if (cacheRelayEvent) { - cacheRelayEvent.tags.forEach(tag => { - if (tag[0] === 'relay' && tag[1]) { - relayUrls.push(tag[1]) - } - }) - } - - return Array.from(new Set(relayUrls)) + return getCacheRelayUrlsFromEvent(cacheRelayEvent) } - diff --git a/src/lib/profile-reports-relays.ts b/src/lib/profile-reports-relays.ts index 2bc04826..05360b8a 100644 --- a/src/lib/profile-reports-relays.ts +++ b/src/lib/profile-reports-relays.ts @@ -1,5 +1,6 @@ import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { relayUrlsLocalsFirst } from '@/lib/relay-url-priority' +import { collectReadInboxUrlsFromRelayList } from '@/lib/viewer-read-inboxes' import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize' import { normalizeAnyRelayUrl } from '@/lib/url' @@ -36,7 +37,7 @@ export function buildProfileReportsRelayUrls( const list = options.includeAuthorLocalRelays ? mailboxList : stripMailboxLocalUrlsForRemoteViewers(mailboxList) - const inboxLayer = relayUrlsLocalsFirst([...(list.httpRead ?? []), ...(list.read ?? [])]) + const inboxLayer = relayUrlsLocalsFirst(collectReadInboxUrlsFromRelayList(list)) const cacheLayer = relayUrlsLocalsFirst( (options.cacheRelayUrls ?? []).filter((u) => { const k = normalizeAnyRelayUrl(u) || u.trim() diff --git a/src/lib/public-message-publish-relays.ts b/src/lib/public-message-publish-relays.ts index 2ca5f5f3..f1fb6a6d 100644 --- a/src/lib/public-message-publish-relays.ts +++ b/src/lib/public-message-publish-relays.ts @@ -5,32 +5,21 @@ import { } from '@/constants' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { dedupeNormalizeRelayUrlsOrdered, relayUrlsLocalsFirst } from '@/lib/relay-url-priority' -import { isLocalNetworkUrl, normalizeHttpRelayUrl, normalizeUrl } from '@/lib/url' +import { collectRemoteReadInboxUrlsFromRelayList } from '@/lib/viewer-read-inboxes' +import { collectWriteOutboxUrlsFromRelayList } from '@/lib/viewer-write-outboxes' import type { TRelayList } from '@/types' -/** NIP-65 / 10243 outbox URLs for the sender (includes viewer-local outboxes). */ +/** NIP-65 / 10243 outbox URLs for the sender (includes viewer-local outboxes when present on `write`). */ export function collectSenderOutboxUrls( relayList: TRelayList | null | undefined, extraWriteUrls: readonly string[] = [] ): string[] { - const http = (relayList?.httpWrite ?? []) - .map((u) => normalizeHttpRelayUrl(u) || u) - .filter((u): u is string => !!u) - const ws = (relayList?.write ?? []) - .map((u) => normalizeUrl(u) || u) - .filter((u): u is string => !!u) - return dedupeNormalizeRelayUrlsOrdered([...http, ...ws, ...extraWriteUrls]) + return collectWriteOutboxUrlsFromRelayList(relayList, extraWriteUrls) } /** NIP-65 / 10243 inbox URLs for a recipient (drops other people's LAN/loopback). */ export function collectRecipientInboxUrls(relayList: TRelayList | null | undefined): string[] { - const http = (relayList?.httpRead ?? []) - .map((u) => normalizeHttpRelayUrl(u) || u) - .filter((u): u is string => !!u && !isLocalNetworkUrl(u)) - const ws = (relayList?.read ?? []) - .map((u) => normalizeUrl(u) || u) - .filter((u): u is string => !!u && !isLocalNetworkUrl(u)) - return dedupeNormalizeRelayUrlsOrdered([...http, ...ws]) + return collectRemoteReadInboxUrlsFromRelayList(relayList) } /** diff --git a/src/lib/tombstone-events.ts b/src/lib/tombstone-events.ts index 896f09e0..2fc49023 100644 --- a/src/lib/tombstone-events.ts +++ b/src/lib/tombstone-events.ts @@ -1,5 +1,7 @@ import { PROFILE_RELAY_URLS } from '@/constants' -import { normalizeAnyRelayUrl, normalizeHttpRelayUrl, normalizeUrl } from '@/lib/url' +import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' +import { collectUserReadInboxUrls } from '@/lib/viewer-read-inboxes' +import { collectUserWriteOutboxUrls } from '@/lib/viewer-write-outboxes' import type { TRelayList } from '@/types' /** Dispatched after tombstones in IndexedDB change (kind-5 sync or local apply). */ @@ -11,22 +13,21 @@ export function dispatchTombstonesUpdated(): void { } /** Relay set for querying the current user's kind-5 events (aligned with login sync). */ -export function buildDeletionRelayUrls(relayList: TRelayList | null | undefined): string[] { - const httpR = relayList?.httpRead ?? [] - const httpW = relayList?.httpWrite ?? [] - if (!relayList?.read?.length && !relayList?.write?.length && !httpR.length && !httpW.length) { +export function buildDeletionRelayUrls( + relayList: TRelayList | null | undefined, + cacheUrls: readonly string[] = [] +): string[] { + const readInboxes = collectUserReadInboxUrls(relayList, cacheUrls) + const writeOutboxes = collectUserWriteOutboxUrls(relayList, cacheUrls) + if (readInboxes.length === 0 && writeOutboxes.length === 0) { return Array.from( new Set(PROFILE_RELAY_URLS.map((url) => normalizeUrl(url) || url).filter(Boolean)) ).slice(0, 20) } - const ws = relayList?.write ?? [] - const rs = relayList?.read ?? [] return Array.from( new Set([ - ...ws.map((url: string) => normalizeUrl(url) || url), - ...rs.slice(0, 8).map((url: string) => normalizeUrl(url) || url), - ...httpW.map((url: string) => normalizeHttpRelayUrl(url) || url), - ...httpR.slice(0, 8).map((url: string) => normalizeHttpRelayUrl(url) || url), + ...writeOutboxes, + ...readInboxes.slice(0, 8), ...PROFILE_RELAY_URLS.map((url: string) => normalizeAnyRelayUrl(url) || url) ]) ).slice(0, 20) diff --git a/src/lib/viewer-read-inboxes.test.ts b/src/lib/viewer-read-inboxes.test.ts new file mode 100644 index 00000000..f5338a44 --- /dev/null +++ b/src/lib/viewer-read-inboxes.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest' +import { + collectReadInboxUrlsFromRelayList, + collectUserReadInboxUrls, + collectRemoteReadInboxUrlsFromRelayList, + collectViewerReadInboxUrls +} from '@/lib/viewer-read-inboxes' + +vi.mock('@/lib/private-relays', () => ({ + getCacheRelayUrls: vi.fn(async () => ['ws://localhost:4869/']) +})) + +describe('viewer read inboxes', () => { + const relayList = { + write: ['wss://outbox.example/'], + read: ['wss://inbox.example/'], + httpWrite: [], + httpRead: ['https://http-in.example/'], + originalRelays: [], + httpOriginalRelays: [] + } + + it('collectReadInboxUrlsFromRelayList merges http before ws (no cache layer)', () => { + expect(collectReadInboxUrlsFromRelayList(relayList)).toEqual([ + 'https://http-in.example/', + 'wss://inbox.example/' + ]) + }) + + it('collectUserReadInboxUrls orders cache before http before ws', () => { + expect(collectUserReadInboxUrls(relayList, ['ws://127.0.0.1:4869'])).toEqual([ + 'ws://127.0.0.1:4869/', + 'https://http-in.example/', + 'wss://inbox.example/' + ]) + }) + + it('collectRemoteReadInboxUrlsFromRelayList drops LAN urls', () => { + expect( + collectRemoteReadInboxUrlsFromRelayList({ + ...relayList, + read: ['wss://inbox.example/', 'ws://127.0.0.1:4869/'] + }) + ).toEqual(['https://http-in.example/', 'wss://inbox.example/']) + }) + + it('collectViewerReadInboxUrls loads cache from kind 10432', async () => { + await expect(collectViewerReadInboxUrls('ab'.repeat(32), relayList)).resolves.toEqual([ + 'ws://localhost:4869/', + 'https://http-in.example/', + 'wss://inbox.example/' + ]) + }) +}) diff --git a/src/lib/viewer-read-inboxes.ts b/src/lib/viewer-read-inboxes.ts new file mode 100644 index 00000000..914e77a8 --- /dev/null +++ b/src/lib/viewer-read-inboxes.ts @@ -0,0 +1,88 @@ +import { getCacheRelayUrls } from '@/lib/private-relays' +import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority' +import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeHttpRelayUrl, normalizeUrl } from '@/lib/url' +import type { TRelayList } from '@/types' + +export type ReadInboxSource = Pick | { + read?: string[] + httpRead?: string[] +} + +function relayKey(url: string): string { + return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase() +} + +/** + * Logged-in user's read inbox: kind 10432 cache, then kind 10243 HTTP read, then kind 10002 WS read. + * Pass `cacheUrls` when kind 10432 is stored separately (e.g. from {@link cacheRelayListEvent}); + * those URLs are stripped from the WS layer so each relay appears once with cache-first ordering. + */ +export function collectUserReadInboxUrls( + relayList: ReadInboxSource | null | undefined, + cacheUrls: readonly string[] = [], + extraReadUrls: readonly string[] = [] +): string[] { + const cache = cacheUrls + .map((u) => normalizeUrl(u) || u.trim()) + .filter(Boolean) + const cacheKeys = new Set(cache.map(relayKey)) + const http = (relayList?.httpRead ?? []) + .map((u) => normalizeHttpRelayUrl(u) || u) + .filter((u): u is string => !!u) + const ws = (relayList?.read ?? []) + .map((u) => normalizeUrl(u) || u) + .filter((u): u is string => !!u) + .filter((u) => cacheKeys.size === 0 || !cacheKeys.has(relayKey(u))) + return dedupeNormalizeRelayUrlsOrdered([...cache, ...http, ...ws, ...extraReadUrls]) +} + +/** + * Kind 10243 HTTP + kind 10002 WS read fields from a mailbox list (no kind 10432). + * Use for third-party authors; for the viewer prefer {@link collectUserReadInboxUrls}. + */ +export function collectReadInboxUrlsFromRelayList( + relayList: ReadInboxSource | null | undefined, + extraReadUrls: readonly string[] = [] +): string[] { + return collectUserReadInboxUrls(relayList, [], extraReadUrls) +} + +/** NIP-65 / 10243 inbox URLs for a recipient (drops other people's LAN/loopback). */ +export function collectRemoteReadInboxUrlsFromRelayList( + relayList: ReadInboxSource | null | undefined, + extraReadUrls: readonly string[] = [] +): string[] { + return collectReadInboxUrlsFromRelayList(relayList, extraReadUrls).filter( + (u) => !isLocalNetworkUrl(u) + ) +} + +/** @deprecated use {@link collectUserReadInboxUrls} */ +export function collectReadInboxUrlsWithExtraCache( + relayList: ReadInboxSource | null | undefined, + cacheUrls: readonly string[], + extraReadUrls: readonly string[] = [] +): string[] { + return collectUserReadInboxUrls(relayList, cacheUrls, extraReadUrls) +} + +/** + * Full viewer read inbox: kind 10432 cache (IndexedDB) + kind 10243 HTTP + kind 10002 WS. + */ +export async function collectViewerReadInboxUrls( + pubkey: string, + relayList: ReadInboxSource | null | undefined, + extraReadUrls: readonly string[] = [] +): Promise { + const cache = await getCacheRelayUrls(pubkey) + return collectUserReadInboxUrls(relayList, cache, extraReadUrls) +} + +/** True when the viewer has any configured read inbox (10002, 10243, or 10432 cache). */ +export async function viewerHasReadInboxes( + pubkey: string, + relayList: ReadInboxSource | null | undefined +): Promise { + const urls = await collectViewerReadInboxUrls(pubkey, relayList) + return urls.length > 0 +} diff --git a/src/lib/viewer-write-outboxes.test.ts b/src/lib/viewer-write-outboxes.test.ts new file mode 100644 index 00000000..09bfaf1d --- /dev/null +++ b/src/lib/viewer-write-outboxes.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' +import { + collectUserWriteOutboxUrls, + collectViewerWriteOutboxUrls, + collectWriteOutboxUrlsFromRelayList +} from '@/lib/viewer-write-outboxes' + +vi.mock('@/lib/private-relays', () => ({ + getCacheRelayUrls: vi.fn(async () => ['ws://localhost:4869/']) +})) + +describe('viewer write outboxes', () => { + const relayList = { + write: ['wss://nip65.example/'], + read: ['wss://inbox.example/'], + httpWrite: ['https://http-out.example/'], + httpRead: [], + originalRelays: [], + httpOriginalRelays: [] + } + + it('collectWriteOutboxUrlsFromRelayList merges http before ws (no cache layer)', () => { + expect(collectWriteOutboxUrlsFromRelayList(relayList)).toEqual([ + 'https://http-out.example/', + 'wss://nip65.example/' + ]) + }) + + it('collectUserWriteOutboxUrls orders cache before http before ws', () => { + expect(collectUserWriteOutboxUrls(relayList, ['ws://127.0.0.1:4869'])).toEqual([ + 'ws://127.0.0.1:4869/', + 'https://http-out.example/', + 'wss://nip65.example/' + ]) + }) + + it('collectViewerWriteOutboxUrls loads cache from kind 10432', async () => { + await expect(collectViewerWriteOutboxUrls('ab'.repeat(32), relayList)).resolves.toEqual([ + 'ws://localhost:4869/', + 'https://http-out.example/', + 'wss://nip65.example/' + ]) + }) +}) diff --git a/src/lib/viewer-write-outboxes.ts b/src/lib/viewer-write-outboxes.ts new file mode 100644 index 00000000..05e59cac --- /dev/null +++ b/src/lib/viewer-write-outboxes.ts @@ -0,0 +1,77 @@ +import { getCacheRelayUrls } from '@/lib/private-relays' +import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority' +import { normalizeAnyRelayUrl, normalizeHttpRelayUrl, normalizeUrl } from '@/lib/url' +import type { TRelayList } from '@/types' + +export type WriteOutboxSource = Pick | { + write?: string[] + httpWrite?: string[] +} + +function relayKey(url: string): string { + return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase() +} + +/** + * Logged-in user's write outbox: kind 10432 cache, then kind 10243 HTTP write, then kind 10002 WS write. + * Pass `cacheUrls` when kind 10432 is stored separately; those URLs are stripped from the WS layer. + */ +export function collectUserWriteOutboxUrls( + relayList: WriteOutboxSource | null | undefined, + cacheUrls: readonly string[] = [], + extraWriteUrls: readonly string[] = [] +): string[] { + const cache = cacheUrls + .map((u) => normalizeUrl(u) || u.trim()) + .filter(Boolean) + const cacheKeys = new Set(cache.map(relayKey)) + const http = (relayList?.httpWrite ?? []) + .map((u) => normalizeHttpRelayUrl(u) || u) + .filter((u): u is string => !!u) + const ws = (relayList?.write ?? []) + .map((u) => normalizeUrl(u) || u) + .filter((u): u is string => !!u) + .filter((u) => cacheKeys.size === 0 || !cacheKeys.has(relayKey(u))) + return dedupeNormalizeRelayUrlsOrdered([...cache, ...http, ...ws, ...extraWriteUrls]) +} + +/** + * Kind 10243 HTTP + kind 10002 WS write fields from a mailbox list (no kind 10432). + * Use for third-party authors; for the viewer prefer {@link collectUserWriteOutboxUrls}. + */ +export function collectWriteOutboxUrlsFromRelayList( + relayList: WriteOutboxSource | null | undefined, + extraWriteUrls: readonly string[] = [] +): string[] { + return collectUserWriteOutboxUrls(relayList, [], extraWriteUrls) +} + +/** @deprecated use {@link collectUserWriteOutboxUrls} */ +export function collectWriteOutboxUrlsWithExtraCache( + relayList: WriteOutboxSource | null | undefined, + cacheUrls: readonly string[], + extraWriteUrls: readonly string[] = [] +): string[] { + return collectUserWriteOutboxUrls(relayList, cacheUrls, extraWriteUrls) +} + +/** + * Full viewer publish outbox: kind 10432 cache (IndexedDB) + kind 10243 HTTP + kind 10002 WS. + */ +export async function collectViewerWriteOutboxUrls( + pubkey: string, + relayList: WriteOutboxSource | null | undefined, + extraWriteUrls: readonly string[] = [] +): Promise { + const cache = await getCacheRelayUrls(pubkey) + return collectUserWriteOutboxUrls(relayList, cache, extraWriteUrls) +} + +/** True when the viewer has any configured write outbox (10002, 10243, or 10432 cache). */ +export async function viewerHasWriteOutboxes( + pubkey: string, + relayList: WriteOutboxSource | null | undefined +): Promise { + const urls = await collectViewerWriteOutboxUrls(pubkey, relayList) + return urls.length > 0 +} diff --git a/src/pages/primary/CalendarPrimaryPage.tsx b/src/pages/primary/CalendarPrimaryPage.tsx index afa5f2d9..b90f3921 100644 --- a/src/pages/primary/CalendarPrimaryPage.tsx +++ b/src/pages/primary/CalendarPrimaryPage.tsx @@ -8,7 +8,7 @@ import { getLocalMonthRangeMs } from '@/lib/calendar-event' import { replaceableEventDedupeKey } from '@/lib/event' -import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' +import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' import { setCalendarDayPanelEvents } from '@/lib/calendar-day-panel-cache' import { toNote } from '@/lib/link' import { cn } from '@/lib/utils' @@ -72,7 +72,7 @@ const CalendarPrimaryPage = forwardRef(funct ref ) { const { t, i18n } = useTranslation() - const { relayList, pubkey } = useNostr() + const { relayList, cacheRelayListEvent, pubkey } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const followList = useFollowListOptional() const { navigateToNote } = useSmartNoteNavigation() @@ -121,9 +121,9 @@ const CalendarPrimaryPage = forwardRef(funct const base = getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - userReadRelaysWithHttp(relayList), + userReadInboxUrls(relayList, cacheRelayListEvent), { - userWriteRelays: relayList?.write ?? [], + userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent), applySocialKindBlockedFilter: false } ) diff --git a/src/pages/primary/SpellsPage/CreateSpellDialog.tsx b/src/pages/primary/SpellsPage/CreateSpellDialog.tsx index efebb569..7a3b735e 100644 --- a/src/pages/primary/SpellsPage/CreateSpellDialog.tsx +++ b/src/pages/primary/SpellsPage/CreateSpellDialog.tsx @@ -25,7 +25,7 @@ import { useNostr } from '@/providers/NostrProvider' import { showPublishingError, showSimplePublishSuccess } from '@/lib/publishing-feedback' import { eventService } from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' -import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' +import { userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' import { getRelaysForSpellCatalogSync } from '@/services/spell.service' import { Info, Minus, Plus, X } from 'lucide-react' import { useTranslation } from 'react-i18next' @@ -292,7 +292,7 @@ export default function CreateSpellDialog({ spellToClone?: NostrEvent | null }) { const { t } = useTranslation() - const { pubkey, publish, checkLogin, relayList } = useNostr() + const { pubkey, publish, checkLogin, relayList, cacheRelayListEvent } = useNostr() const { addBookmark, removeBookmark } = useBookmarks() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults() @@ -326,8 +326,8 @@ export default function CreateSpellDialog({ const { draft, notices, pendingATags } = applyListEventToSpellDraft(base, ev) setForm(draft) setListImportNotices(notices) - const urls = getRelaysForSpellCatalogSync(favoriteRelays, blockedRelays, userReadRelaysWithHttp(relayList), { - userWriteRelays: relayList?.write ?? [], + const urls = getRelaysForSpellCatalogSync(favoriteRelays, blockedRelays, userReadInboxUrls(relayList, cacheRelayListEvent), { + userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent), useGlobalRelayBootstrap }) if (pendingATags.length === 0) return diff --git a/src/pages/primary/SpellsPage/ProfileInteractionsMap.tsx b/src/pages/primary/SpellsPage/ProfileInteractionsMap.tsx index 68f21512..66f7a357 100644 --- a/src/pages/primary/SpellsPage/ProfileInteractionsMap.tsx +++ b/src/pages/primary/SpellsPage/ProfileInteractionsMap.tsx @@ -4,10 +4,7 @@ import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' import { ExtendedKind } from '@/constants' -import { - getRelayUrlsWithFavoritesFastReadAndInbox, - userReadRelaysWithHttp -} from '@/lib/favorites-feed-relays' +import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' import { toProfile } from '@/lib/link' import { formatPubkey } from '@/lib/pubkey' import { cn } from '@/lib/utils' @@ -114,7 +111,7 @@ function compactCount(n: number): string { export default function ProfileInteractionsMap({ pubkey, refreshKey }: Props) { const { t } = useTranslation() const { push } = useSecondaryPage() - const { relayList } = useNostr() + const { relayList, cacheRelayListEvent } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const [cards, setCards] = useState([]) const [loading, setLoading] = useState(true) @@ -126,9 +123,9 @@ export default function ProfileInteractionsMap({ pubkey, refreshKey }: Props) { getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - userReadRelaysWithHttp(relayList), + userReadInboxUrls(relayList, cacheRelayListEvent), { - userWriteRelays: relayList?.write ?? [], + userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent), applySocialKindBlockedFilter: false } ), diff --git a/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx b/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx index db76e513..617a560b 100644 --- a/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx +++ b/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx @@ -4,7 +4,7 @@ import { SimpleUserAvatar } from '@/components/UserAvatar' import { ExtendedKind } from '@/constants' import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' import { filterEventsExcludingTombstones } from '@/lib/event' -import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' +import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' import { toNote } from '@/lib/link' import logger from '@/lib/logger' import { @@ -100,7 +100,7 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) const { t } = useTranslation() const { navigate: navigatePrimary } = usePrimaryPage() const { navigateToNote } = useSmartNoteNavigation() - const { pubkey, relayList } = useNostr() + const { pubkey, relayList, cacheRelayListEvent } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilterOrDefaults() @@ -125,9 +125,9 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - userReadRelaysWithHttp(relayList), + userReadInboxUrls(relayList, cacheRelayListEvent), { - userWriteRelays: relayList?.write ?? [], + userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent), applySocialKindBlockedFilter: false } ), diff --git a/src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx b/src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx index 947031d5..55fe0d8e 100644 --- a/src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx +++ b/src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx @@ -4,7 +4,7 @@ import { ExtendedKind } from '@/constants' import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' import { filterEventsExcludingTombstones } from '@/lib/event' import { extractHashtagsFromContent, normalizeTopic } from '@/lib/discussion-topics' -import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' +import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' import { toNoteList } from '@/lib/link' import logger from '@/lib/logger' import { useSmartHashtagNavigation } from '@/PageManager' @@ -115,7 +115,7 @@ type Props = { export default function TopicKeywordHeatMap({ refreshKey }: Props) { const { t } = useTranslation() const { navigateToHashtag } = useSmartHashtagNavigation() - const { relayList } = useNostr() + const { relayList, cacheRelayListEvent } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilterOrDefaults() @@ -124,9 +124,9 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) { getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - userReadRelaysWithHttp(relayList), + userReadInboxUrls(relayList, cacheRelayListEvent), { - userWriteRelays: relayList?.write ?? [], + userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent), applySocialKindBlockedFilter: false } ), diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index d37d76fd..780bc221 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -34,7 +34,7 @@ import indexedDb from '@/services/indexed-db.service' import { ExtendedKind, FIRST_RELAY_RESULT_GRACE_MS } from '@/constants' import { filterEventsExcludingTombstones } from '@/lib/event' import { normalizeHexPubkey } from '@/lib/pubkey' -import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' +import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' import { TOMBSTONES_UPDATED_EVENT } from '@/lib/tombstone-events' import { buildSpellCatalogAuthors, @@ -80,6 +80,7 @@ const SpellsPage = forwardRef(function SpellsPage( pubkey, account, relayList, + cacheRelayListEvent, attemptDelete, bookmarkListEvent, interestListEvent, @@ -236,6 +237,7 @@ const SpellsPage = forwardRef(function SpellsPage( selectedSpell, pubkey, relayList, + cacheRelayListEvent, favoriteRelays, blockedRelays, notificationsFeedPubkey, @@ -264,8 +266,8 @@ const SpellsPage = forwardRef(function SpellsPage( const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - userReadRelaysWithHttp(relayList), - { userWriteRelays: relayList?.write ?? [] } + userReadInboxUrls(relayList, cacheRelayListEvent), + { userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent) } ) if (!feedUrls.length) { if (!cancelled) setFollowSetListEvents([]) @@ -378,8 +380,8 @@ const SpellsPage = forwardRef(function SpellsPage( } } - const urls = getRelaysForSpellCatalogSync(favoriteRelays, blockedRelays, userReadRelaysWithHttp(relayList), { - userWriteRelays: relayList?.write ?? [], + const urls = getRelaysForSpellCatalogSync(favoriteRelays, blockedRelays, userReadInboxUrls(relayList, cacheRelayListEvent), { + userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent), useGlobalRelayBootstrap }) const catalogAuthors = buildSpellCatalogAuthors(pubkey, contacts) diff --git a/src/pages/primary/SpellsPage/useSpellsPageFeed.ts b/src/pages/primary/SpellsPage/useSpellsPageFeed.ts index 0089025c..705e8255 100644 --- a/src/pages/primary/SpellsPage/useSpellsPageFeed.ts +++ b/src/pages/primary/SpellsPage/useSpellsPageFeed.ts @@ -8,7 +8,8 @@ import { normalizeUrl } from '@/lib/url' import { augmentSubRequestsWithFavoritesFastReadAndInbox, getRelayUrlsWithFavoritesFastReadAndInbox, - userReadRelaysWithHttp + userReadInboxUrls, + userWriteOutboxUrls } from '@/lib/favorites-feed-relays' import { stableSpellFeedFilterKey } from '@/lib/spell-feed-request-identity' import { isUserInEventMentions } from '@/lib/event' @@ -73,15 +74,16 @@ function buildInboxShardFollowingSubRequests(args: { authors: string[] favoriteRelays: string[] blockedRelays: string[] - relayList: { read: string[]; write: string[] } | null | undefined + relayList: { read: string[]; write: string[]; httpRead?: string[]; httpWrite?: string[] } | null | undefined + cacheRelayListEvent?: Event | null augment: (raw: TFeedSubRequest[]) => TFeedSubRequest[] }): TFeedSubRequest[] { - const { authors, favoriteRelays, blockedRelays, relayList, augment } = args + const { authors, favoriteRelays, blockedRelays, relayList, cacheRelayListEvent, augment } = args const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - userReadRelaysWithHttp(relayList), - { userWriteRelays: relayList?.write ?? [] } + userReadInboxUrls(relayList, cacheRelayListEvent), + { userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent) } ) if (!feedUrls.length) return [] const capped = authors.slice(0, FOLLOWING_INBOX_SHARD_AUTHOR_CAP) @@ -113,7 +115,8 @@ export type UseSpellsPageFeedArgs = { selectedFauxSpell: string | null selectedSpell: Event | null pubkey: string | null | undefined - relayList: { read: string[]; write: string[] } | null | undefined + relayList: { read: string[]; write: string[]; httpRead?: string[]; httpWrite?: string[] } | null | undefined + cacheRelayListEvent?: Event | null favoriteRelays: string[] blockedRelays: string[] notificationsFeedPubkey: string | null @@ -135,6 +138,7 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { selectedSpell, pubkey, relayList, + cacheRelayListEvent, favoriteRelays, blockedRelays, notificationsFeedPubkey, @@ -153,11 +157,9 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { const hideRepliesFollowing = useNoteListHideReplies() const [followingSubRequests, setFollowingSubRequests] = useState([]) - const normalizedReadSorted = relayList - ? [...relayList.read].map((u) => normalizeUrl(u) || u).filter(Boolean).sort() - : [] + const normalizedReadSorted = relayList ? [...userReadInboxUrls(relayList, cacheRelayListEvent)].sort() : [] const normalizedWriteSorted = relayList - ? [...relayList.write].map((u) => normalizeUrl(u) || u).filter(Boolean).sort() + ? [...userWriteOutboxUrls(relayList, cacheRelayListEvent)].sort() : [] const relayMailboxStableKey = @@ -220,8 +222,8 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { raw, favoriteRelays, blockedRelays, - userReadRelaysWithHttp(relayList), - { userWriteRelays: relayList?.write ?? [] } + userReadInboxUrls(relayList, cacheRelayListEvent), + { userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent) } ) try { if (selectedFauxSpell === 'following') { @@ -232,6 +234,7 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { favoriteRelays, blockedRelays, relayList, + cacheRelayListEvent, augment } const syncProvisional = buildInboxShardFollowingSubRequests({ @@ -294,6 +297,7 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { favoriteRelays, blockedRelays, relayList, + cacheRelayListEvent, augment }) if (!cancelled && syncReq.length > 0) setFollowingSubRequests(syncReq) @@ -310,6 +314,7 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { favoriteRelays, blockedRelays, relayList, + cacheRelayListEvent, augment }) if (!cancelled) setFollowingSubRequests(req) @@ -329,6 +334,7 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { sortedFavoriteRelaysKey, sortedBlockedRelaysKey, relayMailboxStableKey, + cacheRelayListEvent, followSetCatalogLoading, followSetListStableKey, followListEvent?.id, @@ -399,9 +405,9 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - userReadRelaysWithHttp(relayList), + userReadInboxUrls(relayList, cacheRelayListEvent), { - userWriteRelays: relayList?.write ?? [], + userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent), applySocialKindBlockedFilter: fauxSpellSkipSocialKindBlocked ? false : undefined } ) @@ -478,7 +484,7 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { const spellSubRequests = useMemo(() => { if (!selectedSpell) return [] - const relayListWrite = relayList?.write ?? [] + const relayListWrite = userWriteOutboxUrls(relayList, cacheRelayListEvent) const ctx = { pubkey: pubkey ?? null, contacts } const filter = spellEventToFilter(selectedSpell, ctx) if (!filter) return [] diff --git a/src/pages/secondary/EmojiSetsSettingsPage/index.tsx b/src/pages/secondary/EmojiSetsSettingsPage/index.tsx index fdb62b07..1b334297 100644 --- a/src/pages/secondary/EmojiSetsSettingsPage/index.tsx +++ b/src/pages/secondary/EmojiSetsSettingsPage/index.tsx @@ -32,10 +32,7 @@ import { randomString } from '@/lib/random' import { showPublishingError } from '@/lib/publishing-feedback' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' -import { - getRelayUrlsWithFavoritesFastReadAndInbox, - userReadRelaysWithHttp -} from '@/lib/favorites-feed-relays' +import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' import { createEmojiSetDraftEvent } from '@/lib/draft-event' import { filterEventsExcludingTombstones } from '@/lib/event' import logger from '@/lib/logger' @@ -63,7 +60,7 @@ const EMOJI_SET_FETCH_OPTS = { const EmojiSetsSettingsPage = forwardRef( ({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const { t } = useTranslation() - const { pubkey, account, publish, attemptDelete, checkLogin, relayList, userEmojiListEvent, profileEvent } = + const { pubkey, account, publish, attemptDelete, checkLogin, relayList, cacheRelayListEvent, userEmojiListEvent, profileEvent } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const [lists, setLists] = useState([]) @@ -91,11 +88,11 @@ const EmojiSetsSettingsPage = forwardRef( const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - userReadRelaysWithHttp(relayList), - { userWriteRelays: relayList?.write ?? [] } + userReadInboxUrls(relayList, cacheRelayListEvent), + { userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent) } ) return appendCuratedReadOnlyRelays(feedUrls, blockedRelays) - }, [favoriteRelays, blockedRelays, relayList]) + }, [favoriteRelays, blockedRelays, relayList, cacheRelayListEvent]) const loadLists = useCallback(async () => { if (!pubkey) { diff --git a/src/pages/secondary/FollowSetsSettingsPage/index.tsx b/src/pages/secondary/FollowSetsSettingsPage/index.tsx index 4c00120b..19ea7514 100644 --- a/src/pages/secondary/FollowSetsSettingsPage/index.tsx +++ b/src/pages/secondary/FollowSetsSettingsPage/index.tsx @@ -34,10 +34,7 @@ import { randomString } from '@/lib/random' import { showPublishingError } from '@/lib/publishing-feedback' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' -import { - getRelayUrlsWithFavoritesFastReadAndInbox, - userReadRelaysWithHttp -} from '@/lib/favorites-feed-relays' +import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' import { createFollowSetDraftEvent } from '@/lib/draft-event' import { filterEventsExcludingTombstones } from '@/lib/event' import logger from '@/lib/logger' @@ -62,7 +59,7 @@ const FOLLOW_SET_FETCH_OPTS = { const FollowSetsSettingsPage = forwardRef( ({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const { t } = useTranslation() - const { pubkey, account, publish, attemptDelete, checkLogin, relayList } = useNostr() + const { pubkey, account, publish, attemptDelete, checkLogin, relayList, cacheRelayListEvent } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const [lists, setLists] = useState([]) const [loading, setLoading] = useState(true) @@ -87,8 +84,8 @@ const FollowSetsSettingsPage = forwardRef( const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - userReadRelaysWithHttp(relayList), - { userWriteRelays: relayList?.write ?? [] } + userReadInboxUrls(relayList, cacheRelayListEvent), + { userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent) } ) return appendCuratedReadOnlyRelays(feedUrls, blockedRelays) }, [favoriteRelays, blockedRelays, relayList]) diff --git a/src/pages/secondary/NoteListPage/index.tsx b/src/pages/secondary/NoteListPage/index.tsx index 6800377b..6fa0dc66 100644 --- a/src/pages/secondary/NoteListPage/index.tsx +++ b/src/pages/secondary/NoteListPage/index.tsx @@ -13,7 +13,8 @@ import { import { augmentSubRequestsWithFavoritesFastReadAndInbox, getRelayUrlsWithFavoritesFastReadAndInbox, - userReadRelaysWithHttp + userReadInboxUrls, + userWriteOutboxUrls } from '@/lib/favorites-feed-relays' import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' @@ -47,7 +48,7 @@ const NoteListPage = forwardRef(({ index, hid const feedRef = useRef(null) const bumpFeed = useCallback(() => feedRef.current?.refresh(), []) const { push } = useSecondaryPage() - const { relayList, pubkey } = useNostr() + const { relayList, cacheRelayListEvent, pubkey } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults() const interestList = useInterestListOptional() @@ -111,7 +112,7 @@ const NoteListPage = forwardRef(({ index, hid .map((k) => parseInt(k)) .filter((k) => !isNaN(k)) const readUrlOpts = { - userWriteRelays: relayList?.write ?? [], + userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent), applySocialKindBlockedFilter: kinds.length === 0 || kinds.some(isSocialKindBlockedKind), useGlobalFavoriteDefaults: useGlobalRelayBootstrap, includeGlobalFastRead: useGlobalRelayBootstrap @@ -124,7 +125,7 @@ const NoteListPage = forwardRef(({ index, hid const relayUrls = getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - userReadRelaysWithHttp(relayList), + userReadInboxUrls(relayList, cacheRelayListEvent), readUrlOpts ) const mergedSearchKinds = Array.from( @@ -166,7 +167,7 @@ const NoteListPage = forwardRef(({ index, hid urls: getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - userReadRelaysWithHttp(relayList), + userReadInboxUrls(relayList, cacheRelayListEvent), readUrlOpts ) } @@ -209,8 +210,8 @@ const NoteListPage = forwardRef(({ index, hid urls: getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - userReadRelaysWithHttp(relayList), - { userWriteRelays: relayList?.write ?? [], useGlobalFavoriteDefaults: useGlobalRelayBootstrap, includeGlobalFastRead: useGlobalRelayBootstrap } + userReadInboxUrls(relayList, cacheRelayListEvent), + { userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent), useGlobalFavoriteDefaults: useGlobalRelayBootstrap, includeGlobalFastRead: useGlobalRelayBootstrap } ) } ]) @@ -241,9 +242,9 @@ const NoteListPage = forwardRef(({ index, hid raw, favoriteRelays, blockedRelays, - userReadRelaysWithHttp(relayList), + userReadInboxUrls(relayList, cacheRelayListEvent), { - userWriteRelays: relayList?.write ?? [], + userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent), useGlobalFavoriteDefaults: useGlobalRelayBootstrap, includeGlobalFastRead: useGlobalRelayBootstrap } @@ -272,7 +273,7 @@ const NoteListPage = forwardRef(({ index, hid const relayUrls = getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - userReadRelaysWithHttp(relayList), + userReadInboxUrls(relayList, cacheRelayListEvent), readUrlOpts ) const mergedReqKinds = Array.from( diff --git a/src/pages/secondary/RelayReviewsPage/index.tsx b/src/pages/secondary/RelayReviewsPage/index.tsx index fc40a164..0d6bb4d4 100644 --- a/src/pages/secondary/RelayReviewsPage/index.tsx +++ b/src/pages/secondary/RelayReviewsPage/index.tsx @@ -4,10 +4,7 @@ import { RefreshButton } from '@/components/RefreshButton' import { ExtendedKind } from '@/constants' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' -import { - getRelayUrlsWithFavoritesFastReadAndInbox, - userReadRelaysWithHttp -} from '@/lib/favorites-feed-relays' +import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls } from '@/lib/favorites-feed-relays' import { relayReviewDTagsForRelayUrl, relayReviewsFeedSnapshotKey } from '@/lib/relay-review-feed' import { normalizeRelayUrlForPage, simplifyUrl } from '@/lib/url' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -24,7 +21,7 @@ const RelayReviewsPage = forwardRef(({ url, index, hideTitlebar = false }: { url const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const feedRef = useRef(null) const bumpFeed = useCallback(() => feedRef.current?.refresh(), []) - const { relayList } = useNostr() + const { relayList, cacheRelayListEvent } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() useEffect(() => { @@ -52,10 +49,10 @@ const RelayReviewsPage = forwardRef(({ url, index, hideTitlebar = false }: { url const base = getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - userReadRelaysWithHttp(relayList) + userReadInboxUrls(relayList, cacheRelayListEvent) ) return [...new Set([normalizedUrl, ...base])] - }, [normalizedUrl, favoriteRelays, blockedRelays, relayList]) + }, [normalizedUrl, favoriteRelays, blockedRelays, relayList, cacheRelayListEvent]) const reviewsSubRequests = useMemo(() => { if (!normalizedUrl || relayReviewDTags.length === 0) return [] return [ diff --git a/src/providers/FeedProvider.tsx b/src/providers/FeedProvider.tsx index 01b52642..ceed3f88 100644 --- a/src/providers/FeedProvider.tsx +++ b/src/providers/FeedProvider.tsx @@ -1,12 +1,14 @@ import { DEFAULT_FAVORITE_RELAYS } from '@/constants' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' -import { getRelayListFromEvent, getHttpRelayListFromEvent } from '@/lib/event-metadata' import { buildAllFavoritesFeedRelayUrls, stripNostrLandAggrFromRelayUrls } from '@/lib/home-feed-relays' import logger from '@/lib/logger' import { syncViewerRelayStackNostrLandAggrEligible, urlsForViewerNostrLandAggrEligibilitySync } from '@/lib/nostr-land-relay-eligibility' +import { collectUserReadInboxUrls } from '@/lib/viewer-read-inboxes' +import { collectUserWriteOutboxUrls } from '@/lib/viewer-write-outboxes' +import { getCacheRelayUrlsFromEvent } from '@/lib/private-relays' import { normalizeAnyRelayUrl } from '@/lib/url' import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' @@ -54,7 +56,7 @@ function buildHomeReplyFeedRelayUrls( } export function FeedProvider({ children }: { children: ReactNode }) { - const { isInitialized, relayList, cacheRelayListEvent, httpRelayListEvent, pubkey } = useNostr() + const { isInitialized, relayList, cacheRelayListEvent, pubkey } = useNostr() const { favoriteRelays, blockedRelays, relaySets } = useFavoriteRelays() const useGlobalRelayDefaults = useMemo( @@ -80,35 +82,33 @@ export function FeedProvider({ children }: { children: ReactNode }) { /** Read-side layers merged into {@link replyRelayUrls}; {@link outboxRelayUrls} is only for aggr eligibility sync. */ const replyExtraRelayLayers = useMemo(() => { - const cacheRelayUrls: string[] = [] - if (cacheRelayListEvent) { - const list = getRelayListFromEvent(cacheRelayListEvent, blockedRelays, { - globalReadWriteFallback: useGlobalRelayDefaults - }) - cacheRelayUrls.push(...list.read) - } + const cacheRelayUrls = getCacheRelayUrlsFromEvent(cacheRelayListEvent) - const httpRelayUrls: string[] = [...(relayList?.httpRead ?? [])] - if (httpRelayListEvent) { - const list = getHttpRelayListFromEvent(httpRelayListEvent, blockedRelays) - httpRelayUrls.push(...list.httpRead) - } + const hasReadMailbox = + cacheRelayUrls.length > 0 || + (relayList?.read?.length ?? 0) > 0 || + (relayList?.httpRead?.length ?? 0) > 0 + const hasWriteMailbox = + cacheRelayUrls.length > 0 || + (relayList?.write?.length ?? 0) > 0 || + (relayList?.httpWrite?.length ?? 0) > 0 return { - inboxRelayUrls: relayList?.read?.length - ? relayList.read + inboxRelayUrls: hasReadMailbox + ? collectUserReadInboxUrls(relayList, cacheRelayUrls) : useGlobalRelayDefaults ? DEFAULT_FAVORITE_RELAYS : [], - outboxRelayUrls: relayList?.write?.length - ? relayList.write + outboxRelayUrls: hasWriteMailbox + ? collectUserWriteOutboxUrls(relayList, cacheRelayUrls) : useGlobalRelayDefaults ? DEFAULT_FAVORITE_RELAYS : [], - cacheRelayUrls, - httpRelayUrls + /** Kept for feed-layer identity / aggr sync; URLs are merged into inbox/outbox above. */ + cacheRelayUrls: [] as string[], + httpRelayUrls: [] as string[] } - }, [relayList, cacheRelayListEvent, httpRelayListEvent, blockedRelays, useGlobalRelayDefaults]) + }, [relayList, cacheRelayListEvent, useGlobalRelayDefaults]) /** Default relays immediately so feeds / sidebar REQ never wait on Nostr session restore. */ const [relayUrls, setRelayUrls] = useState(() => diff --git a/src/providers/LiveActivitiesProvider.tsx b/src/providers/LiveActivitiesProvider.tsx index fbcb6f6a..0a307074 100644 --- a/src/providers/LiveActivitiesProvider.tsx +++ b/src/providers/LiveActivitiesProvider.tsx @@ -8,7 +8,7 @@ import { resolveParentSpacesForLiveActivities, type TLiveActivityItem } from '@/lib/live-activities' -import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' +import { userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' import logger from '@/lib/logger' import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import client from '@/services/client.service' @@ -22,7 +22,7 @@ import { useNostr } from './NostrProvider' import { useUserPreferencesOptional } from './UserPreferencesProvider' export function LiveActivitiesProvider({ children }: { children: React.ReactNode }) { - const { pubkey, relayList, isInitialized, isAccountSessionHydrating } = useNostr() + const { pubkey, relayList, cacheRelayListEvent, isInitialized, isAccountSessionHydrating } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const followListCtx = useFollowListOptional() const followings = followListCtx?.followings ?? [] @@ -50,8 +50,14 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode /** Collapse boot + session-prewarm + StrictMode into one network pass. */ const LIVE_ACTIVITIES_MIN_REFRESH_GAP_MS = 8_000 - const relayRead = useMemo(() => userReadRelaysWithHttp(relayList), [relayList]) - const relayWrite = relayList?.write ?? [] + const relayRead = useMemo( + () => userReadInboxUrls(relayList, cacheRelayListEvent), + [relayList, cacheRelayListEvent] + ) + const relayWrite = useMemo( + () => userWriteOutboxUrls(relayList, cacheRelayListEvent), + [relayList, cacheRelayListEvent] + ) const refresh = useCallback(async () => { if (!showLiveActivitiesBanner) { diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 9e181eb2..0a63cdf5 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -1844,6 +1844,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { await indexedDb.putReplaceableEvent(httpRelayEvent) if (account?.pubkey) { client.clearRelayListCache(account.pubkey) + await client.syncViewerPersonalRelayKeys(account.pubkey) } setHttpRelayListEvent(httpRelayEvent) const mergedRelayList = await client.fetchRelayList(account?.pubkey || '') diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 17858a32..0d055c1c 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -36,6 +36,17 @@ import { } from '@/constants' import { getCacheRelayUrls } from '@/lib/private-relays' +import { + collectReadInboxUrlsFromRelayList, + collectRemoteReadInboxUrlsFromRelayList, + collectUserReadInboxUrls, + collectViewerReadInboxUrls +} from '@/lib/viewer-read-inboxes' +import { + collectUserWriteOutboxUrls, + collectViewerWriteOutboxUrls, + collectWriteOutboxUrlsFromRelayList +} from '@/lib/viewer-write-outboxes' import { buildPersonalRelayKeySet, sanitizeRelayUrlsForFetch, @@ -141,8 +152,7 @@ import { filterRelaysForEventPublish } from '@/lib/relay-publish-filter' import { getPaymentAttestationTargetId } from '@/lib/superchat' import { buildPublicMessagePublishRelayUrls, - collectRecipientInboxUrls, - collectSenderOutboxUrls + collectRecipientInboxUrls } from '@/lib/public-message-publish-relays' import { buildPrioritizedWriteRelayUrls, dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority' import { @@ -173,6 +183,7 @@ import { isWebsocketUrl, normalizeAnyRelayUrl, normalizeHttpRelayUrl, + normalizeRelayUrlByScheme, normalizeUrl, simplifyUrl, urlMatchesConfiguredHttpIndexRelay @@ -626,12 +637,11 @@ class ClientService extends EventTarget { const extra: string[] = [] if (pubkey) { const rl = await this.peekRelayListFromStorage(pubkey) - extra.push( - ...(rl.read ?? []), - ...(rl.write ?? []), - ...(rl.httpRead ?? []), - ...(rl.httpWrite ?? []) - ) + const [readInboxes, writeOutboxes] = await Promise.all([ + collectViewerReadInboxUrls(pubkey, rl), + collectViewerWriteOutboxUrls(pubkey, rl) + ]) + extra.push(...readInboxes, ...writeOutboxes) } await preloadGifsIntoIdbCache(pubkey, extra) } @@ -752,6 +762,35 @@ class ClientService extends EventTarget { return { all, httpIndexBases, cacheRelayEvent } } + /** + * Kind 10243 bases used to route publish targets through the HTTP index API (not WebSocket). + * Refreshes from IndexedDB when the batch includes https targets so reactions/posts work right + * after saving kind 10243 without waiting for a full account re-sync. + */ + private async resolveViewerHttpIndexBasesForPublish( + publishTargetUrls: readonly string[] + ): Promise { + const hasHttpTarget = publishTargetUrls.some((u) => isKind10243HttpRelayTagUrl(u.trim())) + if (!hasHttpTarget) return this.viewerHttpIndexRelayBases + + const pk = this.pubkey?.trim() + if (!pk) return this.viewerHttpIndexRelayBases + + try { + const rl = await this.peekRelayListFromStorage(pk) + const fresh = [...(rl.httpRead ?? []), ...(rl.httpWrite ?? [])] + .map((u) => normalizeHttpRelayUrl(u) || u) + .filter(Boolean) + if (fresh.length > 0) { + this.viewerHttpIndexRelayBases = fresh + return fresh + } + } catch { + /* keep session cache */ + } + return this.viewerHttpIndexRelayBases + } + /** Kind 10012 + embedded NIP-51 relay sets from IndexedDB only (no network). */ async fetchFavoriteRelaysFromStorage(pubkey: string): Promise { try { @@ -858,48 +897,20 @@ class ClientService extends EventTarget { const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) return dedupeNormalizeRelayUrlsOrdered( filterRelaysForEventPublish(relays, event.kind).filter((url) => { - const n = normalizeAnyRelayUrl(url) || url + const n = normalizeRelayUrlByScheme(url) || url if (isSocialKindBlockedKind(event.kind) && socialKindBlockedSet.has(n)) return false return true }) ) } - /** - * Author kind 0 / 10133 publish: NIP-65 WS outbox + HTTP write (10243) + cache relays (10432). - * {@link fetchRelayList} usually merges cache into `write`; this also appends 10432 tags when missing. - */ - private async resolveFullMailboxWriteUrlsForPublish( - pubkey: string, - relayList: TRelayList - ): Promise { - const ws = (relayList.write ?? []) - .map((u) => normalizeUrl(u) || u) - .filter((u): u is string => !!u) - const http = (relayList.httpWrite ?? []) - .map((u) => normalizeHttpRelayUrl(u) || u) - .filter((u): u is string => !!u) - let merged = dedupeNormalizeRelayUrlsOrdered([...http, ...ws]) - try { - const cache = await getCacheRelayUrls(pubkey) - if (cache.length > 0) { - merged = dedupeNormalizeRelayUrlsOrdered([...merged, ...cache]) - } - } catch { - /* ignore */ - } - return merged - } - private relayListHasWriteUrls(relayList: TRelayList): boolean { - return (relayList.write?.length ?? 0) > 0 || (relayList.httpWrite?.length ?? 0) > 0 + return collectUserWriteOutboxUrls(relayList).length > 0 } private relayListUsableForInboxOrdering(relayList: TRelayList): boolean { return ( - this.relayListHasWriteUrls(relayList) || - (relayList.read?.length ?? 0) > 0 || - (relayList.httpRead?.length ?? 0) > 0 + this.relayListHasWriteUrls(relayList) || collectUserReadInboxUrls(relayList).length > 0 ) } @@ -914,23 +925,12 @@ class ClientService extends EventTarget { return this.fetchRelayListWithPublishTimeout(pubkey) } - /** NIP-65 `write` URLs for `event.pubkey`, filtered for publish (no read-only / social-kind blocks). */ + /** NIP-65 / 10243 / 10432 write outboxes for `event.pubkey`, filtered for publish. */ private async getUserOutboxRelayUrlsForPublish(event: NEvent): Promise { try { const relayList = await this.peekOrFetchRelayListForPublish(event.pubkey) - if (!this.relayListHasWriteUrls(relayList)) { - return [] - } - const raw = isAuthorProfileMetadataPublishKind(event.kind) - ? await this.resolveFullMailboxWriteUrlsForPublish(event.pubkey, relayList) - : dedupeNormalizeRelayUrlsOrdered([ - ...(relayList.httpWrite ?? []) - .map((u) => normalizeHttpRelayUrl(u) || u) - .filter((u): u is string => !!u), - ...(relayList.write ?? []) - .map((u) => normalizeUrl(u) || u) - .filter((u): u is string => !!u) - ]) + const raw = await collectViewerWriteOutboxUrls(event.pubkey, relayList) + if (raw.length === 0) return [] return this.filterPublishingRelays(raw, event) } catch { return [] @@ -942,7 +942,7 @@ class ClientService extends EventTarget { userOutboxUrls: string[], relayStatuses: { url: string; success: boolean; error?: string }[] ): Promise { - const norm = (u: string) => normalizeAnyRelayUrl(u) || u + const norm = (u: string) => normalizeRelayUrlByScheme(u) || u const hadSuccess = new Set() for (const r of relayStatuses) { if (r.success) hadSuccess.add(norm(r.url)) @@ -1191,15 +1191,8 @@ class ClientService extends EventTarget { } // For Report events, always include user's write relays first, then add seen relays if they're write-capable if (event.kind === kinds.Report) { - // Start with user's write relays (outboxes) - these are the primary targets for reports const relayList = await this.fetchRelayListWithPublishTimeout(event.pubkey) - const reportHttpWrites = (relayList?.httpWrite ?? []) - .map((url) => normalizeHttpRelayUrl(url) || url) - .filter((u): u is string => !!u) - const reportWsWrites = (relayList?.write ?? []) - .map((url) => normalizeUrl(url) || url) - .filter((u): u is string => !!u) - const userWriteRelays = dedupeNormalizeRelayUrlsOrdered([...reportHttpWrites, ...reportWsWrites]) + const userWriteRelays = await collectViewerWriteOutboxUrls(event.pubkey, relayList) // Get seen relays where the reported event was found const targetEventId = event.tags.find(tagNameEquals('e'))?.[1] @@ -1209,9 +1202,9 @@ class ClientService extends EventTarget { const allSeenRelays = this.getSeenEventRelayUrls(targetEventId) // Filter seen relays: only include those that are in user's write list // This ensures we don't try to publish to read-only relays - const userWriteRelaySet = new Set(userWriteRelays.map(url => normalizeAnyRelayUrl(url) || url)) + const userWriteRelaySet = new Set(userWriteRelays.map((url) => normalizeRelayUrlByScheme(url) || url)) seenRelays.push(...allSeenRelays.filter(url => { - const normalized = normalizeAnyRelayUrl(url) || url + const normalized = normalizeRelayUrlByScheme(url) || url return userWriteRelaySet.has(normalized) })) } @@ -1273,7 +1266,7 @@ class ClientService extends EventTarget { this.fetchRelayListWithPublishTimeout(event.pubkey), recipientListsPromise ]) - const authorWrite = collectSenderOutboxUrls(authorRelayList) + const authorWrite = await collectViewerWriteOutboxUrls(event.pubkey, authorRelayList) const recipientRead = dedupeNormalizeRelayUrlsOrdered( recipientRelayLists.flatMap((rl) => collectRecipientInboxUrls(rl)) ) @@ -1303,7 +1296,7 @@ class ClientService extends EventTarget { ? this.fetchRelayListsWithPublishTimeout(senderPubkeys) : Promise.resolve([] as TRelayList[]) ]) - const authorWrite = collectSenderOutboxUrls(authorRelayList) + const authorWrite = await collectViewerWriteOutboxUrls(event.pubkey, authorRelayList) const authorRead = collectRecipientInboxUrls(authorRelayList) const senderInboxes = dedupeNormalizeRelayUrlsOrdered( senderRelayLists.flatMap((rl) => collectRecipientInboxUrls(rl)) @@ -1351,16 +1344,10 @@ class ClientService extends EventTarget { }) spellRelayList = this.emptyRelayListForPublish() } - const spellHttpWrites = (spellRelayList?.httpWrite ?? []) - .map((url) => normalizeHttpRelayUrl(url)) - .filter((url): url is string => !!url) - const spellWsWrites = (spellRelayList?.write ?? []) - .map((url) => normalizeUrl(url)) - .filter((url): url is string => !!url) - const normalizedWrite = dedupeNormalizeRelayUrlsOrdered([...spellHttpWrites, ...spellWsWrites]) + const spellWriteFilteredRaw = await collectViewerWriteOutboxUrls(event.pubkey, spellRelayList) const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u)) - const spellWriteFiltered = normalizedWrite.filter((url) => { - const n = normalizeAnyRelayUrl(url) || url + const spellWriteFiltered = spellWriteFilteredRaw.filter((url) => { + const n = normalizeRelayUrlByScheme(url) || url return !readOnlySet.has(n) }) return this.filterPublishingRelays( @@ -1395,14 +1382,7 @@ class ClientService extends EventTarget { const relayListPromise = this.fetchRelayListWithPublishTimeout(event.pubkey) const [relayLists, relayList] = await Promise.all([relayListsPromise, relayListPromise]) relayLists.forEach((rl) => { - for (const u of rl.httpRead ?? []) { - const n = normalizeHttpRelayUrl(u) || u - if (n) authorInboxFromContext.push(n) - } - for (const u of rl.read ?? []) { - const n = normalizeUrl(u) || u - if (n) authorInboxFromContext.push(n) - } + authorInboxFromContext.push(...collectRemoteReadInboxUrlsFromRelayList(rl)) }) if ( isAuthorProfileMetadataPublishKind(event.kind) || @@ -1461,15 +1441,10 @@ class ClientService extends EventTarget { writeRelays: relayList?.write?.slice(0, MAX_PUBLISH_RELAYS) ?? [] }) } - const wsWrites = (relayList?.write ?? []) - .map((u) => normalizeUrl(u) || u) - .filter((u): u is string => !!u) - const httpWrites = (relayList?.httpWrite ?? []) - .map((u) => normalizeHttpRelayUrl(u) || u) - .filter((u): u is string => !!u) - const userWritesOrdered = isAuthorProfileMetadataPublishKind(event.kind) - ? await this.resolveFullMailboxWriteUrlsForPublish(event.pubkey, relayList ?? this.emptyRelayListForPublish()) - : dedupeNormalizeRelayUrlsOrdered([...httpWrites, ...wsWrites]) + const userWritesOrdered = await collectViewerWriteOutboxUrls( + event.pubkey, + relayList ?? this.emptyRelayListForPublish() + ) relays = this.filterPublishingRelays( buildPrioritizedWriteRelayUrls({ userWriteRelays: userWritesOrdered, @@ -1668,7 +1643,7 @@ class ClientService extends EventTarget { const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) let filtered = filterRelaysForEventPublish(mergedRelayUrls, event.kind).filter((url) => { - const n = normalizeAnyRelayUrl(url) || url + const n = normalizeRelayUrlByScheme(url) || url if (isSocialKindBlockedKind(event.kind) && socialKindBlockedSet.has(n)) return false return true }) @@ -1762,6 +1737,8 @@ class ClientService extends EventTarget { const publishOpBatch = new RelayPublishOpBatch(publishBatchSource, event.id, publishTargetUrls) publishOpBatch.logBegin() + const httpIndexBasesForPublish = await this.resolveViewerHttpIndexBasesForPublish(publishTargetUrls) + // eslint-disable-next-line @typescript-eslint/no-this-alias const client = this return new Promise<{ success: boolean; relayStatuses: typeof relayStatuses; successCount: number; totalCount: number }>((resolve) => { @@ -1894,7 +1871,7 @@ class ClientService extends EventTarget { }, connectionTimeout + publishAckBudgetMs + 2_000) try { - if (urlMatchesConfiguredHttpIndexRelay(url, this.viewerHttpIndexRelayBases)) { + if (urlMatchesConfiguredHttpIndexRelay(url, httpIndexBasesForPublish)) { const base = normalizeHttpRelayUrl(url) || url logger.debug(`[PublishEvent] Publishing to kind 10243 HTTP index relay`, { url: base }) await Promise.race([ @@ -2033,7 +2010,7 @@ class ClientService extends EventTarget { } } catch (error) { const softHttpDown = - urlMatchesConfiguredHttpIndexRelay(url, this.viewerHttpIndexRelayBases) && + urlMatchesConfiguredHttpIndexRelay(url, httpIndexBasesForPublish) && (error instanceof IndexRelayTransportError || isIndexRelayTransportFailure(error)) if (softHttpDown) { logger.debug('[PublishEvent] HTTP index relay unreachable', { @@ -4551,9 +4528,7 @@ class ClientService extends EventTarget { */ async getMailboxStackWriteUrlsForRepublish(pubkey: string): Promise { const rl = await this.peekRelayListFromStorage(pubkey) - const ws = (rl.write ?? []).map((u) => normalizeUrl(u) || u).filter((u): u is string => !!u) - const http = (rl.httpWrite ?? []).map((u) => normalizeHttpRelayUrl(u) || u).filter((u): u is string => !!u) - return dedupeNormalizeRelayUrlsOrdered([...http, ...ws]) + return collectViewerWriteOutboxUrls(pubkey, rl) } /** Newest kind 10002 for `pubkey` from IndexedDB and/or session LRU (session may hold a copy not persisted yet). */ @@ -5069,8 +5044,8 @@ class ClientService extends EventTarget { if (!/^[0-9a-f]{64}$/.test(pk)) return [] const relayList = await this.fetchRelayList(pk) const urls = dedupeNormalizeRelayUrlsOrdered([ - ...relayList.write.map((u) => normalizeUrl(u) || u), - ...relayList.read.map((u) => normalizeUrl(u) || u), + ...collectWriteOutboxUrlsFromRelayList(relayList), + ...collectReadInboxUrlsFromRelayList(relayList), ...FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u), ...PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u) ]).filter(Boolean) @@ -5127,7 +5102,9 @@ class ClientService extends EventTarget { let urls = [...publicReadRelayFallbackUrls()] if (myPubkey) { const relayList = await this.fetchRelayList(myPubkey) - urls = relayList.read.concat([...publicReadRelayFallbackUrls()]).slice(0, 5) + urls = collectReadInboxUrlsFromRelayList(relayList) + .concat([...publicReadRelayFallbackUrls()]) + .slice(0, 5) } return [{ urls, filter: { authors: pubkeys } }] } diff --git a/src/services/relay-selection.service.ts b/src/services/relay-selection.service.ts index 0eacddcb..9f93030c 100644 --- a/src/services/relay-selection.service.ts +++ b/src/services/relay-selection.service.ts @@ -1,10 +1,8 @@ import { Event, kinds } from 'nostr-tools' import { ExtendedKind, FAST_WRITE_RELAY_URLS, RANDOM_PUBLISH_RELAY_COUNT } from '@/constants' import { filterRelaysForEventPublish } from '@/lib/relay-publish-filter' -import { - collectRecipientInboxUrls, - collectSenderOutboxUrls -} from '@/lib/public-message-publish-relays' +import { collectRecipientInboxUrls, collectSenderOutboxUrls } from '@/lib/public-message-publish-relays' +import { collectViewerWriteOutboxUrls } from '@/lib/viewer-write-outboxes' import storage from '@/services/local-storage.service' import { NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX } from '@/lib/content-patterns' import client from '@/services/client.service' @@ -20,6 +18,7 @@ import logger from '@/lib/logger' import indexedDb from '@/services/indexed-db.service' import { getHttpRelayListFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import { stripLocalNetworkRelaysFromRelayList } from '@/lib/relay-list-sanitize' +import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority' import nip66Service from '@/services/nip66.service' export interface RelaySelectionContext { @@ -68,6 +67,18 @@ class RelaySelectionService { * Filter out local network relays from other users' relay lists * We should only use our own local relays, not other users' local relays */ + private normRelay(url: string): string { + return normalizeRelayUrlByScheme(url) || url.trim() + } + + /** Kind 10002 + 10243 write/both outboxes for the logged-in user. */ + private userWriteOutboxRelays(context: RelaySelectionContext): string[] { + return dedupeNormalizeRelayUrlsOrdered([ + ...(context.userHttpWriteRelays ?? []), + ...context.userWriteRelays + ]) + } + private filterLocalRelaysFromOthers(relays: string[], isOwnRelays: boolean = false): string[] { if (isOwnRelays) { // For our own relays, keep all of them including local ones @@ -133,7 +144,7 @@ class RelaySelectionService { .filter(Boolean) ) filtered.forEach((url) => { - relayTypes[url] = httpSet.has(url) ? 'http_relay_list' : 'relay_list' + relayTypes[url] = httpSet.has(canonicalRelaySessionKey(url)) ? 'http_relay_list' : 'relay_list' }) return { relays: filtered, relayTypes, randomRelayUrls: [] } } @@ -396,7 +407,6 @@ class RelaySelectionService { context: RelaySelectionContext ): Promise { const { - userWriteRelays, parentEvent, isPublicMessage, openFrom, @@ -406,11 +416,12 @@ class RelaySelectionService { let selectedRelays: string[] = [] - const norm = (url: string) => normalizeAnyRelayUrl(url) || url + const userOutboxes = this.userWriteOutboxRelays(context) + const defaultOutboxes = userOutboxes.length > 0 ? userOutboxes : FAST_WRITE_RELAY_URLS // If called with specific relay URLs, use those if (openFrom && openFrom.length > 0) { - selectedRelays = Array.from(new Set(openFrom.map(norm).filter(Boolean))) + selectedRelays = Array.from(new Set(openFrom.map((url) => this.normRelay(url)).filter(Boolean))) } // For discussion replies, use relay hints from the kind 11 + user's outboxes + local relays + thecitadel else if (parentEvent && (parentEvent.kind === ExtendedKind.DISCUSSION || parentEvent.kind === ExtendedKind.COMMENT)) { @@ -423,8 +434,7 @@ class RelaySelectionService { } // For regular replies, use user's write relays + mention relays else if (parentEvent && this.isRegularReply(parentEvent)) { - const userRelays = userWriteRelays.length > 0 ? userWriteRelays : FAST_WRITE_RELAY_URLS - selectedRelays = Array.from(new Set(userRelays.map(norm).filter(Boolean))) + selectedRelays = Array.from(new Set(defaultOutboxes.map((url) => this.normRelay(url)).filter(Boolean))) // Add mention relays if (userPubkey) { @@ -441,28 +451,27 @@ class RelaySelectionService { try { const relayList = await this.getCachedRelayList(pubkey) if (!relayList) return [] - return this.filterLocalRelaysFromOthers(relayList.write || []) + return this.filterLocalRelaysFromOthers(collectSenderOutboxUrls(relayList)) } catch (error) { logger.warn('Failed to get cached relay list', { pubkey, error }) return [] } }) ) - const mentionRelays = mentionRelayLists.flat().map(norm).filter(Boolean) + const mentionRelays = mentionRelayLists.flat().map((url) => this.normRelay(url)).filter(Boolean) selectedRelays = Array.from(new Set([...selectedRelays, ...mentionRelays])) } } } // Default: user's write relays (or fallback to fast write relays if no user relays) else { - const defaultRelays = userWriteRelays.length > 0 ? userWriteRelays : FAST_WRITE_RELAY_URLS - selectedRelays = Array.from(new Set(defaultRelays.map(norm).filter(Boolean))) + selectedRelays = Array.from(new Set(defaultOutboxes.map((url) => this.normRelay(url)).filter(Boolean))) } // ALWAYS include cache relays (local network relays) in selected relays - const cacheRelays = userWriteRelays.filter(url => isLocalNetworkUrl(url)) + const cacheRelays = context.userWriteRelays.filter(url => isLocalNetworkUrl(url)) if (cacheRelays.length > 0) { - selectedRelays = Array.from(new Set([...selectedRelays, ...cacheRelays.map(norm).filter(Boolean)])) + selectedRelays = Array.from(new Set([...selectedRelays, ...cacheRelays.map((url) => this.normRelay(url)).filter(Boolean)])) } // When "add random relays" setting is ON, include random relays in selected by default; when OFF they are still in the list but unchecked @@ -498,20 +507,21 @@ class RelaySelectionService { if (senderRelays.length === 0) { try { const userRelayList = await this.getCachedRelayList(userPubkey) - senderRelays = collectSenderOutboxUrls(userRelayList) + if (userRelayList) { + senderRelays = await collectViewerWriteOutboxUrls(userPubkey, userRelayList) + } } catch (error) { logger.warn('Failed to fetch user relay list for PM', { error, userPubkey }) } } senderRelays.forEach(url => { - const normalized = normalizeAnyRelayUrl(url) - if (normalized) { - if (!relayToMembers.has(normalized)) { - relayToMembers.set(normalized, new Set()) - } - relayToMembers.get(normalized)!.add(userPubkey) + const normalized = this.normRelay(url) + if (!normalized) return + if (!relayToMembers.has(normalized)) { + relayToMembers.set(normalized, new Set()) } + relayToMembers.get(normalized)!.add(userPubkey) }) } @@ -561,13 +571,12 @@ class RelaySelectionService { recipientRelayLists.forEach((relays, index) => { const pubkey = recipientPubkeys[index] relays.forEach(url => { - const normalized = normalizeAnyRelayUrl(url) - if (normalized) { - if (!relayToMembers.has(normalized)) { - relayToMembers.set(normalized, new Set()) - } - relayToMembers.get(normalized)!.add(pubkey) + const normalized = this.normRelay(url) + if (!normalized) return + if (!relayToMembers.has(normalized)) { + relayToMembers.set(normalized, new Set()) } + relayToMembers.get(normalized)!.add(pubkey) }) }) } @@ -627,7 +636,7 @@ class RelaySelectionService { // Normalize and deduplicate final list const normalizedRelays = relays - .map(url => normalizeAnyRelayUrl(url)) + .map((url) => this.normRelay(url)) .filter((url): url is string => !!url) return Array.from(new Set(normalizedRelays)) @@ -663,10 +672,11 @@ class RelaySelectionService { * Includes: relay hints from kind 11, wss://thecitadel.nostr1.com, user's outboxes, and local relays */ private async getDiscussionReplyRelays(context: RelaySelectionContext): Promise { - const { parentEvent, userWriteRelays, userPubkey, blockedRelays } = context + const { parentEvent, userPubkey, blockedRelays } = context if (!parentEvent) return [] const relayUrls = new Set() + const userOutboxes = this.userWriteOutboxRelays(context) // Step 1: Get relay hints from the kind 11 event let discussionEventId: string | null = null @@ -700,24 +710,21 @@ class RelaySelectionService { relayUrls.add(thecitadelUrl) } - // Step 3: Add user's outboxes (write relays from kind 10002) - if (userWriteRelays.length > 0) { - userWriteRelays.forEach(url => { - const normalized = normalizeAnyRelayUrl(url) - if (normalized) { - relayUrls.add(normalized) - } + // Step 3: Add user's outboxes (NIP-65 + HTTP index write relays) + if (userOutboxes.length > 0) { + userOutboxes.forEach((url) => { + const normalized = this.normRelay(url) + if (normalized) relayUrls.add(normalized) }) } else if (userPubkey) { // Fetch user's relay list if not provided try { const relayList = await this.getCachedRelayList(userPubkey) - if (relayList?.write) { - relayList.write.forEach(url => { - const normalized = normalizeAnyRelayUrl(url) - if (normalized) { - relayUrls.add(normalized) - } + if (relayList) { + const outboxes = await collectViewerWriteOutboxUrls(userPubkey, relayList) + outboxes.forEach((url) => { + const normalized = this.normRelay(url) + if (normalized) relayUrls.add(normalized) }) } } catch (error) { @@ -746,7 +753,7 @@ class RelaySelectionService { // Step 5: Convert to array, normalize, and deduplicate const normalizedRelays = Array.from(relayUrls) - .map(url => normalizeAnyRelayUrl(url)) + .map((url) => this.normRelay(url)) .filter((url): url is string => !!url) const deduplicatedRelays = Array.from(new Set(normalizedRelays)) @@ -849,9 +856,7 @@ class RelaySelectionService { return relays } - const safeNormalize = (url: string): string => { - return normalizeAnyRelayUrl(url) || url - } + const safeNormalize = (url: string): string => this.normRelay(url) const normalizedBlocked = blockedRelays.map(safeNormalize) return relays.filter(relay => {