Browse Source

integrate http relays into inboxes and outboxes

imwald
Silberengel 3 weeks ago
parent
commit
c1f952ed46
  1. 11
      src/components/Explore/ExploreRelayDirectory.tsx
  2. 22
      src/components/Explore/ExploreRelayReviews.tsx
  3. 8
      src/components/GifPicker/index.tsx
  4. 10
      src/components/MemePicker/index.tsx
  5. 49
      src/components/PostEditor/PostRelaySelector.tsx
  6. 9
      src/components/RelayInfo/RelayReviewsPreview.tsx
  7. 11
      src/components/RssArticleWebBookmarks/index.tsx
  8. 10
      src/components/SearchResult/index.tsx
  9. 8
      src/components/Sidebar/SidebarCalendarWeekWidget.tsx
  10. 4
      src/features/feed/relay-policy.ts
  11. 22
      src/hooks/useFetchCalendarRsvps.tsx
  12. 21
      src/hooks/useUserMailboxRelayUrls.ts
  13. 7
      src/hooks/useViewerInboxRelayUrls.ts
  14. 14
      src/lib/account-list-relay-urls.ts
  15. 6
      src/lib/draft-event.ts
  16. 44
      src/lib/favorites-feed-relays.ts
  17. 76
      src/lib/private-relays.ts
  18. 3
      src/lib/profile-reports-relays.ts
  19. 21
      src/lib/public-message-publish-relays.ts
  20. 23
      src/lib/tombstone-events.ts
  21. 54
      src/lib/viewer-read-inboxes.test.ts
  22. 88
      src/lib/viewer-read-inboxes.ts
  23. 44
      src/lib/viewer-write-outboxes.test.ts
  24. 77
      src/lib/viewer-write-outboxes.ts
  25. 8
      src/pages/primary/CalendarPrimaryPage.tsx
  26. 8
      src/pages/primary/SpellsPage/CreateSpellDialog.tsx
  27. 11
      src/pages/primary/SpellsPage/ProfileInteractionsMap.tsx
  28. 8
      src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx
  29. 8
      src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx
  30. 12
      src/pages/primary/SpellsPage/index.tsx
  31. 36
      src/pages/primary/SpellsPage/useSpellsPageFeed.ts
  32. 13
      src/pages/secondary/EmojiSetsSettingsPage/index.tsx
  33. 11
      src/pages/secondary/FollowSetsSettingsPage/index.tsx
  34. 21
      src/pages/secondary/NoteListPage/index.tsx
  35. 11
      src/pages/secondary/RelayReviewsPage/index.tsx
  36. 42
      src/providers/FeedProvider.tsx
  37. 14
      src/providers/LiveActivitiesProvider.tsx
  38. 1
      src/providers/NostrProvider/index.tsx
  39. 179
      src/services/client.service.ts
  40. 89
      src/services/relay-selection.service.ts

11
src/components/Explore/ExploreRelayDirectory.tsx

@ -15,10 +15,7 @@ import {
loadCachedRelayReviews loadCachedRelayReviews
} from '@/lib/explore-relay-reviews' } from '@/lib/explore-relay-reviews'
import { getRelayUrlFromRelayReviewEvent } from '@/lib/event-metadata' import { getRelayUrlFromRelayReviewEvent } from '@/lib/event-metadata'
import { import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays'
getRelayUrlsWithFavoritesFastReadAndInbox,
userReadRelaysWithHttp
} from '@/lib/favorites-feed-relays'
import { toRelay } from '@/lib/link' import { toRelay } from '@/lib/link'
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds' import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds'
@ -114,7 +111,7 @@ function ExploreRelayDirectoryCard({ entry }: { entry: ExploreRelayEntry }) {
export default function ExploreRelayDirectory({ listFilter = '' }: { listFilter?: string }) { export default function ExploreRelayDirectory({ listFilter = '' }: { listFilter?: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, relayList } = useNostr() const { pubkey, relayList, cacheRelayListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const relayInputsKey = useMemo( const relayInputsKey = useMemo(
@ -127,9 +124,9 @@ export default function ExploreRelayDirectory({ listFilter = '' }: { listFilter?
getRelayUrlsWithFavoritesFastReadAndInbox( getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
userReadRelaysWithHttp(relayList), userReadInboxUrls(relayList, cacheRelayListEvent),
{ {
userWriteRelays: relayList?.write ?? [], userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent),
maxRelays: EXPLORE_REVIEWS_MAX_RELAYS, maxRelays: EXPLORE_REVIEWS_MAX_RELAYS,
applySocialKindBlockedFilter: false applySocialKindBlockedFilter: false
} }

22
src/components/Explore/ExploreRelayReviews.tsx

@ -8,10 +8,7 @@ import {
dedupeRelayReviewsNewestFirst, dedupeRelayReviewsNewestFirst,
loadCachedRelayReviews loadCachedRelayReviews
} from '@/lib/explore-relay-reviews' } from '@/lib/explore-relay-reviews'
import { import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays'
getRelayUrlsWithFavoritesFastReadAndInbox,
userReadRelaysWithHttp
} from '@/lib/favorites-feed-relays'
import { toRelay } from '@/lib/link' import { toRelay } from '@/lib/link'
import { isExploreBrowsableRelayUrl } from '@/lib/explore-popular-relays' import { isExploreBrowsableRelayUrl } from '@/lib/explore-popular-relays'
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
@ -59,7 +56,8 @@ const EXPLORE_REVIEWS_EOSE_TAIL_MS = 4500
function stableRelayInputsKey( function stableRelayInputsKey(
favoriteRelays: string[], favoriteRelays: string[],
blockedRelays: 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 { ): string {
const normSortJoin = (urls: string[]) => const normSortJoin = (urls: string[]) =>
[...urls] [...urls]
@ -70,19 +68,19 @@ function stableRelayInputsKey(
return [ return [
normSortJoin(favoriteRelays), normSortJoin(favoriteRelays),
normSortJoin(blockedRelays), normSortJoin(blockedRelays),
normSortJoin([...(relayList?.httpRead ?? []), ...(relayList?.read ?? [])]), normSortJoin(userReadInboxUrls(relayList, cacheRelayListEvent)),
normSortJoin(relayList?.write ?? []) normSortJoin(userWriteOutboxUrls(relayList, cacheRelayListEvent))
].join('::') ].join('::')
} }
export default function ExploreRelayReviews() { export default function ExploreRelayReviews() {
const { t } = useTranslation() const { t } = useTranslation()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { relayList } = useNostr() const { relayList, cacheRelayListEvent } = useNostr()
const relayInputsKey = useMemo( const relayInputsKey = useMemo(
() => stableRelayInputsKey(favoriteRelays, blockedRelays, relayList), () => stableRelayInputsKey(favoriteRelays, blockedRelays, relayList, cacheRelayListEvent),
[favoriteRelays, blockedRelays, relayList] [favoriteRelays, blockedRelays, relayList, cacheRelayListEvent]
) )
const relayUrls = useMemo(() => { const relayUrls = useMemo(() => {
@ -90,9 +88,9 @@ export default function ExploreRelayReviews() {
getRelayUrlsWithFavoritesFastReadAndInbox( getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
userReadRelaysWithHttp(relayList), userReadInboxUrls(relayList, cacheRelayListEvent),
{ {
userWriteRelays: relayList?.write ?? [], userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent),
maxRelays: EXPLORE_REVIEWS_MAX_RELAYS, maxRelays: EXPLORE_REVIEWS_MAX_RELAYS,
applySocialKindBlockedFilter: false applySocialKindBlockedFilter: false
} }

8
src/components/GifPicker/index.tsx

@ -10,7 +10,7 @@ import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { useUserReadInboxUrls, useUserWriteOutboxUrls } from '@/hooks/useUserMailboxRelayUrls'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { ExtendedKind, FAST_WRITE_RELAY_URLS, GIF_RELAY_URLS } from '@/constants' import { ExtendedKind, FAST_WRITE_RELAY_URLS, GIF_RELAY_URLS } from '@/constants'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -50,7 +50,7 @@ export default function GifPicker({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { publish, pubkey, relayList } = useNostr() const { publish, pubkey } = useNostr()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [searchInput, setSearchInput] = useState('') const [searchInput, setSearchInput] = useState('')
// Initialise from the module-level session cache so re-opens are instant // Initialise from the module-level session cache so re-opens are instant
@ -70,8 +70,8 @@ export default function GifPicker({
const fileInputRef = useRef<HTMLInputElement | null>(null) const fileInputRef = useRef<HTMLInputElement | null>(null)
const gifbuddyPopupRef = useRef<Window | null>(null) const gifbuddyPopupRef = useRef<Window | null>(null)
const userReadRelays = useMemo(() => userReadRelaysWithHttp(relayList), [relayList]) const userReadRelays = useUserReadInboxUrls()
const userWriteRelays = relayList?.write ?? [] const userWriteRelays = useUserWriteOutboxUrls()
/** Paste / upload: GIF discovery relays + user writes (unchanged). */ /** Paste / upload: GIF discovery relays + user writes (unchanged). */
const gifPublishRelayUrls = useMemo(() => { const gifPublishRelayUrls = useMemo(() => {

10
src/components/MemePicker/index.tsx

@ -10,7 +10,7 @@ import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { useUserReadInboxUrls, useUserWriteOutboxUrls } from '@/hooks/useUserMailboxRelayUrls'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { ExtendedKind, GIF_RELAY_URLS } from '@/constants' import { ExtendedKind, GIF_RELAY_URLS } from '@/constants'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
@ -24,7 +24,7 @@ import {
} from '@/services/meme.service' } from '@/services/meme.service'
import mediaUpload from '@/services/media-upload.service' import mediaUpload from '@/services/media-upload.service'
import { ExternalLink, X } from 'lucide-react' 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. */ /** In-session cache: survives Drawer/Dropdown open↔close without a relay re-fetch. */
let _sessionMemes: MemeMetadata[] = [] let _sessionMemes: MemeMetadata[] = []
@ -68,7 +68,7 @@ export default function MemePicker({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { publish, pubkey, relayList } = useNostr() const { publish, pubkey } = useNostr()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [searchInput, setSearchInput] = useState('') const [searchInput, setSearchInput] = useState('')
@ -86,8 +86,8 @@ export default function MemePicker({
const fileInputRef = useRef<HTMLInputElement | null>(null) const fileInputRef = useRef<HTMLInputElement | null>(null)
const memeamigoPopupRef = useRef<Window | null>(null) const memeamigoPopupRef = useRef<Window | null>(null)
const userReadRelays = useMemo(() => userReadRelaysWithHttp(relayList), [relayList]) const userReadRelays = useUserReadInboxUrls()
const userWriteRelays = relayList?.write ?? [] const userWriteRelays = useUserWriteOutboxUrls()
/** Keep memesRef, session cache, and React state in sync. */ /** Keep memesRef, session cache, and React state in sync. */
const setMemes = useCallback((newMemes: MemeMetadata[], isSearch = false) => { const setMemes = useCallback((newMemes: MemeMetadata[], isSearch = false) => {

49
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 { 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 { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { getRelayListFromEvent } from '@/lib/event-metadata' import { userReadInboxUrls } from '@/lib/favorites-feed-relays'
import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import indexedDb from '@/services/indexed-db.service'
import { Check, ChevronDown, Server } from 'lucide-react' import { Check, ChevronDown, Server } from 'lucide-react'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { Dispatch, SetStateAction, useCallback, useEffect, useState, useMemo, useRef } from 'react' 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). */ /** Keep auto-selection within {@link MAX_PUBLISH_RELAYS}, preserving {@link selectableRelaysOrder} (top of list first). */
function capAutoSelectedRelays(selectableRelaysOrder: string[], selectedWithCache: string[]): string[] { 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 selectedNormSet = new Set(selectedWithCache.map(norm))
const ordered: string[] = [] const ordered: string[] = []
for (const url of selectableRelaysOrder) { for (const url of selectableRelaysOrder) {
@ -71,8 +70,11 @@ export default function PostRelaySelector({
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
useCurrentRelays() // Keep this hook call for any side effects useCurrentRelays() // Keep this hook call for any side effects
const { relaySets, favoriteRelays, blockedRelays } = useFavoriteRelays() const { relaySets, favoriteRelays, blockedRelays } = useFavoriteRelays()
const { pubkey, relayList } = useNostr() const { pubkey, relayList, cacheRelayListEvent } = useNostr()
const userReadRelaysForSelection = useMemo(() => userReadRelaysWithHttp(relayList), [relayList]) const userReadRelaysForSelection = useMemo(
() => userReadInboxUrls(relayList, cacheRelayListEvent),
[relayList, cacheRelayListEvent]
)
const [selectedRelayUrls, setSelectedRelayUrls] = useState<string[]>([]) const [selectedRelayUrls, setSelectedRelayUrls] = useState<string[]>([])
const [selectableRelays, setSelectableRelays] = useState<string[]>([]) const [selectableRelays, setSelectableRelays] = useState<string[]>([])
const [relayTypes, setRelayTypes] = useState<Record<string, RelaySourceType>>({}) const [relayTypes, setRelayTypes] = useState<Record<string, RelaySourceType>>({})
@ -165,32 +167,9 @@ export default function PostRelaySelector({
const updateRelaySelection = async () => { const updateRelaySelection = async () => {
setIsLoading(true) setIsLoading(true)
try { try {
let userWriteRelays = relayList?.write || [] let userWriteRelays: string[] = []
if (pubkey) { if (pubkey && relayList) {
try { userWriteRelays = await collectViewerWriteOutboxUrls(pubkey, relayList)
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 })
}
} }
const result = await relaySelectionService.selectRelays({ const result = await relaySelectionService.selectRelays({
@ -269,7 +248,7 @@ export default function PostRelaySelector({
useEffect(() => { useEffect(() => {
// An event is "protected" if we have selected relays that aren't the default user write relays // An event is "protected" if we have selected relays that aren't the default user write relays
const defaultUserWriteRelays = [...(relayList?.httpWrite ?? []), ...(relayList?.write || [])] 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 defaultNorm = new Set(defaultUserWriteRelays.map(normW))
const isProtectedEvent = const isProtectedEvent =
selectedRelayUrls.length > 0 && selectedRelayUrls.length > 0 &&

9
src/components/RelayInfo/RelayReviewsPreview.tsx

@ -8,10 +8,7 @@ import {
CarouselPrevious CarouselPrevious
} from '@/components/ui/carousel' } from '@/components/ui/carousel'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls } from '@/lib/favorites-feed-relays'
getRelayUrlsWithFavoritesFastReadAndInbox,
userReadRelaysWithHttp
} from '@/lib/favorites-feed-relays'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { compareEvents } from '@/lib/event' import { compareEvents } from '@/lib/event'
import { getStarsFromRelayReviewEvent } from '@/lib/event-metadata' import { getStarsFromRelayReviewEvent } from '@/lib/event-metadata'
@ -39,7 +36,7 @@ import ReviewEditor from './ReviewEditor'
export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string }) { export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { pubkey, checkLogin, relayList } = useNostr() const { pubkey, checkLogin, relayList, cacheRelayListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const [showEditor, setShowEditor] = useState(false) const [showEditor, setShowEditor] = useState(false)
@ -117,7 +114,7 @@ export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string })
const base = getRelayUrlsWithFavoritesFastReadAndInbox( const base = getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
userReadRelaysWithHttp(relayList) userReadInboxUrls(relayList, cacheRelayListEvent)
) )
const uniqueUrls = [...new Set([normalizedTarget, ...base])] const uniqueUrls = [...new Set([normalizedTarget, ...base])]

11
src/components/RssArticleWebBookmarks/index.tsx

@ -5,10 +5,7 @@ import { Separator } from '@/components/ui/separator'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { createWebBookmarkDraftEvent } from '@/lib/draft-event' import { createWebBookmarkDraftEvent } from '@/lib/draft-event'
import { import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls } from '@/lib/favorites-feed-relays'
getRelayUrlsWithFavoritesFastReadAndInbox,
userReadRelaysWithHttp
} from '@/lib/favorites-feed-relays'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { showPublishingError } from '@/lib/publishing-feedback' import { showPublishingError } from '@/lib/publishing-feedback'
import { import {
@ -34,7 +31,7 @@ import { useTranslation } from 'react-i18next'
export default function RssArticleWebBookmarks({ articleUrl }: { articleUrl: string }) { export default function RssArticleWebBookmarks({ articleUrl }: { articleUrl: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() 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 canonical = useMemo(() => canonicalizeRssArticleUrl(articleUrl), [articleUrl])
const iVals = useMemo(() => { const iVals = useMemo(() => {
@ -43,11 +40,11 @@ export default function RssArticleWebBookmarks({ articleUrl }: { articleUrl: str
}, [canonical]) }, [canonical])
const relayUrls = useMemo(() => { const relayUrls = useMemo(() => {
const read = userReadRelaysWithHttp(relayList) const read = userReadInboxUrls(relayList, cacheRelayListEvent)
const base = getRelayUrlsWithFavoritesFastReadAndInbox(favoriteRelays, blockedRelays, read, {}) const base = getRelayUrlsWithFavoritesFastReadAndInbox(favoriteRelays, blockedRelays, read, {})
if (!base.length) return [] if (!base.length) return []
return appendCuratedReadOnlyRelays(base, blockedRelays) return appendCuratedReadOnlyRelays(base, blockedRelays)
}, [favoriteRelays, blockedRelays, relayList]) }, [favoriteRelays, blockedRelays, relayList, cacheRelayListEvent])
const [mine, setMine] = useState<Event[]>([]) const [mine, setMine] = useState<Event[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)

10
src/components/SearchResult/index.tsx

@ -8,6 +8,7 @@ import Relay from '../Relay'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import { userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { buildAlexandriaEventsSearchUrlForTSearchParams } from '@/lib/alexandria-events-search-url' import { buildAlexandriaEventsSearchUrlForTSearchParams } from '@/lib/alexandria-events-search-url'
import { useLayoutEffect, useMemo } from 'react' import { useLayoutEffect, useMemo } from 'react'
@ -17,7 +18,7 @@ function relayDedupeKey(url: string): string {
} }
export default function SearchResult({ searchParams }: { searchParams: TSearchParams | null }) { export default function SearchResult({ searchParams }: { searchParams: TSearchParams | null }) {
const { pubkey, relayList } = useNostr() const { relayList, cacheRelayListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
/** /**
@ -54,7 +55,10 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
const relays: string[] = [] const relays: string[] = []
if (relayList) { if (relayList) {
relays.push(...(relayList.read || []), ...(relayList.write || [])) relays.push(
...userReadInboxUrls(relayList, cacheRelayListEvent),
...userWriteOutboxUrls(relayList, cacheRelayListEvent)
)
} }
relays.push(...(favoriteRelays || [])) relays.push(...(favoriteRelays || []))
@ -75,7 +79,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
const n = normalizeUrl(relay) || relay const n = normalizeUrl(relay) || relay
return !blockedSet.has(n) return !blockedSet.has(n)
}) })
}, [pubkey, relayList, favoriteRelays, blockedRelays]) }, [relayList, cacheRelayListEvent, favoriteRelays, blockedRelays])
const nonSearchableRelays = useMemo( const nonSearchableRelays = useMemo(
() => combinedRelays.filter((u) => !searchableKeySet.has(relayDedupeKey(u))), () => combinedRelays.filter((u) => !searchableKeySet.has(relayDedupeKey(u))),

8
src/components/Sidebar/SidebarCalendarWeekWidget.tsx

@ -7,7 +7,7 @@ import {
getCalendarOccurrenceWindowMs, getCalendarOccurrenceWindowMs,
getLocalMondayWeekBounds getLocalMondayWeekBounds
} from '@/lib/calendar-event' } 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 { replaceableEventDedupeKey } from '@/lib/event'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -41,7 +41,7 @@ const SESSION_CALENDAR_MERGE_CAP = 1200
export default function SidebarCalendarWeekWidget() { export default function SidebarCalendarWeekWidget() {
const { t } = useTranslation() const { t } = useTranslation()
const { relayList, pubkey } = useNostr() const { relayList, cacheRelayListEvent, pubkey } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const followList = useFollowListOptional() const followList = useFollowListOptional()
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigation()
@ -62,9 +62,9 @@ export default function SidebarCalendarWeekWidget() {
const base = getRelayUrlsWithFavoritesFastReadAndInbox( const base = getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
userReadRelaysWithHttp(relayList), userReadInboxUrls(relayList, cacheRelayListEvent),
{ {
userWriteRelays: relayList?.write ?? [], userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent),
applySocialKindBlockedFilter: false applySocialKindBlockedFilter: false
} }
) )

4
src/features/feed/relay-policy.ts

@ -11,7 +11,7 @@ import {
relayUrlsStripExtendedTagReqBlocked relayUrlsStripExtendedTagReqBlocked
} from '@/lib/relay-extended-tag-req-blocks' } from '@/lib/relay-extended-tag-req-blocks'
import { isRelayBlockedByUser } from '@/lib/relay-blocked' 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' import type { TSubRequestFilter } from '@/types'
export type FeedRelayOperation = 'read' | 'write' | 'publish-picker' | 'favorites-feed' 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 { function normalizedRelayUrl(url: string, layerSource?: FeedRelayLayerSource | string): string {
if (layerSource === 'http-index') return normalizeHttpRelayUrl(url) || url.trim() 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<string> { function normalizedSet(urls: readonly string[] | undefined): Set<string> {

22
src/hooks/useFetchCalendarRsvps.tsx

@ -12,23 +12,9 @@ import { Event } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import { FAST_READ_RELAY_URLS } from '@/constants' 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' 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 { function getRsvpStatus(rsvp: Event): 'accepted' | 'tentative' | 'declined' | undefined {
const status = rsvp.tags.find(tagNameEquals('status'))?.[1] const status = rsvp.tags.find(tagNameEquals('status'))?.[1]
if (status === 'accepted' || status === 'tentative' || status === 'declined') return status if (status === 'accepted' || status === 'tentative' || status === 'declined') return status
@ -53,7 +39,7 @@ function mergeRsvpList(events: Event[]): Event[] {
} }
export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
const { relayList } = useNostr() const { relayList, cacheRelayListEvent } = useNostr()
const [rsvps, setRsvps] = useState<Event[]>([]) const [rsvps, setRsvps] = useState<Event[]>([])
const [isFetching, setIsFetching] = useState(false) const [isFetching, setIsFetching] = useState(false)
@ -69,8 +55,8 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
const coordinate = normalizeReplaceableCoordinateString( const coordinate = normalizeReplaceableCoordinateString(
getReplaceableCoordinateFromEvent(calendarEvent) getReplaceableCoordinateFromEvent(calendarEvent)
) )
const userRead = userReadRelaysWithHttp(relayList) const userRead = userReadInboxUrls(relayList, cacheRelayListEvent)
const userWrite = userWriteRelaysForQuery(relayList) const userWrite = userWriteOutboxUrls(relayList, cacheRelayListEvent)
void (async () => { void (async () => {
const fromSession = client.getSessionCalendarRsvpsForCalendarEvent(calendarEvent) const fromSession = client.getSessionCalendarRsvpsForCalendarEvent(calendarEvent)

21
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]
)
}

7
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 { useNostrOptional } from '@/providers/nostr-context'
import client from '@/services/client.service' import client from '@/services/client.service'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@ -19,7 +19,10 @@ export function useViewerInboxRelayUrls(): {
let cancelled = false let cancelled = false
void client.peekRelayListFromStorage(pk).then((rl) => { void client.peekRelayListFromStorage(pk).then((rl) => {
if (cancelled) return if (cancelled) return
setInboxRelayUrls(userReadRelaysWithHttp(rl).slice(0, 14)) void collectViewerReadInboxUrls(pk, rl).then((urls) => {
if (cancelled) return
setInboxRelayUrls(urls.slice(0, 14))
})
}) })
return () => { return () => {
cancelled = true cancelled = true

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

@ -1,6 +1,8 @@
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { buildPrioritizedReadRelayUrls, buildPrioritizedWriteRelayUrls } from '@/lib/relay-url-priority' import { buildPrioritizedReadRelayUrls, buildPrioritizedWriteRelayUrls } from '@/lib/relay-url-priority'
import { normalizeAnyRelayUrl } from '@/lib/url' import { 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 { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -21,9 +23,11 @@ export async function buildAccountListRelayUrlsForMerge(options: {
relayList: myRelayList relayList: myRelayList
}) })
const favoritesTier = getFavoritesFeedRelayUrls(favoriteRelays ?? [], blockedRelays, useGlobal) const favoritesTier = getFavoritesFeedRelayUrls(favoriteRelays ?? [], blockedRelays, useGlobal)
const writeOutboxes = await collectViewerWriteOutboxUrls(accountPubkey, myRelayList)
const readInboxes = await collectViewerReadInboxUrls(accountPubkey, myRelayList)
const read = buildPrioritizedReadRelayUrls({ const read = buildPrioritizedReadRelayUrls({
userReadRelays: myRelayList.read ?? [], userReadRelays: readInboxes,
userWriteRelays: myRelayList.write ?? [], userWriteRelays: writeOutboxes,
favoriteRelays: favoritesTier, favoriteRelays: favoritesTier,
blockedRelays, blockedRelays,
maxRelays: 100, maxRelays: 100,
@ -31,7 +35,7 @@ export async function buildAccountListRelayUrlsForMerge(options: {
includeGlobalFastRead: useGlobal includeGlobalFastRead: useGlobal
}) })
const write = buildPrioritizedWriteRelayUrls({ const write = buildPrioritizedWriteRelayUrls({
userWriteRelays: myRelayList.write ?? [], userWriteRelays: writeOutboxes,
favoriteRelays: favoritesTier, favoriteRelays: favoritesTier,
blockedRelays, blockedRelays,
maxRelays: 100, maxRelays: 100,
@ -39,5 +43,5 @@ export async function buildAccountListRelayUrlsForMerge(options: {
includeGlobalFastWriteReadTails: useGlobal includeGlobalFastWriteReadTails: useGlobal
}) })
const merged = [...read, ...write] const merged = [...read, ...write]
return [...new Set(merged.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean))] return [...new Set(merged.map((u) => normalizeRelayUrlByScheme(u) || u).filter(Boolean))]
} }

6
src/lib/draft-event.ts

@ -33,6 +33,7 @@ import {
} from '@/lib/rss-article' } from '@/lib/rss-article'
import { EMOJI_SHORT_CODE_REGEX } from '@/lib/content-patterns' import { EMOJI_SHORT_CODE_REGEX } from '@/lib/content-patterns'
import { blossomSha256FromBlobUrl, cleanUrl, isBlossomBudBlobUrl } from '@/lib/url' import { blossomSha256FromBlobUrl, cleanUrl, isBlossomBudBlobUrl } from '@/lib/url'
import { collectReadInboxUrlsFromRelayList } from '@/lib/viewer-read-inboxes'
import { urlToWebBookmarkDTag } from '@/lib/web-bookmark-nip' import { urlToWebBookmarkDTag } from '@/lib/web-bookmark-nip'
import { randomString } from './random' import { randomString } from './random'
import { generateBech32IdFromETag, getImetaInfoFromImetaTag, tagNameEquals } from './tag' import { generateBech32IdFromETag, getImetaInfoFromImetaTag, tagNameEquals } from './tag'
@ -1169,10 +1170,7 @@ export async function createPollDraftEvent(
relays.forEach((relay) => tags.push(buildRelayTag(relay))) relays.forEach((relay) => tags.push(buildRelayTag(relay)))
} else { } else {
const relayList = await client.fetchRelayList(author) const relayList = await client.fetchRelayList(author)
const readHints = [ const readHints = collectReadInboxUrlsFromRelayList(relayList).slice(0, 4)
...(relayList.httpRead || []).slice(0, 4),
...(relayList.read || []).slice(0, 4)
].slice(0, 4)
readHints.forEach((relay) => { readHints.forEach((relay) => {
tags.push(buildRelayTag(relay)) tags.push(buildRelayTag(relay))
}) })

44
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 { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize'
import { relaySessionStrikes } from '@/lib/relay-strikes' import { relaySessionStrikes } from '@/lib/relay-strikes'
import { profileFetchRelayUrlsWithoutFastReadLayer } from '@/lib/viewer-relay-defaults' 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 { function isBlockedRelay(url: string, blockedRelays: string[]): boolean {
return isRelayBlockedByUser(url, blockedRelays) 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. * 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( export function userReadRelaysWithHttp(
relayList: { read?: string[]; httpRead?: string[] } | undefined | null relayList: { read?: string[]; httpRead?: string[] } | undefined | null,
cacheRelayListEvent?: Event | null
): string[] { ): string[] {
const http = relayList?.httpRead ?? [] return userReadInboxUrls(relayList, cacheRelayListEvent)
const read = relayList?.read ?? []
return dedupeNormalizeRelayUrlsOrdered([...http, ...read])
} }
export function getFavoritesFeedRelayUrls( export function getFavoritesFeedRelayUrls(
@ -98,8 +124,8 @@ export function buildAuthorInboxOutboxRelayUrls(
const list = includeAuthorLocalRelays const list = includeAuthorLocalRelays
? authorRelayList ? authorRelayList
: stripMailboxLocalUrlsForRemoteViewers(authorRelayList) : stripMailboxLocalUrlsForRemoteViewers(authorRelayList)
const inboxLayer = relayUrlsLocalsFirst([...(list.httpRead ?? []), ...(list.read ?? [])]) const inboxLayer = relayUrlsLocalsFirst(collectUserReadInboxUrls(list))
const outboxLayer = relayUrlsLocalsFirst([...(list.httpWrite ?? []), ...(list.write ?? [])]) const outboxLayer = relayUrlsLocalsFirst(collectUserWriteOutboxUrls(list))
return mergeRelayUrlLayers([inboxLayer, outboxLayer], blockedRelays) return mergeRelayUrlLayers([inboxLayer, outboxLayer], blockedRelays)
} }
@ -213,8 +239,8 @@ export function buildProfilePageReadRelayUrls(
const list = includeAuthorLocalRelays const list = includeAuthorLocalRelays
? authorRelayList ? authorRelayList
: stripMailboxLocalUrlsForRemoteViewers(authorRelayList) : stripMailboxLocalUrlsForRemoteViewers(authorRelayList)
const authorRead = [...(list.httpRead ?? []), ...(list.read ?? [])] const authorRead = collectUserReadInboxUrls(list)
const authorWrite = [...(list.httpWrite ?? []), ...(list.write ?? [])] const authorWrite = collectUserWriteOutboxUrls(list)
const authorHasNoNip65 = authorRead.length === 0 && authorWrite.length === 0 const authorHasNoNip65 = authorRead.length === 0 && authorWrite.length === 0
const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useGlobal) const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useGlobal)

76
src/lib/private-relays.ts

@ -1,78 +1,46 @@
import client from '@/services/client.service' import client from '@/services/client.service'
import {
collectViewerWriteOutboxUrls,
viewerHasWriteOutboxes
} from '@/lib/viewer-write-outboxes'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { ExtendedKind } from '@/constants' 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) * Check if user has private relays available (outbox relays or cache relays)
* @param pubkey - User's public key
* @returns Promise<boolean> - true if user has at least one private relay available
*/ */
export async function hasPrivateRelays(pubkey: string): Promise<boolean> { export async function hasPrivateRelays(pubkey: string): Promise<boolean> {
// Check for outbox relays (kind 10002) — IndexedDB merge only; no network wait.
const relayList = await client.peekRelayListFromStorage(pubkey) const relayList = await client.peekRelayListFromStorage(pubkey)
if (relayList.write && relayList.write.length > 0) { return viewerHasWriteOutboxes(pubkey, relayList)
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
} }
/** /**
* Get private relay URLs (outbox + cache relays) * Get private relay URLs (kind 10002 WS + kind 10243 HTTP + kind 10432 cache write outboxes)
* @param pubkey - User's public key
* @returns Promise<string[]> - Array of relay URLs
*/ */
export async function getPrivateRelayUrls(pubkey: string): Promise<string[]> { export async function getPrivateRelayUrls(pubkey: string): Promise<string[]> {
const relayUrls: string[] = []
// Get outbox relays (kind 10002) — storage-first; cache rows below still augment.
const relayList = await client.peekRelayListFromStorage(pubkey) const relayList = await client.peekRelayListFromStorage(pubkey)
if (relayList.write) { return collectViewerWriteOutboxUrls(pubkey, relayList)
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))
} }
/** /**
* Get cache relay URLs only * Get cache relay URLs only (kind 10432)
* @param pubkey - User's public key * @param pubkey - User's public key
* @returns Promise<string[]> - Array of cache relay URLs * @returns Promise<string[]> - Array of cache relay URLs
*/ */
export async function getCacheRelayUrls(pubkey: string): Promise<string[]> { export async function getCacheRelayUrls(pubkey: string): Promise<string[]> {
const relayUrls: string[] = []
// Get cache relays (kind 10432)
const cacheRelayEvent = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS) const cacheRelayEvent = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS)
if (cacheRelayEvent) { return getCacheRelayUrlsFromEvent(cacheRelayEvent)
cacheRelayEvent.tags.forEach(tag => {
if (tag[0] === 'relay' && tag[1]) {
relayUrls.push(tag[1])
}
})
}
return Array.from(new Set(relayUrls))
} }

3
src/lib/profile-reports-relays.ts

@ -1,5 +1,6 @@
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { relayUrlsLocalsFirst } from '@/lib/relay-url-priority' import { relayUrlsLocalsFirst } from '@/lib/relay-url-priority'
import { collectReadInboxUrlsFromRelayList } from '@/lib/viewer-read-inboxes'
import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize' import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize'
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
@ -36,7 +37,7 @@ export function buildProfileReportsRelayUrls(
const list = options.includeAuthorLocalRelays const list = options.includeAuthorLocalRelays
? mailboxList ? mailboxList
: stripMailboxLocalUrlsForRemoteViewers(mailboxList) : stripMailboxLocalUrlsForRemoteViewers(mailboxList)
const inboxLayer = relayUrlsLocalsFirst([...(list.httpRead ?? []), ...(list.read ?? [])]) const inboxLayer = relayUrlsLocalsFirst(collectReadInboxUrlsFromRelayList(list))
const cacheLayer = relayUrlsLocalsFirst( const cacheLayer = relayUrlsLocalsFirst(
(options.cacheRelayUrls ?? []).filter((u) => { (options.cacheRelayUrls ?? []).filter((u) => {
const k = normalizeAnyRelayUrl(u) || u.trim() const k = normalizeAnyRelayUrl(u) || u.trim()

21
src/lib/public-message-publish-relays.ts

@ -5,32 +5,21 @@ import {
} from '@/constants' } from '@/constants'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { dedupeNormalizeRelayUrlsOrdered, relayUrlsLocalsFirst } from '@/lib/relay-url-priority' 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' 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( export function collectSenderOutboxUrls(
relayList: TRelayList | null | undefined, relayList: TRelayList | null | undefined,
extraWriteUrls: readonly string[] = [] extraWriteUrls: readonly string[] = []
): string[] { ): string[] {
const http = (relayList?.httpWrite ?? []) return collectWriteOutboxUrlsFromRelayList(relayList, extraWriteUrls)
.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])
} }
/** NIP-65 / 10243 inbox URLs for a recipient (drops other people's LAN/loopback). */ /** NIP-65 / 10243 inbox URLs for a recipient (drops other people's LAN/loopback). */
export function collectRecipientInboxUrls(relayList: TRelayList | null | undefined): string[] { export function collectRecipientInboxUrls(relayList: TRelayList | null | undefined): string[] {
const http = (relayList?.httpRead ?? []) return collectRemoteReadInboxUrlsFromRelayList(relayList)
.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])
} }
/** /**

23
src/lib/tombstone-events.ts

@ -1,5 +1,7 @@
import { PROFILE_RELAY_URLS } from '@/constants' 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' import type { TRelayList } from '@/types'
/** Dispatched after tombstones in IndexedDB change (kind-5 sync or local apply). */ /** 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). */ /** Relay set for querying the current user's kind-5 events (aligned with login sync). */
export function buildDeletionRelayUrls(relayList: TRelayList | null | undefined): string[] { export function buildDeletionRelayUrls(
const httpR = relayList?.httpRead ?? [] relayList: TRelayList | null | undefined,
const httpW = relayList?.httpWrite ?? [] cacheUrls: readonly string[] = []
if (!relayList?.read?.length && !relayList?.write?.length && !httpR.length && !httpW.length) { ): string[] {
const readInboxes = collectUserReadInboxUrls(relayList, cacheUrls)
const writeOutboxes = collectUserWriteOutboxUrls(relayList, cacheUrls)
if (readInboxes.length === 0 && writeOutboxes.length === 0) {
return Array.from( return Array.from(
new Set(PROFILE_RELAY_URLS.map((url) => normalizeUrl(url) || url).filter(Boolean)) new Set(PROFILE_RELAY_URLS.map((url) => normalizeUrl(url) || url).filter(Boolean))
).slice(0, 20) ).slice(0, 20)
} }
const ws = relayList?.write ?? []
const rs = relayList?.read ?? []
return Array.from( return Array.from(
new Set([ new Set([
...ws.map((url: string) => normalizeUrl(url) || url), ...writeOutboxes,
...rs.slice(0, 8).map((url: string) => normalizeUrl(url) || url), ...readInboxes.slice(0, 8),
...httpW.map((url: string) => normalizeHttpRelayUrl(url) || url),
...httpR.slice(0, 8).map((url: string) => normalizeHttpRelayUrl(url) || url),
...PROFILE_RELAY_URLS.map((url: string) => normalizeAnyRelayUrl(url) || url) ...PROFILE_RELAY_URLS.map((url: string) => normalizeAnyRelayUrl(url) || url)
]) ])
).slice(0, 20) ).slice(0, 20)

54
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/'
])
})
})

88
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<TRelayList, 'read' | 'httpRead'> | {
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<string[]> {
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<boolean> {
const urls = await collectViewerReadInboxUrls(pubkey, relayList)
return urls.length > 0
}

44
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/'
])
})
})

77
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<TRelayList, 'write' | 'httpWrite'> | {
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<string[]> {
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<boolean> {
const urls = await collectViewerWriteOutboxUrls(pubkey, relayList)
return urls.length > 0
}

8
src/pages/primary/CalendarPrimaryPage.tsx

@ -8,7 +8,7 @@ import {
getLocalMonthRangeMs getLocalMonthRangeMs
} from '@/lib/calendar-event' } from '@/lib/calendar-event'
import { replaceableEventDedupeKey } from '@/lib/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 { setCalendarDayPanelEvents } from '@/lib/calendar-day-panel-cache'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -72,7 +72,7 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
ref ref
) { ) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const { relayList, pubkey } = useNostr() const { relayList, cacheRelayListEvent, pubkey } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const followList = useFollowListOptional() const followList = useFollowListOptional()
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigation()
@ -121,9 +121,9 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
const base = getRelayUrlsWithFavoritesFastReadAndInbox( const base = getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
userReadRelaysWithHttp(relayList), userReadInboxUrls(relayList, cacheRelayListEvent),
{ {
userWriteRelays: relayList?.write ?? [], userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent),
applySocialKindBlockedFilter: false applySocialKindBlockedFilter: false
} }
) )

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

@ -25,7 +25,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { showPublishingError, showSimplePublishSuccess } from '@/lib/publishing-feedback' import { showPublishingError, showSimplePublishSuccess } from '@/lib/publishing-feedback'
import { eventService } from '@/services/client.service' import { eventService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.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 { getRelaysForSpellCatalogSync } from '@/services/spell.service'
import { Info, Minus, Plus, X } from 'lucide-react' import { Info, Minus, Plus, X } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -292,7 +292,7 @@ export default function CreateSpellDialog({
spellToClone?: NostrEvent | null spellToClone?: NostrEvent | null
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, publish, checkLogin, relayList } = useNostr() const { pubkey, publish, checkLogin, relayList, cacheRelayListEvent } = useNostr()
const { addBookmark, removeBookmark } = useBookmarks() const { addBookmark, removeBookmark } = useBookmarks()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults() const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults()
@ -326,8 +326,8 @@ export default function CreateSpellDialog({
const { draft, notices, pendingATags } = applyListEventToSpellDraft(base, ev) const { draft, notices, pendingATags } = applyListEventToSpellDraft(base, ev)
setForm(draft) setForm(draft)
setListImportNotices(notices) setListImportNotices(notices)
const urls = getRelaysForSpellCatalogSync(favoriteRelays, blockedRelays, userReadRelaysWithHttp(relayList), { const urls = getRelaysForSpellCatalogSync(favoriteRelays, blockedRelays, userReadInboxUrls(relayList, cacheRelayListEvent), {
userWriteRelays: relayList?.write ?? [], userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent),
useGlobalRelayBootstrap useGlobalRelayBootstrap
}) })
if (pendingATags.length === 0) return if (pendingATags.length === 0) return

11
src/pages/primary/SpellsPage/ProfileInteractionsMap.tsx

@ -4,10 +4,7 @@ import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays'
getRelayUrlsWithFavoritesFastReadAndInbox,
userReadRelaysWithHttp
} from '@/lib/favorites-feed-relays'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
import { formatPubkey } from '@/lib/pubkey' import { formatPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -114,7 +111,7 @@ function compactCount(n: number): string {
export default function ProfileInteractionsMap({ pubkey, refreshKey }: Props) { export default function ProfileInteractionsMap({ pubkey, refreshKey }: Props) {
const { t } = useTranslation() const { t } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { relayList } = useNostr() const { relayList, cacheRelayListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [cards, setCards] = useState<InteractionCard[]>([]) const [cards, setCards] = useState<InteractionCard[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -126,9 +123,9 @@ export default function ProfileInteractionsMap({ pubkey, refreshKey }: Props) {
getRelayUrlsWithFavoritesFastReadAndInbox( getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
userReadRelaysWithHttp(relayList), userReadInboxUrls(relayList, cacheRelayListEvent),
{ {
userWriteRelays: relayList?.write ?? [], userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent),
applySocialKindBlockedFilter: false applySocialKindBlockedFilter: false
} }
), ),

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

@ -4,7 +4,7 @@ import { SimpleUserAvatar } from '@/components/UserAvatar'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter'
import { filterEventsExcludingTombstones } from '@/lib/event' 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 { toNote } from '@/lib/link'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { import {
@ -100,7 +100,7 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
const { t } = useTranslation() const { t } = useTranslation()
const { navigate: navigatePrimary } = usePrimaryPage() const { navigate: navigatePrimary } = usePrimaryPage()
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigation()
const { pubkey, relayList } = useNostr() const { pubkey, relayList, cacheRelayListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilterOrDefaults() const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilterOrDefaults()
@ -125,9 +125,9 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
getRelayUrlsWithFavoritesFastReadAndInbox( getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
userReadRelaysWithHttp(relayList), userReadInboxUrls(relayList, cacheRelayListEvent),
{ {
userWriteRelays: relayList?.write ?? [], userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent),
applySocialKindBlockedFilter: false applySocialKindBlockedFilter: false
} }
), ),

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

@ -4,7 +4,7 @@ import { ExtendedKind } from '@/constants'
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter'
import { filterEventsExcludingTombstones } from '@/lib/event' import { filterEventsExcludingTombstones } from '@/lib/event'
import { extractHashtagsFromContent, normalizeTopic } from '@/lib/discussion-topics' 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 { toNoteList } from '@/lib/link'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { useSmartHashtagNavigation } from '@/PageManager' import { useSmartHashtagNavigation } from '@/PageManager'
@ -115,7 +115,7 @@ type Props = {
export default function TopicKeywordHeatMap({ refreshKey }: Props) { export default function TopicKeywordHeatMap({ refreshKey }: Props) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigateToHashtag } = useSmartHashtagNavigation() const { navigateToHashtag } = useSmartHashtagNavigation()
const { relayList } = useNostr() const { relayList, cacheRelayListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilterOrDefaults() const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilterOrDefaults()
@ -124,9 +124,9 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) {
getRelayUrlsWithFavoritesFastReadAndInbox( getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
userReadRelaysWithHttp(relayList), userReadInboxUrls(relayList, cacheRelayListEvent),
{ {
userWriteRelays: relayList?.write ?? [], userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent),
applySocialKindBlockedFilter: false applySocialKindBlockedFilter: false
} }
), ),

12
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 { ExtendedKind, FIRST_RELAY_RESULT_GRACE_MS } from '@/constants'
import { filterEventsExcludingTombstones } from '@/lib/event' import { filterEventsExcludingTombstones } from '@/lib/event'
import { normalizeHexPubkey } from '@/lib/pubkey' 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 { TOMBSTONES_UPDATED_EVENT } from '@/lib/tombstone-events'
import { import {
buildSpellCatalogAuthors, buildSpellCatalogAuthors,
@ -80,6 +80,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
pubkey, pubkey,
account, account,
relayList, relayList,
cacheRelayListEvent,
attemptDelete, attemptDelete,
bookmarkListEvent, bookmarkListEvent,
interestListEvent, interestListEvent,
@ -236,6 +237,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
selectedSpell, selectedSpell,
pubkey, pubkey,
relayList, relayList,
cacheRelayListEvent,
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
notificationsFeedPubkey, notificationsFeedPubkey,
@ -264,8 +266,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox( const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
userReadRelaysWithHttp(relayList), userReadInboxUrls(relayList, cacheRelayListEvent),
{ userWriteRelays: relayList?.write ?? [] } { userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent) }
) )
if (!feedUrls.length) { if (!feedUrls.length) {
if (!cancelled) setFollowSetListEvents([]) if (!cancelled) setFollowSetListEvents([])
@ -378,8 +380,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
} }
} }
const urls = getRelaysForSpellCatalogSync(favoriteRelays, blockedRelays, userReadRelaysWithHttp(relayList), { const urls = getRelaysForSpellCatalogSync(favoriteRelays, blockedRelays, userReadInboxUrls(relayList, cacheRelayListEvent), {
userWriteRelays: relayList?.write ?? [], userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent),
useGlobalRelayBootstrap useGlobalRelayBootstrap
}) })
const catalogAuthors = buildSpellCatalogAuthors(pubkey, contacts) const catalogAuthors = buildSpellCatalogAuthors(pubkey, contacts)

36
src/pages/primary/SpellsPage/useSpellsPageFeed.ts

@ -8,7 +8,8 @@ import { normalizeUrl } from '@/lib/url'
import { import {
augmentSubRequestsWithFavoritesFastReadAndInbox, augmentSubRequestsWithFavoritesFastReadAndInbox,
getRelayUrlsWithFavoritesFastReadAndInbox, getRelayUrlsWithFavoritesFastReadAndInbox,
userReadRelaysWithHttp userReadInboxUrls,
userWriteOutboxUrls
} from '@/lib/favorites-feed-relays' } from '@/lib/favorites-feed-relays'
import { stableSpellFeedFilterKey } from '@/lib/spell-feed-request-identity' import { stableSpellFeedFilterKey } from '@/lib/spell-feed-request-identity'
import { isUserInEventMentions } from '@/lib/event' import { isUserInEventMentions } from '@/lib/event'
@ -73,15 +74,16 @@ function buildInboxShardFollowingSubRequests(args: {
authors: string[] authors: string[]
favoriteRelays: string[] favoriteRelays: string[]
blockedRelays: 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[] augment: (raw: TFeedSubRequest[]) => TFeedSubRequest[]
}): TFeedSubRequest[] { }): TFeedSubRequest[] {
const { authors, favoriteRelays, blockedRelays, relayList, augment } = args const { authors, favoriteRelays, blockedRelays, relayList, cacheRelayListEvent, augment } = args
const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox( const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
userReadRelaysWithHttp(relayList), userReadInboxUrls(relayList, cacheRelayListEvent),
{ userWriteRelays: relayList?.write ?? [] } { userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent) }
) )
if (!feedUrls.length) return [] if (!feedUrls.length) return []
const capped = authors.slice(0, FOLLOWING_INBOX_SHARD_AUTHOR_CAP) const capped = authors.slice(0, FOLLOWING_INBOX_SHARD_AUTHOR_CAP)
@ -113,7 +115,8 @@ export type UseSpellsPageFeedArgs = {
selectedFauxSpell: string | null selectedFauxSpell: string | null
selectedSpell: Event | null selectedSpell: Event | null
pubkey: string | null | undefined 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[] favoriteRelays: string[]
blockedRelays: string[] blockedRelays: string[]
notificationsFeedPubkey: string | null notificationsFeedPubkey: string | null
@ -135,6 +138,7 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
selectedSpell, selectedSpell,
pubkey, pubkey,
relayList, relayList,
cacheRelayListEvent,
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
notificationsFeedPubkey, notificationsFeedPubkey,
@ -153,11 +157,9 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
const hideRepliesFollowing = useNoteListHideReplies() const hideRepliesFollowing = useNoteListHideReplies()
const [followingSubRequests, setFollowingSubRequests] = useState<TFeedSubRequest[]>([]) const [followingSubRequests, setFollowingSubRequests] = useState<TFeedSubRequest[]>([])
const normalizedReadSorted = relayList const normalizedReadSorted = relayList ? [...userReadInboxUrls(relayList, cacheRelayListEvent)].sort() : []
? [...relayList.read].map((u) => normalizeUrl(u) || u).filter(Boolean).sort()
: []
const normalizedWriteSorted = relayList const normalizedWriteSorted = relayList
? [...relayList.write].map((u) => normalizeUrl(u) || u).filter(Boolean).sort() ? [...userWriteOutboxUrls(relayList, cacheRelayListEvent)].sort()
: [] : []
const relayMailboxStableKey = const relayMailboxStableKey =
@ -220,8 +222,8 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
raw, raw,
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
userReadRelaysWithHttp(relayList), userReadInboxUrls(relayList, cacheRelayListEvent),
{ userWriteRelays: relayList?.write ?? [] } { userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent) }
) )
try { try {
if (selectedFauxSpell === 'following') { if (selectedFauxSpell === 'following') {
@ -232,6 +234,7 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
relayList, relayList,
cacheRelayListEvent,
augment augment
} }
const syncProvisional = buildInboxShardFollowingSubRequests({ const syncProvisional = buildInboxShardFollowingSubRequests({
@ -294,6 +297,7 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
relayList, relayList,
cacheRelayListEvent,
augment augment
}) })
if (!cancelled && syncReq.length > 0) setFollowingSubRequests(syncReq) if (!cancelled && syncReq.length > 0) setFollowingSubRequests(syncReq)
@ -310,6 +314,7 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
relayList, relayList,
cacheRelayListEvent,
augment augment
}) })
if (!cancelled) setFollowingSubRequests(req) if (!cancelled) setFollowingSubRequests(req)
@ -329,6 +334,7 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
sortedFavoriteRelaysKey, sortedFavoriteRelaysKey,
sortedBlockedRelaysKey, sortedBlockedRelaysKey,
relayMailboxStableKey, relayMailboxStableKey,
cacheRelayListEvent,
followSetCatalogLoading, followSetCatalogLoading,
followSetListStableKey, followSetListStableKey,
followListEvent?.id, followListEvent?.id,
@ -399,9 +405,9 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
getRelayUrlsWithFavoritesFastReadAndInbox( getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
userReadRelaysWithHttp(relayList), userReadInboxUrls(relayList, cacheRelayListEvent),
{ {
userWriteRelays: relayList?.write ?? [], userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent),
applySocialKindBlockedFilter: fauxSpellSkipSocialKindBlocked ? false : undefined applySocialKindBlockedFilter: fauxSpellSkipSocialKindBlocked ? false : undefined
} }
) )
@ -478,7 +484,7 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
const spellSubRequests = useMemo<TFeedSubRequest[]>(() => { const spellSubRequests = useMemo<TFeedSubRequest[]>(() => {
if (!selectedSpell) return [] if (!selectedSpell) return []
const relayListWrite = relayList?.write ?? [] const relayListWrite = userWriteOutboxUrls(relayList, cacheRelayListEvent)
const ctx = { pubkey: pubkey ?? null, contacts } const ctx = { pubkey: pubkey ?? null, contacts }
const filter = spellEventToFilter(selectedSpell, ctx) const filter = spellEventToFilter(selectedSpell, ctx)
if (!filter) return [] if (!filter) return []

13
src/pages/secondary/EmojiSetsSettingsPage/index.tsx

@ -32,10 +32,7 @@ import { randomString } from '@/lib/random'
import { showPublishingError } from '@/lib/publishing-feedback' import { showPublishingError } from '@/lib/publishing-feedback'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays'
getRelayUrlsWithFavoritesFastReadAndInbox,
userReadRelaysWithHttp
} from '@/lib/favorites-feed-relays'
import { createEmojiSetDraftEvent } from '@/lib/draft-event' import { createEmojiSetDraftEvent } from '@/lib/draft-event'
import { filterEventsExcludingTombstones } from '@/lib/event' import { filterEventsExcludingTombstones } from '@/lib/event'
import logger from '@/lib/logger' import logger from '@/lib/logger'
@ -63,7 +60,7 @@ const EMOJI_SET_FETCH_OPTS = {
const EmojiSetsSettingsPage = forwardRef( const EmojiSetsSettingsPage = forwardRef(
({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { ({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, account, publish, attemptDelete, checkLogin, relayList, userEmojiListEvent, profileEvent } = const { pubkey, account, publish, attemptDelete, checkLogin, relayList, cacheRelayListEvent, userEmojiListEvent, profileEvent } =
useNostr() useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [lists, setLists] = useState<Event[]>([]) const [lists, setLists] = useState<Event[]>([])
@ -91,11 +88,11 @@ const EmojiSetsSettingsPage = forwardRef(
const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox( const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
userReadRelaysWithHttp(relayList), userReadInboxUrls(relayList, cacheRelayListEvent),
{ userWriteRelays: relayList?.write ?? [] } { userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent) }
) )
return appendCuratedReadOnlyRelays(feedUrls, blockedRelays) return appendCuratedReadOnlyRelays(feedUrls, blockedRelays)
}, [favoriteRelays, blockedRelays, relayList]) }, [favoriteRelays, blockedRelays, relayList, cacheRelayListEvent])
const loadLists = useCallback(async () => { const loadLists = useCallback(async () => {
if (!pubkey) { if (!pubkey) {

11
src/pages/secondary/FollowSetsSettingsPage/index.tsx

@ -34,10 +34,7 @@ import { randomString } from '@/lib/random'
import { showPublishingError } from '@/lib/publishing-feedback' import { showPublishingError } from '@/lib/publishing-feedback'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays'
getRelayUrlsWithFavoritesFastReadAndInbox,
userReadRelaysWithHttp
} from '@/lib/favorites-feed-relays'
import { createFollowSetDraftEvent } from '@/lib/draft-event' import { createFollowSetDraftEvent } from '@/lib/draft-event'
import { filterEventsExcludingTombstones } from '@/lib/event' import { filterEventsExcludingTombstones } from '@/lib/event'
import logger from '@/lib/logger' import logger from '@/lib/logger'
@ -62,7 +59,7 @@ const FOLLOW_SET_FETCH_OPTS = {
const FollowSetsSettingsPage = forwardRef( const FollowSetsSettingsPage = forwardRef(
({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { ({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation() 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 { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [lists, setLists] = useState<Event[]>([]) const [lists, setLists] = useState<Event[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -87,8 +84,8 @@ const FollowSetsSettingsPage = forwardRef(
const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox( const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
userReadRelaysWithHttp(relayList), userReadInboxUrls(relayList, cacheRelayListEvent),
{ userWriteRelays: relayList?.write ?? [] } { userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent) }
) )
return appendCuratedReadOnlyRelays(feedUrls, blockedRelays) return appendCuratedReadOnlyRelays(feedUrls, blockedRelays)
}, [favoriteRelays, blockedRelays, relayList]) }, [favoriteRelays, blockedRelays, relayList])

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

@ -13,7 +13,8 @@ import {
import { import {
augmentSubRequestsWithFavoritesFastReadAndInbox, augmentSubRequestsWithFavoritesFastReadAndInbox,
getRelayUrlsWithFavoritesFastReadAndInbox, getRelayUrlsWithFavoritesFastReadAndInbox,
userReadRelaysWithHttp userReadInboxUrls,
userWriteOutboxUrls
} from '@/lib/favorites-feed-relays' } from '@/lib/favorites-feed-relays'
import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults' import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
@ -47,7 +48,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
const feedRef = useRef<TNoteListRef>(null) const feedRef = useRef<TNoteListRef>(null)
const bumpFeed = useCallback(() => feedRef.current?.refresh(), []) const bumpFeed = useCallback(() => feedRef.current?.refresh(), [])
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { relayList, pubkey } = useNostr() const { relayList, cacheRelayListEvent, pubkey } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults() const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults()
const interestList = useInterestListOptional() const interestList = useInterestListOptional()
@ -111,7 +112,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
.map((k) => parseInt(k)) .map((k) => parseInt(k))
.filter((k) => !isNaN(k)) .filter((k) => !isNaN(k))
const readUrlOpts = { const readUrlOpts = {
userWriteRelays: relayList?.write ?? [], userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent),
applySocialKindBlockedFilter: kinds.length === 0 || kinds.some(isSocialKindBlockedKind), applySocialKindBlockedFilter: kinds.length === 0 || kinds.some(isSocialKindBlockedKind),
useGlobalFavoriteDefaults: useGlobalRelayBootstrap, useGlobalFavoriteDefaults: useGlobalRelayBootstrap,
includeGlobalFastRead: useGlobalRelayBootstrap includeGlobalFastRead: useGlobalRelayBootstrap
@ -124,7 +125,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
const relayUrls = getRelayUrlsWithFavoritesFastReadAndInbox( const relayUrls = getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
userReadRelaysWithHttp(relayList), userReadInboxUrls(relayList, cacheRelayListEvent),
readUrlOpts readUrlOpts
) )
const mergedSearchKinds = Array.from( const mergedSearchKinds = Array.from(
@ -166,7 +167,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
urls: getRelayUrlsWithFavoritesFastReadAndInbox( urls: getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
userReadRelaysWithHttp(relayList), userReadInboxUrls(relayList, cacheRelayListEvent),
readUrlOpts readUrlOpts
) )
} }
@ -209,8 +210,8 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
urls: getRelayUrlsWithFavoritesFastReadAndInbox( urls: getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
userReadRelaysWithHttp(relayList), userReadInboxUrls(relayList, cacheRelayListEvent),
{ userWriteRelays: relayList?.write ?? [], useGlobalFavoriteDefaults: useGlobalRelayBootstrap, includeGlobalFastRead: useGlobalRelayBootstrap } { userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent), useGlobalFavoriteDefaults: useGlobalRelayBootstrap, includeGlobalFastRead: useGlobalRelayBootstrap }
) )
} }
]) ])
@ -241,9 +242,9 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
raw, raw,
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
userReadRelaysWithHttp(relayList), userReadInboxUrls(relayList, cacheRelayListEvent),
{ {
userWriteRelays: relayList?.write ?? [], userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent),
useGlobalFavoriteDefaults: useGlobalRelayBootstrap, useGlobalFavoriteDefaults: useGlobalRelayBootstrap,
includeGlobalFastRead: useGlobalRelayBootstrap includeGlobalFastRead: useGlobalRelayBootstrap
} }
@ -272,7 +273,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
const relayUrls = getRelayUrlsWithFavoritesFastReadAndInbox( const relayUrls = getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
userReadRelaysWithHttp(relayList), userReadInboxUrls(relayList, cacheRelayListEvent),
readUrlOpts readUrlOpts
) )
const mergedReqKinds = Array.from( const mergedReqKinds = Array.from(

11
src/pages/secondary/RelayReviewsPage/index.tsx

@ -4,10 +4,7 @@ import { RefreshButton } from '@/components/RefreshButton'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls } from '@/lib/favorites-feed-relays'
getRelayUrlsWithFavoritesFastReadAndInbox,
userReadRelaysWithHttp
} from '@/lib/favorites-feed-relays'
import { relayReviewDTagsForRelayUrl, relayReviewsFeedSnapshotKey } from '@/lib/relay-review-feed' import { relayReviewDTagsForRelayUrl, relayReviewsFeedSnapshotKey } from '@/lib/relay-review-feed'
import { normalizeRelayUrlForPage, simplifyUrl } from '@/lib/url' import { normalizeRelayUrlForPage, simplifyUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@ -24,7 +21,7 @@ const RelayReviewsPage = forwardRef(({ url, index, hideTitlebar = false }: { url
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const feedRef = useRef<TNoteListRef>(null) const feedRef = useRef<TNoteListRef>(null)
const bumpFeed = useCallback(() => feedRef.current?.refresh(), []) const bumpFeed = useCallback(() => feedRef.current?.refresh(), [])
const { relayList } = useNostr() const { relayList, cacheRelayListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
useEffect(() => { useEffect(() => {
@ -52,10 +49,10 @@ const RelayReviewsPage = forwardRef(({ url, index, hideTitlebar = false }: { url
const base = getRelayUrlsWithFavoritesFastReadAndInbox( const base = getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
userReadRelaysWithHttp(relayList) userReadInboxUrls(relayList, cacheRelayListEvent)
) )
return [...new Set([normalizedUrl, ...base])] return [...new Set([normalizedUrl, ...base])]
}, [normalizedUrl, favoriteRelays, blockedRelays, relayList]) }, [normalizedUrl, favoriteRelays, blockedRelays, relayList, cacheRelayListEvent])
const reviewsSubRequests = useMemo<TFeedSubRequest[]>(() => { const reviewsSubRequests = useMemo<TFeedSubRequest[]>(() => {
if (!normalizedUrl || relayReviewDTags.length === 0) return [] if (!normalizedUrl || relayReviewDTags.length === 0) return []
return [ return [

42
src/providers/FeedProvider.tsx

@ -1,12 +1,14 @@
import { DEFAULT_FAVORITE_RELAYS } from '@/constants' import { DEFAULT_FAVORITE_RELAYS } from '@/constants'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { getRelayListFromEvent, getHttpRelayListFromEvent } from '@/lib/event-metadata'
import { buildAllFavoritesFeedRelayUrls, stripNostrLandAggrFromRelayUrls } from '@/lib/home-feed-relays' import { buildAllFavoritesFeedRelayUrls, stripNostrLandAggrFromRelayUrls } from '@/lib/home-feed-relays'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { import {
syncViewerRelayStackNostrLandAggrEligible, syncViewerRelayStackNostrLandAggrEligible,
urlsForViewerNostrLandAggrEligibilitySync urlsForViewerNostrLandAggrEligibilitySync
} from '@/lib/nostr-land-relay-eligibility' } 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 { normalizeAnyRelayUrl } from '@/lib/url'
import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
@ -54,7 +56,7 @@ function buildHomeReplyFeedRelayUrls(
} }
export function FeedProvider({ children }: { children: ReactNode }) { 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 { favoriteRelays, blockedRelays, relaySets } = useFavoriteRelays()
const useGlobalRelayDefaults = useMemo( 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. */ /** Read-side layers merged into {@link replyRelayUrls}; {@link outboxRelayUrls} is only for aggr eligibility sync. */
const replyExtraRelayLayers = useMemo(() => { const replyExtraRelayLayers = useMemo(() => {
const cacheRelayUrls: string[] = [] const cacheRelayUrls = getCacheRelayUrlsFromEvent(cacheRelayListEvent)
if (cacheRelayListEvent) {
const list = getRelayListFromEvent(cacheRelayListEvent, blockedRelays, {
globalReadWriteFallback: useGlobalRelayDefaults
})
cacheRelayUrls.push(...list.read)
}
const httpRelayUrls: string[] = [...(relayList?.httpRead ?? [])] const hasReadMailbox =
if (httpRelayListEvent) { cacheRelayUrls.length > 0 ||
const list = getHttpRelayListFromEvent(httpRelayListEvent, blockedRelays) (relayList?.read?.length ?? 0) > 0 ||
httpRelayUrls.push(...list.httpRead) (relayList?.httpRead?.length ?? 0) > 0
} const hasWriteMailbox =
cacheRelayUrls.length > 0 ||
(relayList?.write?.length ?? 0) > 0 ||
(relayList?.httpWrite?.length ?? 0) > 0
return { return {
inboxRelayUrls: relayList?.read?.length inboxRelayUrls: hasReadMailbox
? relayList.read ? collectUserReadInboxUrls(relayList, cacheRelayUrls)
: useGlobalRelayDefaults : useGlobalRelayDefaults
? DEFAULT_FAVORITE_RELAYS ? DEFAULT_FAVORITE_RELAYS
: [], : [],
outboxRelayUrls: relayList?.write?.length outboxRelayUrls: hasWriteMailbox
? relayList.write ? collectUserWriteOutboxUrls(relayList, cacheRelayUrls)
: useGlobalRelayDefaults : useGlobalRelayDefaults
? DEFAULT_FAVORITE_RELAYS ? DEFAULT_FAVORITE_RELAYS
: [], : [],
cacheRelayUrls, /** Kept for feed-layer identity / aggr sync; URLs are merged into inbox/outbox above. */
httpRelayUrls 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. */ /** Default relays immediately so feeds / sidebar REQ never wait on Nostr session restore. */
const [relayUrls, setRelayUrls] = useState<string[]>(() => const [relayUrls, setRelayUrls] = useState<string[]>(() =>

14
src/providers/LiveActivitiesProvider.tsx

@ -8,7 +8,7 @@ import {
resolveParentSpacesForLiveActivities, resolveParentSpacesForLiveActivities,
type TLiveActivityItem type TLiveActivityItem
} from '@/lib/live-activities' } 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 logger from '@/lib/logger'
import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -22,7 +22,7 @@ import { useNostr } from './NostrProvider'
import { useUserPreferencesOptional } from './UserPreferencesProvider' import { useUserPreferencesOptional } from './UserPreferencesProvider'
export function LiveActivitiesProvider({ children }: { children: React.ReactNode }) { 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 { favoriteRelays, blockedRelays } = useFavoriteRelays()
const followListCtx = useFollowListOptional() const followListCtx = useFollowListOptional()
const followings = followListCtx?.followings ?? [] const followings = followListCtx?.followings ?? []
@ -50,8 +50,14 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
/** Collapse boot + session-prewarm + StrictMode into one network pass. */ /** Collapse boot + session-prewarm + StrictMode into one network pass. */
const LIVE_ACTIVITIES_MIN_REFRESH_GAP_MS = 8_000 const LIVE_ACTIVITIES_MIN_REFRESH_GAP_MS = 8_000
const relayRead = useMemo(() => userReadRelaysWithHttp(relayList), [relayList]) const relayRead = useMemo(
const relayWrite = relayList?.write ?? [] () => userReadInboxUrls(relayList, cacheRelayListEvent),
[relayList, cacheRelayListEvent]
)
const relayWrite = useMemo(
() => userWriteOutboxUrls(relayList, cacheRelayListEvent),
[relayList, cacheRelayListEvent]
)
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (!showLiveActivitiesBanner) { if (!showLiveActivitiesBanner) {

1
src/providers/NostrProvider/index.tsx

@ -1844,6 +1844,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
await indexedDb.putReplaceableEvent(httpRelayEvent) await indexedDb.putReplaceableEvent(httpRelayEvent)
if (account?.pubkey) { if (account?.pubkey) {
client.clearRelayListCache(account.pubkey) client.clearRelayListCache(account.pubkey)
await client.syncViewerPersonalRelayKeys(account.pubkey)
} }
setHttpRelayListEvent(httpRelayEvent) setHttpRelayListEvent(httpRelayEvent)
const mergedRelayList = await client.fetchRelayList(account?.pubkey || '') const mergedRelayList = await client.fetchRelayList(account?.pubkey || '')

179
src/services/client.service.ts

@ -36,6 +36,17 @@ import {
} from '@/constants' } from '@/constants'
import { getCacheRelayUrls } from '@/lib/private-relays' 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 { import {
buildPersonalRelayKeySet, buildPersonalRelayKeySet,
sanitizeRelayUrlsForFetch, sanitizeRelayUrlsForFetch,
@ -141,8 +152,7 @@ import { filterRelaysForEventPublish } from '@/lib/relay-publish-filter'
import { getPaymentAttestationTargetId } from '@/lib/superchat' import { getPaymentAttestationTargetId } from '@/lib/superchat'
import { import {
buildPublicMessagePublishRelayUrls, buildPublicMessagePublishRelayUrls,
collectRecipientInboxUrls, collectRecipientInboxUrls
collectSenderOutboxUrls
} from '@/lib/public-message-publish-relays' } from '@/lib/public-message-publish-relays'
import { buildPrioritizedWriteRelayUrls, dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority' import { buildPrioritizedWriteRelayUrls, dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority'
import { import {
@ -173,6 +183,7 @@ import {
isWebsocketUrl, isWebsocketUrl,
normalizeAnyRelayUrl, normalizeAnyRelayUrl,
normalizeHttpRelayUrl, normalizeHttpRelayUrl,
normalizeRelayUrlByScheme,
normalizeUrl, normalizeUrl,
simplifyUrl, simplifyUrl,
urlMatchesConfiguredHttpIndexRelay urlMatchesConfiguredHttpIndexRelay
@ -626,12 +637,11 @@ class ClientService extends EventTarget {
const extra: string[] = [] const extra: string[] = []
if (pubkey) { if (pubkey) {
const rl = await this.peekRelayListFromStorage(pubkey) const rl = await this.peekRelayListFromStorage(pubkey)
extra.push( const [readInboxes, writeOutboxes] = await Promise.all([
...(rl.read ?? []), collectViewerReadInboxUrls(pubkey, rl),
...(rl.write ?? []), collectViewerWriteOutboxUrls(pubkey, rl)
...(rl.httpRead ?? []), ])
...(rl.httpWrite ?? []) extra.push(...readInboxes, ...writeOutboxes)
)
} }
await preloadGifsIntoIdbCache(pubkey, extra) await preloadGifsIntoIdbCache(pubkey, extra)
} }
@ -752,6 +762,35 @@ class ClientService extends EventTarget {
return { all, httpIndexBases, cacheRelayEvent } 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<string[]> {
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). */ /** Kind 10012 + embedded NIP-51 relay sets from IndexedDB only (no network). */
async fetchFavoriteRelaysFromStorage(pubkey: string): Promise<string[]> { async fetchFavoriteRelaysFromStorage(pubkey: string): Promise<string[]> {
try { try {
@ -858,48 +897,20 @@ class ClientService extends EventTarget {
const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u))
return dedupeNormalizeRelayUrlsOrdered( return dedupeNormalizeRelayUrlsOrdered(
filterRelaysForEventPublish(relays, event.kind).filter((url) => { 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 if (isSocialKindBlockedKind(event.kind) && socialKindBlockedSet.has(n)) return false
return true 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<string[]> {
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 { 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 { private relayListUsableForInboxOrdering(relayList: TRelayList): boolean {
return ( return (
this.relayListHasWriteUrls(relayList) || this.relayListHasWriteUrls(relayList) || collectUserReadInboxUrls(relayList).length > 0
(relayList.read?.length ?? 0) > 0 ||
(relayList.httpRead?.length ?? 0) > 0
) )
} }
@ -914,23 +925,12 @@ class ClientService extends EventTarget {
return this.fetchRelayListWithPublishTimeout(pubkey) 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<string[]> { private async getUserOutboxRelayUrlsForPublish(event: NEvent): Promise<string[]> {
try { try {
const relayList = await this.peekOrFetchRelayListForPublish(event.pubkey) const relayList = await this.peekOrFetchRelayListForPublish(event.pubkey)
if (!this.relayListHasWriteUrls(relayList)) { const raw = await collectViewerWriteOutboxUrls(event.pubkey, relayList)
return [] if (raw.length === 0) 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)
])
return this.filterPublishingRelays(raw, event) return this.filterPublishingRelays(raw, event)
} catch { } catch {
return [] return []
@ -942,7 +942,7 @@ class ClientService extends EventTarget {
userOutboxUrls: string[], userOutboxUrls: string[],
relayStatuses: { url: string; success: boolean; error?: string }[] relayStatuses: { url: string; success: boolean; error?: string }[]
): Promise<void> { ): Promise<void> {
const norm = (u: string) => normalizeAnyRelayUrl(u) || u const norm = (u: string) => normalizeRelayUrlByScheme(u) || u
const hadSuccess = new Set<string>() const hadSuccess = new Set<string>()
for (const r of relayStatuses) { for (const r of relayStatuses) {
if (r.success) hadSuccess.add(norm(r.url)) 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 // For Report events, always include user's write relays first, then add seen relays if they're write-capable
if (event.kind === kinds.Report) { 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 relayList = await this.fetchRelayListWithPublishTimeout(event.pubkey)
const reportHttpWrites = (relayList?.httpWrite ?? []) const userWriteRelays = await collectViewerWriteOutboxUrls(event.pubkey, relayList)
.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])
// Get seen relays where the reported event was found // Get seen relays where the reported event was found
const targetEventId = event.tags.find(tagNameEquals('e'))?.[1] const targetEventId = event.tags.find(tagNameEquals('e'))?.[1]
@ -1209,9 +1202,9 @@ class ClientService extends EventTarget {
const allSeenRelays = this.getSeenEventRelayUrls(targetEventId) const allSeenRelays = this.getSeenEventRelayUrls(targetEventId)
// Filter seen relays: only include those that are in user's write list // 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 // 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 => { seenRelays.push(...allSeenRelays.filter(url => {
const normalized = normalizeAnyRelayUrl(url) || url const normalized = normalizeRelayUrlByScheme(url) || url
return userWriteRelaySet.has(normalized) return userWriteRelaySet.has(normalized)
})) }))
} }
@ -1273,7 +1266,7 @@ class ClientService extends EventTarget {
this.fetchRelayListWithPublishTimeout(event.pubkey), this.fetchRelayListWithPublishTimeout(event.pubkey),
recipientListsPromise recipientListsPromise
]) ])
const authorWrite = collectSenderOutboxUrls(authorRelayList) const authorWrite = await collectViewerWriteOutboxUrls(event.pubkey, authorRelayList)
const recipientRead = dedupeNormalizeRelayUrlsOrdered( const recipientRead = dedupeNormalizeRelayUrlsOrdered(
recipientRelayLists.flatMap((rl) => collectRecipientInboxUrls(rl)) recipientRelayLists.flatMap((rl) => collectRecipientInboxUrls(rl))
) )
@ -1303,7 +1296,7 @@ class ClientService extends EventTarget {
? this.fetchRelayListsWithPublishTimeout(senderPubkeys) ? this.fetchRelayListsWithPublishTimeout(senderPubkeys)
: Promise.resolve([] as TRelayList[]) : Promise.resolve([] as TRelayList[])
]) ])
const authorWrite = collectSenderOutboxUrls(authorRelayList) const authorWrite = await collectViewerWriteOutboxUrls(event.pubkey, authorRelayList)
const authorRead = collectRecipientInboxUrls(authorRelayList) const authorRead = collectRecipientInboxUrls(authorRelayList)
const senderInboxes = dedupeNormalizeRelayUrlsOrdered( const senderInboxes = dedupeNormalizeRelayUrlsOrdered(
senderRelayLists.flatMap((rl) => collectRecipientInboxUrls(rl)) senderRelayLists.flatMap((rl) => collectRecipientInboxUrls(rl))
@ -1351,16 +1344,10 @@ class ClientService extends EventTarget {
}) })
spellRelayList = this.emptyRelayListForPublish() spellRelayList = this.emptyRelayListForPublish()
} }
const spellHttpWrites = (spellRelayList?.httpWrite ?? []) const spellWriteFilteredRaw = await collectViewerWriteOutboxUrls(event.pubkey, spellRelayList)
.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 readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u)) const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u))
const spellWriteFiltered = normalizedWrite.filter((url) => { const spellWriteFiltered = spellWriteFilteredRaw.filter((url) => {
const n = normalizeAnyRelayUrl(url) || url const n = normalizeRelayUrlByScheme(url) || url
return !readOnlySet.has(n) return !readOnlySet.has(n)
}) })
return this.filterPublishingRelays( return this.filterPublishingRelays(
@ -1395,14 +1382,7 @@ class ClientService extends EventTarget {
const relayListPromise = this.fetchRelayListWithPublishTimeout(event.pubkey) const relayListPromise = this.fetchRelayListWithPublishTimeout(event.pubkey)
const [relayLists, relayList] = await Promise.all([relayListsPromise, relayListPromise]) const [relayLists, relayList] = await Promise.all([relayListsPromise, relayListPromise])
relayLists.forEach((rl) => { relayLists.forEach((rl) => {
for (const u of rl.httpRead ?? []) { authorInboxFromContext.push(...collectRemoteReadInboxUrlsFromRelayList(rl))
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)
}
}) })
if ( if (
isAuthorProfileMetadataPublishKind(event.kind) || isAuthorProfileMetadataPublishKind(event.kind) ||
@ -1461,15 +1441,10 @@ class ClientService extends EventTarget {
writeRelays: relayList?.write?.slice(0, MAX_PUBLISH_RELAYS) ?? [] writeRelays: relayList?.write?.slice(0, MAX_PUBLISH_RELAYS) ?? []
}) })
} }
const wsWrites = (relayList?.write ?? []) const userWritesOrdered = await collectViewerWriteOutboxUrls(
.map((u) => normalizeUrl(u) || u) event.pubkey,
.filter((u): u is string => !!u) relayList ?? this.emptyRelayListForPublish()
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])
relays = this.filterPublishingRelays( relays = this.filterPublishingRelays(
buildPrioritizedWriteRelayUrls({ buildPrioritizedWriteRelayUrls({
userWriteRelays: userWritesOrdered, userWriteRelays: userWritesOrdered,
@ -1668,7 +1643,7 @@ class ClientService extends EventTarget {
const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u))
let filtered = filterRelaysForEventPublish(mergedRelayUrls, event.kind).filter((url) => { 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 if (isSocialKindBlockedKind(event.kind) && socialKindBlockedSet.has(n)) return false
return true return true
}) })
@ -1762,6 +1737,8 @@ class ClientService extends EventTarget {
const publishOpBatch = new RelayPublishOpBatch(publishBatchSource, event.id, publishTargetUrls) const publishOpBatch = new RelayPublishOpBatch(publishBatchSource, event.id, publishTargetUrls)
publishOpBatch.logBegin() publishOpBatch.logBegin()
const httpIndexBasesForPublish = await this.resolveViewerHttpIndexBasesForPublish(publishTargetUrls)
// eslint-disable-next-line @typescript-eslint/no-this-alias // eslint-disable-next-line @typescript-eslint/no-this-alias
const client = this const client = this
return new Promise<{ success: boolean; relayStatuses: typeof relayStatuses; successCount: number; totalCount: number }>((resolve) => { 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) }, connectionTimeout + publishAckBudgetMs + 2_000)
try { try {
if (urlMatchesConfiguredHttpIndexRelay(url, this.viewerHttpIndexRelayBases)) { if (urlMatchesConfiguredHttpIndexRelay(url, httpIndexBasesForPublish)) {
const base = normalizeHttpRelayUrl(url) || url const base = normalizeHttpRelayUrl(url) || url
logger.debug(`[PublishEvent] Publishing to kind 10243 HTTP index relay`, { url: base }) logger.debug(`[PublishEvent] Publishing to kind 10243 HTTP index relay`, { url: base })
await Promise.race([ await Promise.race([
@ -2033,7 +2010,7 @@ class ClientService extends EventTarget {
} }
} catch (error) { } catch (error) {
const softHttpDown = const softHttpDown =
urlMatchesConfiguredHttpIndexRelay(url, this.viewerHttpIndexRelayBases) && urlMatchesConfiguredHttpIndexRelay(url, httpIndexBasesForPublish) &&
(error instanceof IndexRelayTransportError || isIndexRelayTransportFailure(error)) (error instanceof IndexRelayTransportError || isIndexRelayTransportFailure(error))
if (softHttpDown) { if (softHttpDown) {
logger.debug('[PublishEvent] HTTP index relay unreachable', { logger.debug('[PublishEvent] HTTP index relay unreachable', {
@ -4551,9 +4528,7 @@ class ClientService extends EventTarget {
*/ */
async getMailboxStackWriteUrlsForRepublish(pubkey: string): Promise<string[]> { async getMailboxStackWriteUrlsForRepublish(pubkey: string): Promise<string[]> {
const rl = await this.peekRelayListFromStorage(pubkey) const rl = await this.peekRelayListFromStorage(pubkey)
const ws = (rl.write ?? []).map((u) => normalizeUrl(u) || u).filter((u): u is string => !!u) return collectViewerWriteOutboxUrls(pubkey, rl)
const http = (rl.httpWrite ?? []).map((u) => normalizeHttpRelayUrl(u) || u).filter((u): u is string => !!u)
return dedupeNormalizeRelayUrlsOrdered([...http, ...ws])
} }
/** Newest kind 10002 for `pubkey` from IndexedDB and/or session LRU (session may hold a copy not persisted yet). */ /** 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 [] if (!/^[0-9a-f]{64}$/.test(pk)) return []
const relayList = await this.fetchRelayList(pk) const relayList = await this.fetchRelayList(pk)
const urls = dedupeNormalizeRelayUrlsOrdered([ const urls = dedupeNormalizeRelayUrlsOrdered([
...relayList.write.map((u) => normalizeUrl(u) || u), ...collectWriteOutboxUrlsFromRelayList(relayList),
...relayList.read.map((u) => normalizeUrl(u) || u), ...collectReadInboxUrlsFromRelayList(relayList),
...FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u), ...FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u),
...PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u) ...PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u)
]).filter(Boolean) ]).filter(Boolean)
@ -5127,7 +5102,9 @@ class ClientService extends EventTarget {
let urls = [...publicReadRelayFallbackUrls()] let urls = [...publicReadRelayFallbackUrls()]
if (myPubkey) { if (myPubkey) {
const relayList = await this.fetchRelayList(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 } }] return [{ urls, filter: { authors: pubkeys } }]
} }

89
src/services/relay-selection.service.ts

@ -1,10 +1,8 @@
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { ExtendedKind, FAST_WRITE_RELAY_URLS, RANDOM_PUBLISH_RELAY_COUNT } from '@/constants' import { ExtendedKind, FAST_WRITE_RELAY_URLS, RANDOM_PUBLISH_RELAY_COUNT } from '@/constants'
import { filterRelaysForEventPublish } from '@/lib/relay-publish-filter' import { filterRelaysForEventPublish } from '@/lib/relay-publish-filter'
import { import { collectRecipientInboxUrls, collectSenderOutboxUrls } from '@/lib/public-message-publish-relays'
collectRecipientInboxUrls, import { collectViewerWriteOutboxUrls } from '@/lib/viewer-write-outboxes'
collectSenderOutboxUrls
} from '@/lib/public-message-publish-relays'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX } from '@/lib/content-patterns' import { NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX } from '@/lib/content-patterns'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -20,6 +18,7 @@ import logger from '@/lib/logger'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { getHttpRelayListFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import { getHttpRelayListFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
import { stripLocalNetworkRelaysFromRelayList } from '@/lib/relay-list-sanitize' import { stripLocalNetworkRelaysFromRelayList } from '@/lib/relay-list-sanitize'
import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority'
import nip66Service from '@/services/nip66.service' import nip66Service from '@/services/nip66.service'
export interface RelaySelectionContext { export interface RelaySelectionContext {
@ -68,6 +67,18 @@ class RelaySelectionService {
* Filter out local network relays from other users' relay lists * Filter out local network relays from other users' relay lists
* We should only use our own local relays, not other users' local relays * 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[] { private filterLocalRelaysFromOthers(relays: string[], isOwnRelays: boolean = false): string[] {
if (isOwnRelays) { if (isOwnRelays) {
// For our own relays, keep all of them including local ones // For our own relays, keep all of them including local ones
@ -133,7 +144,7 @@ class RelaySelectionService {
.filter(Boolean) .filter(Boolean)
) )
filtered.forEach((url) => { 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: [] } return { relays: filtered, relayTypes, randomRelayUrls: [] }
} }
@ -396,7 +407,6 @@ class RelaySelectionService {
context: RelaySelectionContext context: RelaySelectionContext
): Promise<string[]> { ): Promise<string[]> {
const { const {
userWriteRelays,
parentEvent, parentEvent,
isPublicMessage, isPublicMessage,
openFrom, openFrom,
@ -406,11 +416,12 @@ class RelaySelectionService {
let selectedRelays: string[] = [] 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 called with specific relay URLs, use those
if (openFrom && openFrom.length > 0) { 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 // 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)) { 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 // For regular replies, use user's write relays + mention relays
else if (parentEvent && this.isRegularReply(parentEvent)) { else if (parentEvent && this.isRegularReply(parentEvent)) {
const userRelays = userWriteRelays.length > 0 ? userWriteRelays : FAST_WRITE_RELAY_URLS selectedRelays = Array.from(new Set(defaultOutboxes.map((url) => this.normRelay(url)).filter(Boolean)))
selectedRelays = Array.from(new Set(userRelays.map(norm).filter(Boolean)))
// Add mention relays // Add mention relays
if (userPubkey) { if (userPubkey) {
@ -441,28 +451,27 @@ class RelaySelectionService {
try { try {
const relayList = await this.getCachedRelayList(pubkey) const relayList = await this.getCachedRelayList(pubkey)
if (!relayList) return [] if (!relayList) return []
return this.filterLocalRelaysFromOthers(relayList.write || []) return this.filterLocalRelaysFromOthers(collectSenderOutboxUrls(relayList))
} catch (error) { } catch (error) {
logger.warn('Failed to get cached relay list', { pubkey, error }) logger.warn('Failed to get cached relay list', { pubkey, error })
return [] 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])) selectedRelays = Array.from(new Set([...selectedRelays, ...mentionRelays]))
} }
} }
} }
// Default: user's write relays (or fallback to fast write relays if no user relays) // Default: user's write relays (or fallback to fast write relays if no user relays)
else { else {
const defaultRelays = userWriteRelays.length > 0 ? userWriteRelays : FAST_WRITE_RELAY_URLS selectedRelays = Array.from(new Set(defaultOutboxes.map((url) => this.normRelay(url)).filter(Boolean)))
selectedRelays = Array.from(new Set(defaultRelays.map(norm).filter(Boolean)))
} }
// ALWAYS include cache relays (local network relays) in selected relays // 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) { 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 // 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) { if (senderRelays.length === 0) {
try { try {
const userRelayList = await this.getCachedRelayList(userPubkey) const userRelayList = await this.getCachedRelayList(userPubkey)
senderRelays = collectSenderOutboxUrls(userRelayList) if (userRelayList) {
senderRelays = await collectViewerWriteOutboxUrls(userPubkey, userRelayList)
}
} catch (error) { } catch (error) {
logger.warn('Failed to fetch user relay list for PM', { error, userPubkey }) logger.warn('Failed to fetch user relay list for PM', { error, userPubkey })
} }
} }
senderRelays.forEach(url => { senderRelays.forEach(url => {
const normalized = normalizeAnyRelayUrl(url) const normalized = this.normRelay(url)
if (normalized) { if (!normalized) return
if (!relayToMembers.has(normalized)) { if (!relayToMembers.has(normalized)) {
relayToMembers.set(normalized, new Set()) relayToMembers.set(normalized, new Set())
} }
relayToMembers.get(normalized)!.add(userPubkey) relayToMembers.get(normalized)!.add(userPubkey)
}
}) })
} }
@ -561,13 +571,12 @@ class RelaySelectionService {
recipientRelayLists.forEach((relays, index) => { recipientRelayLists.forEach((relays, index) => {
const pubkey = recipientPubkeys[index] const pubkey = recipientPubkeys[index]
relays.forEach(url => { relays.forEach(url => {
const normalized = normalizeAnyRelayUrl(url) const normalized = this.normRelay(url)
if (normalized) { if (!normalized) return
if (!relayToMembers.has(normalized)) { if (!relayToMembers.has(normalized)) {
relayToMembers.set(normalized, new Set()) relayToMembers.set(normalized, new Set())
} }
relayToMembers.get(normalized)!.add(pubkey) relayToMembers.get(normalized)!.add(pubkey)
}
}) })
}) })
} }
@ -627,7 +636,7 @@ class RelaySelectionService {
// Normalize and deduplicate final list // Normalize and deduplicate final list
const normalizedRelays = relays const normalizedRelays = relays
.map(url => normalizeAnyRelayUrl(url)) .map((url) => this.normRelay(url))
.filter((url): url is string => !!url) .filter((url): url is string => !!url)
return Array.from(new Set(normalizedRelays)) 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 * Includes: relay hints from kind 11, wss://thecitadel.nostr1.com, user's outboxes, and local relays
*/ */
private async getDiscussionReplyRelays(context: RelaySelectionContext): Promise<string[]> { private async getDiscussionReplyRelays(context: RelaySelectionContext): Promise<string[]> {
const { parentEvent, userWriteRelays, userPubkey, blockedRelays } = context const { parentEvent, userPubkey, blockedRelays } = context
if (!parentEvent) return [] if (!parentEvent) return []
const relayUrls = new Set<string>() const relayUrls = new Set<string>()
const userOutboxes = this.userWriteOutboxRelays(context)
// Step 1: Get relay hints from the kind 11 event // Step 1: Get relay hints from the kind 11 event
let discussionEventId: string | null = null let discussionEventId: string | null = null
@ -700,24 +710,21 @@ class RelaySelectionService {
relayUrls.add(thecitadelUrl) relayUrls.add(thecitadelUrl)
} }
// Step 3: Add user's outboxes (write relays from kind 10002) // Step 3: Add user's outboxes (NIP-65 + HTTP index write relays)
if (userWriteRelays.length > 0) { if (userOutboxes.length > 0) {
userWriteRelays.forEach(url => { userOutboxes.forEach((url) => {
const normalized = normalizeAnyRelayUrl(url) const normalized = this.normRelay(url)
if (normalized) { if (normalized) relayUrls.add(normalized)
relayUrls.add(normalized)
}
}) })
} else if (userPubkey) { } else if (userPubkey) {
// Fetch user's relay list if not provided // Fetch user's relay list if not provided
try { try {
const relayList = await this.getCachedRelayList(userPubkey) const relayList = await this.getCachedRelayList(userPubkey)
if (relayList?.write) { if (relayList) {
relayList.write.forEach(url => { const outboxes = await collectViewerWriteOutboxUrls(userPubkey, relayList)
const normalized = normalizeAnyRelayUrl(url) outboxes.forEach((url) => {
if (normalized) { const normalized = this.normRelay(url)
relayUrls.add(normalized) if (normalized) relayUrls.add(normalized)
}
}) })
} }
} catch (error) { } catch (error) {
@ -746,7 +753,7 @@ class RelaySelectionService {
// Step 5: Convert to array, normalize, and deduplicate // Step 5: Convert to array, normalize, and deduplicate
const normalizedRelays = Array.from(relayUrls) const normalizedRelays = Array.from(relayUrls)
.map(url => normalizeAnyRelayUrl(url)) .map((url) => this.normRelay(url))
.filter((url): url is string => !!url) .filter((url): url is string => !!url)
const deduplicatedRelays = Array.from(new Set(normalizedRelays)) const deduplicatedRelays = Array.from(new Set(normalizedRelays))
@ -849,9 +856,7 @@ class RelaySelectionService {
return relays return relays
} }
const safeNormalize = (url: string): string => { const safeNormalize = (url: string): string => this.normRelay(url)
return normalizeAnyRelayUrl(url) || url
}
const normalizedBlocked = blockedRelays.map(safeNormalize) const normalizedBlocked = blockedRelays.map(safeNormalize)
return relays.filter(relay => { return relays.filter(relay => {

Loading…
Cancel
Save