Browse Source

fix http relay service

imwald
Silberengel 3 weeks ago
parent
commit
8f1d44404a
  1. 2
      src/components/ContentPreview/FollowPackPreview.tsx
  2. 2
      src/components/FollowButton/index.tsx
  3. 34
      src/components/NoteOptions/useMenuActions.tsx
  4. 2
      src/components/PostEditor/PostContent.tsx
  5. 2
      src/components/Profile/Followings.tsx
  6. 2
      src/components/Profile/SmartFollowings.tsx
  7. 12
      src/components/Profile/index.tsx
  8. 12
      src/components/ProfileOptions/index.tsx
  9. 11
      src/components/Relay/index.tsx
  10. 4
      src/components/ReplyNoteList/index.tsx
  11. 8
      src/components/SaveRelayDropdownMenu/index.tsx
  12. 6
      src/components/SearchBar/index.tsx
  13. 2
      src/components/TopicSubscribeButton/index.tsx
  14. 4
      src/hooks/useQuoteEvents.tsx
  15. 6
      src/lib/account-list-relay-urls.ts
  16. 26
      src/lib/index-relay-http.ts
  17. 25
      src/lib/replaceable-list-latest.ts
  18. 18
      src/lib/url.ts
  19. 8
      src/pages/primary/ExplorePage/index.tsx
  20. 4
      src/pages/primary/RelayPage/index.tsx
  21. 2
      src/pages/secondary/InterestListPage/index.tsx
  22. 2
      src/pages/secondary/NoteListPage/index.tsx
  23. 4
      src/pages/secondary/RelayPage/index.tsx
  24. 4
      src/pages/secondary/RelayReviewsPage/index.tsx
  25. 20
      src/providers/FavoriteRelaysProvider.tsx
  26. 12
      src/providers/FeedProvider.tsx
  27. 24
      src/providers/FollowListProvider.tsx
  28. 20
      src/providers/GroupListProvider.tsx
  29. 28
      src/providers/InterestListProvider.tsx
  30. 2
      src/providers/LiveActivitiesProvider.tsx
  31. 8
      src/providers/NostrProvider/index.tsx
  32. 22
      src/providers/follow-list-context.tsx
  33. 18
      src/providers/group-list-context.tsx
  34. 27
      src/providers/interest-list-context.tsx
  35. 132
      src/services/client.service.ts
  36. 8
      src/services/relay-info.service.ts
  37. 4
      src/services/relay-operation-log.service.ts
  38. 2
      vite.config.ts

2
src/components/ContentPreview/FollowPackPreview.tsx

@ -3,7 +3,7 @@ import { getImetaInfosFromEvent } from '@/lib/event'
import { getPubkeysFromPTags } from '@/lib/tag' import { getPubkeysFromPTags } from '@/lib/tag'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useFollowListOptional } from '@/providers/FollowListProvider' import { useFollowListOptional } from '@/providers/follow-list-context'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'

2
src/components/FollowButton/index.tsx

@ -11,7 +11,7 @@ import {
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useFollowListOptional } from '@/providers/FollowListProvider' import { useFollowListOptional } from '@/providers/follow-list-context'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'

34
src/components/NoteOptions/useMenuActions.tsx

@ -11,7 +11,7 @@ import {
parsePublicationATagCoordinate, parsePublicationATagCoordinate,
type PublicationSectionRef type PublicationSectionRef
} from '@/lib/publication-section-fetch' } from '@/lib/publication-section-fetch'
import { normalizeUrl, simplifyUrl } from '@/lib/url' import { normalizeAnyRelayUrl, normalizeHttpRelayUrl, simplifyUrl } from '@/lib/url'
import { speakNoteReadAloud } from '@/lib/read-aloud' import { speakNoteReadAloud } from '@/lib/read-aloud'
import { import {
buildPinListTagsAfterToggle, buildPinListTagsAfterToggle,
@ -104,25 +104,30 @@ export function useMenuActions({
// Use useContext directly to avoid error if provider is not available // Use useContext directly to avoid error if provider is not available
const primaryPageContext = useContext(PrimaryPageContext) const primaryPageContext = useContext(PrimaryPageContext)
const currentPrimaryPage = primaryPageContext?.current ?? null const currentPrimaryPage = primaryPageContext?.current ?? null
const { pubkey, profile, attemptDelete, publish, account } = useNostr() const { pubkey, profile, attemptDelete, publish, account, relayList } = useNostr()
const canSignEvents = account != null && account.signerType !== 'npub' const canSignEvents = account != null && account.signerType !== 'npub'
const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays() const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays()
const { relaySets, favoriteRelays } = useFavoriteRelays() const { relaySets, favoriteRelays } = useFavoriteRelays()
const httpWriteRelayUrls = useMemo(() => {
return (relayList?.httpWrite ?? [])
.map(url => normalizeHttpRelayUrl(url) || url)
.filter(Boolean) as string[]
}, [relayList?.httpWrite])
const relayUrls = useMemo(() => { const relayUrls = useMemo(() => {
return Array.from(new Set([ return Array.from(new Set([
...currentBrowsingRelayUrls.map(url => normalizeUrl(url) || url), ...currentBrowsingRelayUrls.map(url => normalizeAnyRelayUrl(url) || url),
...favoriteRelays.map(url => normalizeUrl(url) || url) ...favoriteRelays.map(url => normalizeAnyRelayUrl(url) || url)
])) ]))
}, [currentBrowsingRelayUrls, favoriteRelays]) }, [currentBrowsingRelayUrls, favoriteRelays])
/** All available relays: current feed, favorites, relay sets, defaults (BIG, FAST_READ, FAST_WRITE). */ /** All available relays: current feed, favorites, relay sets, defaults (BIG, FAST_READ, FAST_WRITE). */
const allAvailableRelayUrls = useMemo(() => { const allAvailableRelayUrls = useMemo(() => {
const urls = [ const urls = [
...currentBrowsingRelayUrls.map(url => normalizeUrl(url) || url), ...currentBrowsingRelayUrls.map(url => normalizeAnyRelayUrl(url) || url),
...favoriteRelays.map(url => normalizeUrl(url) || url), ...favoriteRelays.map(url => normalizeAnyRelayUrl(url) || url),
...relaySets.flatMap(set => set.relayUrls.map(url => normalizeUrl(url) || url)), ...relaySets.flatMap(set => set.relayUrls.map(url => normalizeAnyRelayUrl(url) || url)),
...FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url), ...FAST_READ_RELAY_URLS.map(url => normalizeAnyRelayUrl(url) || url),
...FAST_WRITE_RELAY_URLS.map(url => normalizeUrl(url) || url) ...FAST_WRITE_RELAY_URLS.map(url => normalizeAnyRelayUrl(url) || url)
].filter(Boolean) as string[] ].filter(Boolean) as string[]
return Array.from(new Set(urls)) return Array.from(new Set(urls))
}, [currentBrowsingRelayUrls, favoriteRelays, relaySets]) }, [currentBrowsingRelayUrls, favoriteRelays, relaySets])
@ -163,7 +168,7 @@ export function useMenuActions({
...FAST_WRITE_RELAY_URLS ...FAST_WRITE_RELAY_URLS
] ]
const comprehensiveRelays = Array.from( const comprehensiveRelays = Array.from(
new Set(allRelays.map(url => normalizeUrl(url)).filter((url): url is string => !!url)) new Set(allRelays.map(url => normalizeAnyRelayUrl(url)).filter((url): url is string => !!url))
) )
const pinListEvent = await fetchNewestPinListForPubkey(pubkey, comprehensiveRelays) const pinListEvent = await fetchNewestPinListForPubkey(pubkey, comprehensiveRelays)
if (pinListEvent) { if (pinListEvent) {
@ -196,7 +201,7 @@ export function useMenuActions({
] ]
const normalizedRelays = allRelays const normalizedRelays = allRelays
.map(url => normalizeUrl(url)) .map(url => normalizeAnyRelayUrl(url))
.filter((url): url is string => !!url) .filter((url): url is string => !!url)
const comprehensiveRelays = Array.from(new Set(normalizedRelays)) const comprehensiveRelays = Array.from(new Set(normalizedRelays))
@ -386,9 +391,10 @@ export function useMenuActions({
) )
} }
if (relayUrls.length) { const wsAndHttpRelayUrls = Array.from(new Set([...relayUrls, ...httpWriteRelayUrls]))
if (wsAndHttpRelayUrls.length) {
items.push( items.push(
...relayUrls.map((relay, index) => ({ ...wsAndHttpRelayUrls.map((relay, index) => ({
label: ( label: (
<div className="flex items-center gap-2 w-full"> <div className="flex items-center gap-2 w-full">
<RelayIcon url={relay} /> <RelayIcon url={relay} />
@ -418,7 +424,7 @@ export function useMenuActions({
} }
return items return items
}, [pubkey, relayUrls, relaySets, allAvailableRelayUrls, monitoringListRelayCount, event, closeDrawer, t]) }, [pubkey, relayUrls, httpWriteRelayUrls, relaySets, allAvailableRelayUrls, monitoringListRelayCount, event, closeDrawer, t])
// Check if this is an article-type event // Check if this is an article-type event
const isArticleType = useMemo(() => { const isArticleType = useMemo(() => {

2
src/components/PostEditor/PostContent.tsx

@ -89,7 +89,7 @@ import {
import { prefixNostrAddresses } from '@/lib/nostr-address' import { prefixNostrAddresses } from '@/lib/nostr-address'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { TDraftEvent } from '@/types' import { TDraftEvent } from '@/types'
import { useGroupList } from '@/providers/GroupListProvider' import { useGroupList } from '@/providers/group-list-context'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { DISCUSSION_TOPICS } from '@/pages/primary/DiscussionsPage/discussionTopics' import { DISCUSSION_TOPICS } from '@/pages/primary/DiscussionsPage/discussionTopics'

2
src/components/Profile/Followings.tsx

@ -1,7 +1,7 @@
import { useFetchFollowings } from '@/hooks' import { useFetchFollowings } from '@/hooks'
import { toFollowingList } from '@/lib/link' import { toFollowingList } from '@/lib/link'
import { SecondaryPageLink } from '@/PageManager' import { SecondaryPageLink } from '@/PageManager'
import { useFollowList } from '@/providers/FollowListProvider' import { useFollowList } from '@/providers/follow-list-context'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'

2
src/components/Profile/SmartFollowings.tsx

@ -1,7 +1,7 @@
import { useFetchFollowings } from '@/hooks' import { useFetchFollowings } from '@/hooks'
import { toFollowingList } from '@/lib/link' import { toFollowingList } from '@/lib/link'
import { useSmartFollowingListNavigation } from '@/PageManager' import { useSmartFollowingListNavigation } from '@/PageManager'
import { useFollowListOptional } from '@/providers/FollowListProvider' import { useFollowListOptional } from '@/providers/follow-list-context'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'

12
src/components/Profile/index.tsx

@ -66,7 +66,7 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import { nip66Service } from '@/services/nip66.service' import { nip66Service } from '@/services/nip66.service'
import { normalizeUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import type { TProfile } from '@/types' import type { TProfile } from '@/types'
/** /**
@ -292,11 +292,11 @@ export default function Profile({
/** All available relays: current feed, favorites, relay sets, defaults (FAST_READ, FAST_WRITE). */ /** All available relays: current feed, favorites, relay sets, defaults (FAST_READ, FAST_WRITE). */
const allAvailableRelayUrls = useMemo(() => { const allAvailableRelayUrls = useMemo(() => {
const urls = [ const urls = [
...currentBrowsingRelayUrls.map(url => normalizeUrl(url) || url), ...currentBrowsingRelayUrls.map(url => normalizeAnyRelayUrl(url) || url),
...favoriteRelays.map(url => normalizeUrl(url) || url), ...favoriteRelays.map(url => normalizeAnyRelayUrl(url) || url),
...relaySets.flatMap(set => set.relayUrls.map(url => normalizeUrl(url) || url)), ...relaySets.flatMap(set => set.relayUrls.map(url => normalizeAnyRelayUrl(url) || url)),
...FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url), ...FAST_READ_RELAY_URLS.map(url => normalizeAnyRelayUrl(url) || url),
...FAST_WRITE_RELAY_URLS.map(url => normalizeUrl(url) || url) ...FAST_WRITE_RELAY_URLS.map(url => normalizeAnyRelayUrl(url) || url)
].filter(Boolean) as string[] ].filter(Boolean) as string[]
return Array.from(new Set(urls)) return Array.from(new Set(urls))
}, [currentBrowsingRelayUrls, favoriteRelays, relaySets]) }, [currentBrowsingRelayUrls, favoriteRelays, relaySets])

12
src/components/ProfileOptions/index.tsx

@ -8,7 +8,7 @@ import {
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { buildHiveTalkJoinUrl, roomIdForPubkeys } from '@/lib/hivetalk' import { buildHiveTalkJoinUrl, roomIdForPubkeys } from '@/lib/hivetalk'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { normalizeUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
@ -82,11 +82,11 @@ export default function ProfileOptions({
/** All available relays: current feed, favorites, relay sets, defaults (FAST_READ, FAST_WRITE). */ /** All available relays: current feed, favorites, relay sets, defaults (FAST_READ, FAST_WRITE). */
const allAvailableRelayUrls = useMemo(() => { const allAvailableRelayUrls = useMemo(() => {
const urls = [ const urls = [
...currentBrowsingRelayUrls.map(url => normalizeUrl(url) || url), ...currentBrowsingRelayUrls.map(url => normalizeAnyRelayUrl(url) || url),
...favoriteRelays.map(url => normalizeUrl(url) || url), ...favoriteRelays.map(url => normalizeAnyRelayUrl(url) || url),
...relaySets.flatMap(set => set.relayUrls.map(url => normalizeUrl(url) || url)), ...relaySets.flatMap(set => set.relayUrls.map(url => normalizeAnyRelayUrl(url) || url)),
...FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url), ...FAST_READ_RELAY_URLS.map(url => normalizeAnyRelayUrl(url) || url),
...FAST_WRITE_RELAY_URLS.map(url => normalizeUrl(url) || url) ...FAST_WRITE_RELAY_URLS.map(url => normalizeAnyRelayUrl(url) || url)
].filter(Boolean) as string[] ].filter(Boolean) as string[]
return Array.from(new Set(urls)) return Array.from(new Set(urls))
}, [currentBrowsingRelayUrls, favoriteRelays, relaySets]) }, [currentBrowsingRelayUrls, favoriteRelays, relaySets])

11
src/components/Relay/index.tsx

@ -5,7 +5,7 @@ import SearchInput from '@/components/SearchInput'
import { useFetchRelayInfo } from '@/hooks' import { useFetchRelayInfo } from '@/hooks'
import type { TPrimaryPageName } from '@/PageManager' import type { TPrimaryPageName } from '@/PageManager'
import { SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants' import { SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants'
import { normalizeUrl } from '@/lib/url' import { isHttpRelayUrl, normalizeAnyRelayUrl } from '@/lib/url'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import client, { JUMBLE_SESSION_RELAY_STRIKES_CHANGED } from '@/services/client.service' import client, { JUMBLE_SESSION_RELAY_STRIKES_CHANGED } from '@/services/client.service'
import type { TFeedSubRequest } from '@/types' import type { TFeedSubRequest } from '@/types'
@ -19,7 +19,8 @@ const Relay = forwardRef<
>(function Relay({ url, className, hostPrimaryPageName }, ref) { >(function Relay({ url, className, hostPrimaryPageName }, ref) {
const { t } = useTranslation() const { t } = useTranslation()
const { addRelayUrls, removeRelayUrls } = useCurrentRelays() const { addRelayUrls, removeRelayUrls } = useCurrentRelays()
const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url]) const normalizedUrl = useMemo(() => (url ? normalizeAnyRelayUrl(url) : undefined), [url])
const isHttpRelay = useMemo(() => !!normalizedUrl && isHttpRelayUrl(normalizedUrl), [normalizedUrl])
const { relayInfo } = useFetchRelayInfo(normalizedUrl) const { relayInfo } = useFetchRelayInfo(normalizedUrl)
const [searchInput, setSearchInput] = useState('') const [searchInput, setSearchInput] = useState('')
const [debouncedInput, setDebouncedInput] = useState(searchInput) const [debouncedInput, setDebouncedInput] = useState(searchInput)
@ -82,7 +83,7 @@ const Relay = forwardRef<
const handleRelayRefresh = (event: CustomEvent) => { const handleRelayRefresh = (event: CustomEvent) => {
const { relayUrl } = event.detail const { relayUrl } = event.detail
if (normalizeUrl(relayUrl) === normalizedUrl) { if (normalizeAnyRelayUrl(relayUrl) === normalizedUrl) {
if (noteListRef && typeof noteListRef !== 'function') { if (noteListRef && typeof noteListRef !== 'function') {
noteListRef.current?.refresh() noteListRef.current?.refresh()
} }
@ -97,7 +98,7 @@ const Relay = forwardRef<
}, [normalizedUrl, noteListRef]) }, [normalizedUrl, noteListRef])
const relayFeedSubRequests = useMemo<TFeedSubRequest[]>(() => { const relayFeedSubRequests = useMemo<TFeedSubRequest[]>(() => {
if (!normalizedUrl) return [] if (!normalizedUrl || isHttpRelay) return []
const q = debouncedInput.trim() const q = debouncedInput.trim()
return [ return [
{ {
@ -107,7 +108,7 @@ const Relay = forwardRef<
: { limit: SINGLE_RELAY_KINDLESS_REQ_LIMIT } : { limit: SINGLE_RELAY_KINDLESS_REQ_LIMIT }
} }
] ]
}, [normalizedUrl, debouncedInput]) }, [normalizedUrl, isHttpRelay, debouncedInput])
if (!normalizedUrl) { if (!normalizedUrl) {
return <NotFound /> return <NotFound />

4
src/components/ReplyNoteList/index.tsx

@ -19,7 +19,7 @@ import {
} from '@/lib/event' } from '@/lib/event'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { getZapInfoFromEvent, shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata' import { getZapInfoFromEvent, shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata'
import { normalizeUrl } from '@/lib/url' import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter' import { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter'
import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
@ -768,7 +768,7 @@ function ReplyNoteList({
// READ from: FAST_READ_RELAY_URLS + user's inboxes + local relays + OP author's outboxes // READ from: FAST_READ_RELAY_URLS + user's inboxes + local relays + OP author's outboxes
const opAuthorPubkey = rootInfo.type === 'E' || rootInfo.type === 'A' ? rootInfo.pubkey : undefined const opAuthorPubkey = rootInfo.type === 'E' || rootInfo.type === 'A' ? rootInfo.pubkey : undefined
const seenOn = client.getSeenEventRelayUrls(event.id).map((u) => normalizeUrl(u) || u).filter(Boolean) const seenOn = client.getSeenEventRelayUrls(event.id).map((u) => normalizeUrl(u) || u).filter(Boolean)
const fromBrowsingFeed = browsingRelayUrls.map((u) => normalizeUrl(u) || u).filter(Boolean) const fromBrowsingFeed = browsingRelayUrls.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean)
const threadRelayHints = [ const threadRelayHints = [
...new Set([...relayHintsFromEventTags(event), ...seenOn, ...fromBrowsingFeed]) ...new Set([...relayHintsFromEventTags(event), ...seenOn, ...fromBrowsingFeed])
] ]

8
src/components/SaveRelayDropdownMenu/index.tsx

@ -26,7 +26,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { normalizeUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
@ -47,7 +47,7 @@ export default function SaveRelayDropdownMenu({
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { favoriteRelays, relaySets } = useFavoriteRelays() const { favoriteRelays, relaySets } = useFavoriteRelays()
const normalizedUrls = useMemo(() => urls.map((url) => normalizeUrl(url)).filter(Boolean), [urls]) const normalizedUrls = useMemo(() => urls.map((url) => normalizeAnyRelayUrl(url)).filter(Boolean), [urls])
const alreadySaved = useMemo(() => { const alreadySaved = useMemo(() => {
return ( return (
normalizedUrls.every((url) => favoriteRelays.includes(url)) || normalizedUrls.every((url) => favoriteRelays.includes(url)) ||
@ -188,8 +188,8 @@ function RelaySetItem({ set, urls }: { set: TRelaySet; urls: string[] }) {
updateRelaySet({ updateRelaySet({
...set, ...set,
relayUrls: Array.from(new Set([ relayUrls: Array.from(new Set([
...set.relayUrls.map(url => normalizeUrl(url) || url), ...set.relayUrls.map(url => normalizeAnyRelayUrl(url) || url),
...urls.map(url => normalizeUrl(url) || url) ...urls.map(url => normalizeAnyRelayUrl(url) || url)
])) ]))
}) })
} }

6
src/components/SearchBar/index.tsx

@ -4,7 +4,7 @@ import { toNote, toNoteList } from '@/lib/link'
import client from '@/services/client.service' import client from '@/services/client.service'
import { eventService } from '@/services/client.service' import { eventService } from '@/services/client.service'
import { randomString } from '@/lib/random' import { randomString } from '@/lib/random'
import { normalizeUrl } from '@/lib/url' import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl } from '@/lib/url'
import { normalizeToDTag } from '@/lib/search-parser' import { normalizeToDTag } from '@/lib/search-parser'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSmartNoteNavigation, useSmartHashtagNavigation } from '@/PageManager' import { useSmartNoteNavigation, useSmartHashtagNavigation } from '@/PageManager'
@ -53,7 +53,9 @@ const SearchBar = forwardRef<
return undefined return undefined
} }
try { try {
return normalizeUrl(input) const n = normalizeAnyRelayUrl(input)
if (!n || (!isHttpRelayUrl(n) && !isWebsocketUrl(n))) return undefined
return n
} catch { } catch {
return undefined return undefined
} }

2
src/components/TopicSubscribeButton/index.tsx

@ -1,6 +1,6 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useInterestList } from '@/providers/InterestListProvider' import { useInterestList } from '@/providers/interest-list-context'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Bell, BellOff } from 'lucide-react' import { Bell, BellOff } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'

4
src/hooks/useQuoteEvents.tsx

@ -6,7 +6,7 @@ import {
} from '@/constants' } from '@/constants'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { buildNormalizedBlockedRelaySet } from '@/lib/thread-response-filter' import { buildNormalizedBlockedRelaySet } from '@/lib/thread-response-filter'
import { normalizeUrl } from '@/lib/url' import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
@ -69,7 +69,7 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) {
}, INITIAL_QUOTE_LOAD_TIMEOUT_MS) }, INITIAL_QUOTE_LOAD_TIMEOUT_MS)
const userRelays = userRelayList?.read || [] const userRelays = userRelayList?.read || []
const fromFeed = browsingRelayUrls.map((u) => normalizeUrl(u) || u).filter(Boolean) const fromFeed = browsingRelayUrls.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean)
const seenOn = client.getSeenEventRelayUrls(ev.id) const seenOn = client.getSeenEventRelayUrls(ev.id)
const eTagBlockedSet = new Set( const eTagBlockedSet = new Set(
E_TAG_FILTER_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u) E_TAG_FILTER_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)

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

@ -16,15 +16,15 @@ export async function buildAccountListRelayUrlsForMerge(options: {
const myRelayList = await client.fetchRelayList(accountPubkey) const myRelayList = await client.fetchRelayList(accountPubkey)
const favoritesTier = getFavoritesFeedRelayUrls(favoriteRelays ?? [], blockedRelays) const favoritesTier = getFavoritesFeedRelayUrls(favoriteRelays ?? [], blockedRelays)
const read = buildPrioritizedReadRelayUrls({ const read = buildPrioritizedReadRelayUrls({
userReadRelays: [...(myRelayList.httpRead ?? []), ...(myRelayList.read ?? [])], userReadRelays: myRelayList.read ?? [],
userWriteRelays: [...(myRelayList.httpWrite ?? []), ...(myRelayList.write ?? [])], userWriteRelays: myRelayList.write ?? [],
favoriteRelays: favoritesTier, favoriteRelays: favoritesTier,
blockedRelays, blockedRelays,
maxRelays: 100, maxRelays: 100,
applySocialKindBlockedFilter: false applySocialKindBlockedFilter: false
}) })
const write = buildPrioritizedWriteRelayUrls({ const write = buildPrioritizedWriteRelayUrls({
userWriteRelays: [...(myRelayList.httpWrite ?? []), ...(myRelayList.write ?? [])], userWriteRelays: myRelayList.write ?? [],
favoriteRelays: favoritesTier, favoriteRelays: favoritesTier,
blockedRelays, blockedRelays,
maxRelays: 100, maxRelays: 100,

26
src/lib/index-relay-http.ts

@ -8,7 +8,7 @@
*/ */
import { fetchWithTimeout } from '@/lib/fetch-with-timeout' import { fetchWithTimeout } from '@/lib/fetch-with-timeout'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { normalizeHttpRelayUrl } from '@/lib/url' import { devProxyLoopbackHttpRelayBase, normalizeHttpRelayUrl } from '@/lib/url'
import type { Filter, Event as NEvent } from 'nostr-tools' import type { Filter, Event as NEvent } from 'nostr-tools'
import { verifyEvent } from 'nostr-tools' import { verifyEvent } from 'nostr-tools'
@ -16,24 +16,6 @@ function trimSlash(base: string): string {
return base.replace(/\/+$/, '') return base.replace(/\/+$/, '')
} }
/**
* Avoid browser CORS in dev: `http://localhost:1122/api/...` becomes same-origin `…/dev-index-relay/api/…`
* and Vite forwards to the real relay (see `vite.config.ts`).
*/
function devProxyLoopbackIndexRelayBase(normalizedBase: string): string {
if (import.meta.env.PROD || typeof window === 'undefined') return normalizedBase
let u: URL
try {
u = new URL(normalizedBase)
} catch {
return normalizedBase
}
if (u.protocol !== 'http:') return normalizedBase
const h = u.hostname
if (h !== 'localhost' && h !== '127.0.0.1') return normalizedBase
return `${window.location.origin}/dev-index-relay`
}
export function indexRelayFilterUrl(baseUrl: string): string { export function indexRelayFilterUrl(baseUrl: string): string {
return `${trimSlash(normalizeHttpRelayUrl(baseUrl) || baseUrl)}/api/events/filter` return `${trimSlash(normalizeHttpRelayUrl(baseUrl) || baseUrl)}/api/events/filter`
} }
@ -162,7 +144,7 @@ export async function queryIndexRelay(
filter: Filter | Filter[], filter: Filter | Filter[],
options?: { signal?: AbortSignal; onHardFailure?: () => void } options?: { signal?: AbortSignal; onHardFailure?: () => void }
): Promise<NEvent[]> { ): Promise<NEvent[]> {
const base = devProxyLoopbackIndexRelayBase(normalizeHttpRelayUrl(baseUrl) || baseUrl) const base = devProxyLoopbackHttpRelayBase(normalizeHttpRelayUrl(baseUrl) || baseUrl)
const endpoint = indexRelayFilterUrl(base) const endpoint = indexRelayFilterUrl(base)
const filters = Array.isArray(filter) ? filter : [filter] const filters = Array.isArray(filter) ? filter : [filter]
const out: NEvent[] = [] const out: NEvent[] = []
@ -225,12 +207,12 @@ function filterForIndexRelay(f: Filter): Filter {
return rest as Filter return rest as Filter
} }
export async function publishEventToIndexRelay( export async function publishEventToHttpRelay(
baseUrl: string, baseUrl: string,
event: NEvent, event: NEvent,
options?: { signal?: AbortSignal } options?: { signal?: AbortSignal }
): Promise<void> { ): Promise<void> {
const base = devProxyLoopbackIndexRelayBase(normalizeHttpRelayUrl(baseUrl) || baseUrl) const base = devProxyLoopbackHttpRelayBase(normalizeHttpRelayUrl(baseUrl) || baseUrl)
const endpoint = indexRelayPublishUrl(base) const endpoint = indexRelayPublishUrl(base)
try { try {
const res = await fetchWithTimeout(endpoint, { const res = await fetchWithTimeout(endpoint, {

25
src/lib/replaceable-list-latest.ts

@ -1,7 +1,7 @@
import { METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS } from '@/constants' import { METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS } from '@/constants'
import { normalizeHexPubkey } from '@/lib/pubkey' import { normalizeHexPubkey } from '@/lib/pubkey'
import { normalizeUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import client, { queryService } from '@/services/client.service' import client from '@/services/client.service'
import type { TPersonalListBech32Ref } from '@/lib/personal-list-mutations' import type { TPersonalListBech32Ref } from '@/lib/personal-list-mutations'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
@ -15,17 +15,16 @@ export async function fetchLatestReplaceableListEvent(
relayUrls: string[] relayUrls: string[]
): Promise<Event | undefined> { ): Promise<Event | undefined> {
const pk = normalizeHexPubkey(pubkeyHex) const pk = normalizeHexPubkey(pubkeyHex)
const urls = [...new Set(relayUrls.map((u) => normalizeUrl(u) || u).filter(Boolean))] const allUrls = [...new Set(relayUrls.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean))]
if (!urls.length) return undefined if (!allUrls.length) return undefined
const rows = await queryService.fetchEvents(
urls, // client.fetchEvents() handles both HTTP index relays and WebSocket relays internally.
{ authors: [pk], kinds: [kind], limit: 80 }, const rows = await client.fetchEvents(allUrls, { authors: [pk], kinds: [kind], limit: 80 }, {
{ replaceableRace: true,
replaceableRace: true, eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS,
eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS
globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS })
}
)
if (!rows.length) return undefined if (!rows.length) return undefined
return rows.reduce((best, e) => (e.created_at > best.created_at ? e : best)) return rows.reduce((best, e) => (e.created_at > best.created_at ? e : best))
} }

18
src/lib/url.ts

@ -30,6 +30,24 @@ export function normalizeHttpRelayUrl(url: string): string {
return normalizeHttpUrl(url) return normalizeHttpUrl(url)
} }
/**
* In dev, loopback HTTP relay bases (`http://localhost:*` / `http://127.0.0.1:*`) use the Vite
* same-origin `/dev-index-relay` proxy (see `vite.config.ts`) so JSON APIs and NIP-11 avoid CORS.
*/
export function devProxyLoopbackHttpRelayBase(normalizedBase: string): string {
if (import.meta.env.PROD || typeof window === 'undefined') return normalizedBase
let u: URL
try {
u = new URL(normalizedBase)
} catch {
return normalizedBase
}
if (u.protocol !== 'http:') return normalizedBase
const h = u.hostname
if (h !== 'localhost' && h !== '127.0.0.1') return normalizedBase
return `${window.location.origin}/dev-index-relay`
}
/** /**
* Normalize relay URL for deduplication: WebSocket URLs via {@link normalizeUrl}, HTTPS index relays via {@link normalizeHttpRelayUrl}. * Normalize relay URL for deduplication: WebSocket URLs via {@link normalizeUrl}, HTTPS index relays via {@link normalizeHttpRelayUrl}.
*/ */

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

@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { toRelay } from '@/lib/link' import { toRelay } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { isWebsocketUrl, normalizeUrl, simplifyUrl } from '@/lib/url' import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl, simplifyUrl } from '@/lib/url'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions'
@ -35,7 +35,7 @@ function dedupeNormalizedRelayUrls(urls: string[]): string[] {
const seen = new Set<string>() const seen = new Set<string>()
const out: string[] = [] const out: string[] = []
for (const u of urls) { for (const u of urls) {
const k = normalizeUrl(u) || u const k = normalizeAnyRelayUrl(u) || u.trim()
if (!k || seen.has(k)) continue if (!k || seen.has(k)) continue
seen.add(k) seen.add(k)
out.push(k) out.push(k)
@ -232,8 +232,8 @@ function ExploreRelaySearchSection() {
const tryOpenRelay = () => { const tryOpenRelay = () => {
const trimmed = relayQuery.trim() const trimmed = relayQuery.trim()
if (!trimmed) return if (!trimmed) return
const normalized = normalizeUrl(trimmed) const normalized = normalizeAnyRelayUrl(trimmed)
if (!normalized || !isWebsocketUrl(normalized)) { if (!normalized || (!isHttpRelayUrl(normalized) && !isWebsocketUrl(normalized))) {
toast.error(t('invalid relay URL')) toast.error(t('invalid relay URL'))
return return
} }

4
src/pages/primary/RelayPage/index.tsx

@ -3,13 +3,13 @@ import { RefreshButton } from '@/components/RefreshButton'
import Relay from '@/components/Relay' import Relay from '@/components/Relay'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { TPageRef } from '@/types' import { TPageRef } from '@/types'
import { normalizeUrl, simplifyUrl } from '@/lib/url' import { normalizeAnyRelayUrl, simplifyUrl } from '@/lib/url'
import client from '@/services/client.service' import client from '@/services/client.service'
import { Server } from 'lucide-react' import { Server } from 'lucide-react'
import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from 'react' import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from 'react'
const RelayPage = forwardRef<TPageRef, { url?: string }>(({ url }, ref) => { const RelayPage = forwardRef<TPageRef, { url?: string }>(({ url }, ref) => {
const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url]) const normalizedUrl = useMemo(() => (url ? normalizeAnyRelayUrl(url) : undefined), [url])
const layoutRef = useRef<TPageRef>(null) const layoutRef = useRef<TPageRef>(null)
const feedRef = useRef<TNoteListRef>(null) const feedRef = useRef<TNoteListRef>(null)

2
src/pages/secondary/InterestListPage/index.tsx

@ -27,7 +27,7 @@ import { toNoteList } from '@/lib/link'
import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest' import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSmartHashtagNavigation } from '@/PageManager' import { useSmartHashtagNavigation } from '@/PageManager'
import { useInterestList } from '@/providers/InterestListProvider' import { useInterestList } from '@/providers/interest-list-context'
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'

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

@ -17,7 +17,7 @@ import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useInterestListOptional } from '@/providers/InterestListProvider' import { useInterestListOptional } from '@/providers/interest-list-context'
import client from '@/services/client.service' import client from '@/services/client.service'
import { TFeedSubRequest } from '@/types' import { TFeedSubRequest } from '@/types'
import { UserRound, Plus } from 'lucide-react' import { UserRound, Plus } from 'lucide-react'

4
src/pages/secondary/RelayPage/index.tsx

@ -3,7 +3,7 @@ import Relay from '@/components/Relay'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
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 { normalizeUrl, simplifyUrl } from '@/lib/url' import { normalizeAnyRelayUrl, simplifyUrl } from '@/lib/url'
import client from '@/services/client.service' import client from '@/services/client.service'
import { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react'
import NotFoundPage from '../NotFoundPage' import NotFoundPage from '../NotFoundPage'
@ -11,7 +11,7 @@ import NotFoundPage from '../NotFoundPage'
const RelayPage = forwardRef(({ url, index, hideTitlebar = false }: { url?: string; index?: number; hideTitlebar?: boolean }, ref) => { const RelayPage = forwardRef(({ url, index, hideTitlebar = false }: { url?: string; index?: number; hideTitlebar?: boolean }, ref) => {
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const feedRef = useRef<TNoteListRef>(null) const feedRef = useRef<TNoteListRef>(null)
const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url]) const normalizedUrl = useMemo(() => (url ? normalizeAnyRelayUrl(url) : undefined), [url])
const title = useMemo(() => (url ? simplifyUrl(url) : undefined), [url]) const title = useMemo(() => (url ? simplifyUrl(url) : undefined), [url])
const bumpFeed = useCallback(() => { const bumpFeed = useCallback(() => {

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

@ -5,7 +5,7 @@ import { FAST_READ_RELAY_URLS, 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 { relayReviewDTagsForRelayUrl, relayReviewsFeedSnapshotKey } from '@/lib/relay-review-feed' import { relayReviewDTagsForRelayUrl, relayReviewsFeedSnapshotKey } from '@/lib/relay-review-feed'
import { normalizeUrl, simplifyUrl } from '@/lib/url' import { normalizeAnyRelayUrl, simplifyUrl } from '@/lib/url'
import type { TFeedSubRequest } from '@/types' import type { TFeedSubRequest } from '@/types'
import { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -26,7 +26,7 @@ const RelayReviewsPage = forwardRef(({ url, index, hideTitlebar = false }: { url
return () => registerPrimaryPanelRefresh(null) return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, bumpFeed]) }, [hideTitlebar, registerPrimaryPanelRefresh, bumpFeed])
const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url]) const normalizedUrl = useMemo(() => (url ? normalizeAnyRelayUrl(url) : undefined), [url])
/** `d` tag values vary by client (raw vs normalized URL); REQ must OR-match every variant. */ /** `d` tag values vary by client (raw vs normalized URL); REQ must OR-match every variant. */
const relayReviewDTags = useMemo( const relayReviewDTags = useMemo(
() => (url ? relayReviewDTagsForRelayUrl(url) : []), () => (url ? relayReviewDTagsForRelayUrl(url) : []),

20
src/providers/FavoriteRelaysProvider.tsx

@ -3,7 +3,7 @@ import { createFavoriteRelaysDraftEvent, createBlockedRelaysDraftEvent, createRe
import { getReplaceableEventIdentifier } from '@/lib/event' import { getReplaceableEventIdentifier } from '@/lib/event'
import { getRelaySetFromEvent } from '@/lib/event-metadata' import { getRelaySetFromEvent } from '@/lib/event-metadata'
import { randomString } from '@/lib/random' import { randomString } from '@/lib/random'
import { isWebsocketUrl, normalizeUrl } from '@/lib/url' import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { queryService } from '@/services/client.service' import { queryService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
@ -54,7 +54,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
if (!tagValue) return if (!tagValue) return
if (tagName === 'relay') { if (tagName === 'relay') {
const normalizedUrl = normalizeUrl(tagValue) const normalizedUrl = normalizeAnyRelayUrl(tagValue)
if (normalizedUrl && !relays.includes(normalizedUrl)) { if (normalizedUrl && !relays.includes(normalizedUrl)) {
relays.push(normalizedUrl) relays.push(normalizedUrl)
} }
@ -84,7 +84,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
setRelaySetEvents(storedRelaySetEvents.filter(Boolean) as Event[]) setRelaySetEvents(storedRelaySetEvents.filter(Boolean) as Event[])
const normalizedRelays = [ const normalizedRelays = [
...(relayList?.write ?? []).map(url => normalizeUrl(url) || url), ...(relayList?.write ?? []).map(url => normalizeAnyRelayUrl(url) || url),
...FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url) ...FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url)
] ]
const newRelaySetEvents = await queryService.fetchEvents( const newRelaySetEvents = await queryService.fetchEvents(
@ -133,7 +133,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
const relays: string[] = [] const relays: string[] = []
blockedRelaysEvent.tags.forEach(([tagName, tagValue]) => { blockedRelaysEvent.tags.forEach(([tagName, tagValue]) => {
if (tagName === 'relay' && tagValue) { if (tagName === 'relay' && tagValue) {
const normalizedUrl = normalizeUrl(tagValue) const normalizedUrl = normalizeAnyRelayUrl(tagValue)
if (normalizedUrl && !relays.includes(normalizedUrl)) { if (normalizedUrl && !relays.includes(normalizedUrl)) {
relays.push(normalizedUrl) relays.push(normalizedUrl)
} }
@ -151,7 +151,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
const addFavoriteRelays = useCallback( const addFavoriteRelays = useCallback(
async (relayUrls: string[]) => { async (relayUrls: string[]) => {
const normalizedUrls = relayUrls const normalizedUrls = relayUrls
.map((relayUrl) => normalizeUrl(relayUrl)) .map((relayUrl) => normalizeAnyRelayUrl(relayUrl))
.filter((url) => !!url && !favoriteRelays.includes(url)) .filter((url) => !!url && !favoriteRelays.includes(url))
if (!normalizedUrls.length) return if (!normalizedUrls.length) return
@ -168,7 +168,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
const deleteFavoriteRelays = useCallback( const deleteFavoriteRelays = useCallback(
async (relayUrls: string[]) => { async (relayUrls: string[]) => {
const normalizedUrls = relayUrls const normalizedUrls = relayUrls
.map((relayUrl) => normalizeUrl(relayUrl)) .map((relayUrl) => normalizeAnyRelayUrl(relayUrl))
.filter((url) => !!url && favoriteRelays.includes(url)) .filter((url) => !!url && favoriteRelays.includes(url))
if (!normalizedUrls.length) return if (!normalizedUrls.length) return
@ -185,8 +185,8 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
const createRelaySet = useCallback( const createRelaySet = useCallback(
async (relaySetName: string, relayUrls: string[] = []) => { async (relaySetName: string, relayUrls: string[] = []) => {
const normalizedUrls = relayUrls const normalizedUrls = relayUrls
.map((url) => normalizeUrl(url)) .map((url) => normalizeAnyRelayUrl(url))
.filter((url) => isWebsocketUrl(url)) .filter((url) => isWebsocketUrl(url) || isHttpRelayUrl(url))
const id = randomString() const id = randomString()
const relaySetDraftEvent = createRelaySetDraftEvent({ const relaySetDraftEvent = createRelaySetDraftEvent({
id, id,
@ -271,7 +271,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
const addBlockedRelays = useCallback( const addBlockedRelays = useCallback(
async (relayUrls: string[]) => { async (relayUrls: string[]) => {
const normalizedUrls = relayUrls const normalizedUrls = relayUrls
.map((relayUrl) => normalizeUrl(relayUrl)) .map((relayUrl) => normalizeAnyRelayUrl(relayUrl))
.filter((url) => !!url && !blockedRelays.includes(url)) .filter((url) => !!url && !blockedRelays.includes(url))
if (!normalizedUrls.length) return if (!normalizedUrls.length) return
const newBlockedRelays = [...blockedRelays, ...normalizedUrls] const newBlockedRelays = [...blockedRelays, ...normalizedUrls]
@ -285,7 +285,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
const deleteBlockedRelays = useCallback( const deleteBlockedRelays = useCallback(
async (relayUrls: string[]) => { async (relayUrls: string[]) => {
const normalizedUrls = relayUrls.map((relayUrl) => normalizeUrl(relayUrl)).filter(Boolean) const normalizedUrls = relayUrls.map((relayUrl) => normalizeAnyRelayUrl(relayUrl)).filter(Boolean)
const newBlockedRelays = blockedRelays.filter((relay) => !normalizedUrls.includes(relay)) const newBlockedRelays = blockedRelays.filter((relay) => !normalizedUrls.includes(relay))
setBlockedRelays(newBlockedRelays) setBlockedRelays(newBlockedRelays)
const draftEvent = createBlockedRelaysDraftEvent(newBlockedRelays) const draftEvent = createBlockedRelaysDraftEvent(newBlockedRelays)

12
src/providers/FeedProvider.tsx

@ -2,7 +2,7 @@ import { DEFAULT_FAVORITE_RELAYS } from '@/constants'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { getRelaySetFromEvent } from '@/lib/event-metadata' import { getRelaySetFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { isWebsocketUrl, normalizeUrl } from '@/lib/url' import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl } from '@/lib/url'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { TFeedInfo, TFeedType } from '@/types' import { TFeedInfo, TFeedType } from '@/types'
@ -38,10 +38,12 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
logger.debug('switchFeed called:', { feedType, options }) logger.debug('switchFeed called:', { feedType, options })
setIsReady(false) setIsReady(false)
if (feedType === 'relay') { if (feedType === 'relay') {
const normalizedUrl = normalizeUrl(options.relay ?? '') const normalizedUrl = normalizeAnyRelayUrl(options.relay ?? '')
logger.debug('Relay switchFeed:', { normalizedUrl, isWebsocketUrl: isWebsocketUrl(normalizedUrl), blockedRelays }) const isRelayFeedUrl =
!!normalizedUrl && (isHttpRelayUrl(normalizedUrl) || isWebsocketUrl(normalizedUrl))
if (!normalizedUrl || !isWebsocketUrl(normalizedUrl)) { logger.debug('Relay switchFeed:', { normalizedUrl, isRelayFeedUrl, blockedRelays })
if (!isRelayFeedUrl) {
logger.debug('Invalid relay URL, setting isReady to true') logger.debug('Invalid relay URL, setting isReady to true')
setIsReady(true) setIsReady(true)
return return

24
src/providers/FollowListProvider.tsx

@ -8,31 +8,11 @@ import {
import { getPubkeysFromPTags } from '@/lib/tag' import { getPubkeysFromPTags } from '@/lib/tag'
import client from '@/services/client.service' import client from '@/services/client.service'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
import { createContext, useContext, useMemo, useCallback } from 'react' import { useMemo, useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNostr } from './NostrProvider' import { useNostr } from './NostrProvider'
import { useFavoriteRelays } from './FavoriteRelaysProvider' import { useFavoriteRelays } from './FavoriteRelaysProvider'
import { FollowListContext } from './follow-list-context'
type TFollowListContext = {
followings: string[]
follow: (pubkey: string) => Promise<void>
unfollow: (pubkey: string) => Promise<void>
}
const FollowListContext = createContext<TFollowListContext | undefined>(undefined)
export const useFollowList = () => {
const context = useContext(FollowListContext)
if (!context) {
throw new Error('useFollowList must be used within a FollowListProvider')
}
return context
}
/** Same as {@link useFollowList} but returns undefined outside the provider (avoids HMR / refresh-boundary crashes). */
export function useFollowListOptional(): TFollowListContext | undefined {
return useContext(FollowListContext)
}
export function FollowListProvider({ children }: { children: React.ReactNode }) { export function FollowListProvider({ children }: { children: React.ReactNode }) {
const { t } = useTranslation() const { t } = useTranslation()

20
src/providers/GroupListProvider.tsx

@ -1,4 +1,4 @@
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react' import { useEffect, useState, useCallback, useMemo } from 'react'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
@ -7,23 +7,7 @@ import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest'
import { buildPrioritizedReadRelayUrls } from '@/lib/relay-url-priority' import { buildPrioritizedReadRelayUrls } from '@/lib/relay-url-priority'
import client from '@/services/client.service' import client from '@/services/client.service'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { GroupListContext } from './group-list-context'
interface GroupListContextType {
userGroups: string[]
isUserInGroup: (groupId: string) => boolean
refreshGroupList: () => Promise<void>
isLoading: boolean
}
const GroupListContext = createContext<GroupListContextType | undefined>(undefined)
export const useGroupList = () => {
const context = useContext(GroupListContext)
if (context === undefined) {
throw new Error('useGroupList must be used within a GroupListProvider')
}
return context
}
export function GroupListProvider({ children }: { children: React.ReactNode }) { export function GroupListProvider({ children }: { children: React.ReactNode }) {
const { pubkey: accountPubkey } = useNostr() const { pubkey: accountPubkey } = useNostr()

28
src/providers/InterestListProvider.tsx

@ -4,36 +4,12 @@ import { normalizeTopic } from '@/lib/discussion-topics'
import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest' import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import client from '@/services/client.service' import client from '@/services/client.service'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useNostr } from '@/providers/nostr-context' import { useNostr } from '@/providers/nostr-context'
import { useFavoriteRelays } from './FavoriteRelaysProvider' import { useFavoriteRelays } from './FavoriteRelaysProvider'
import { InterestListContext } from './interest-list-context'
type TInterestListContext = {
subscribedTopics: Set<string>
changing: boolean
isSubscribed: (topic: string) => boolean
subscribe: (topic: string) => Promise<void>
unsubscribe: (topic: string) => Promise<void>
getSubscribedTopics: () => string[]
}
const InterestListContext = createContext<TInterestListContext | undefined>(undefined)
export const useInterestList = () => {
const context = useContext(InterestListContext)
if (!context) {
throw new Error('useInterestList must be used within an InterestListProvider')
}
return context
}
/**
* Optional variant for routes/components that can be mounted
* during transient navigation/HMR paths before providers settle.
*/
export const useInterestListOptional = () => useContext(InterestListContext)
export function InterestListProvider({ children }: { children: React.ReactNode }) { export function InterestListProvider({ children }: { children: React.ReactNode }) {
const { t } = useTranslation() const { t } = useTranslation()

2
src/providers/LiveActivitiesProvider.tsx

@ -21,7 +21,7 @@ import {
useState useState
} from 'react' } from 'react'
import { useFavoriteRelays } from './FavoriteRelaysProvider' import { useFavoriteRelays } from './FavoriteRelaysProvider'
import { useFollowListOptional } from './FollowListProvider' import { useFollowListOptional } from './follow-list-context'
import { useNostr } from './NostrProvider' import { useNostr } from './NostrProvider'
import { useUserPreferencesOptional } from './UserPreferencesProvider' import { useUserPreferencesOptional } from './UserPreferencesProvider'

8
src/providers/NostrProvider/index.tsx

@ -24,7 +24,7 @@ import { getLatestEvent, minePow } from '@/lib/event'
import { getHttpRelayListFromEvent, getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import { getHttpRelayListFromEvent, getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { LoginRequiredError } from '@/lib/nostr-errors' import { LoginRequiredError } from '@/lib/nostr-errors'
import { normalizeHttpRelayUrl, normalizeUrl } from '@/lib/url' import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback' import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -70,7 +70,7 @@ function favoriteRelayUrlsForPublish(favoriteRelaysEvent: Event | null, pubkey:
const urls: string[] = [] const urls: string[] = []
favoriteRelaysEvent.tags.forEach(([name, v]) => { favoriteRelaysEvent.tags.forEach(([name, v]) => {
if (name === 'relay' && v) { if (name === 'relay' && v) {
const n = normalizeUrl(v) || v const n = normalizeAnyRelayUrl(v) || v
if (n && !urls.includes(n)) urls.push(n) if (n && !urls.includes(n)) urls.push(n)
} }
}) })
@ -82,7 +82,7 @@ function blockedRelayUrlsFromEvent(blockedRelaysEvent: Event | null): string[] {
if (!blockedRelaysEvent) return out if (!blockedRelaysEvent) return out
blockedRelaysEvent.tags.forEach(([tagName, tagValue]) => { blockedRelaysEvent.tags.forEach(([tagName, tagValue]) => {
if (tagName === 'relay' && tagValue) { if (tagName === 'relay' && tagValue) {
const n = normalizeUrl(tagValue) const n = normalizeAnyRelayUrl(tagValue)
if (n && !out.includes(n)) out.push(n) if (n && !out.includes(n)) out.push(n)
} }
}) })
@ -477,8 +477,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const normalizedRelays = [ const normalizedRelays = [
...mergedRelayList.write.map((url: string) => normalizeUrl(url) || url), ...mergedRelayList.write.map((url: string) => normalizeUrl(url) || url),
...mergedRelayList.read.map((url: string) => normalizeUrl(url) || url), ...mergedRelayList.read.map((url: string) => normalizeUrl(url) || url),
...mergedRelayList.httpRead.map((url: string) => normalizeHttpRelayUrl(url) || url),
...mergedRelayList.httpWrite.map((url: string) => normalizeHttpRelayUrl(url) || url),
...FAST_WRITE_RELAY_URLS.map((url: string) => normalizeUrl(url) || url), ...FAST_WRITE_RELAY_URLS.map((url: string) => normalizeUrl(url) || url),
...PROFILE_FETCH_RELAY_URLS.map((url: string) => normalizeUrl(url) || url) ...PROFILE_FETCH_RELAY_URLS.map((url: string) => normalizeUrl(url) || url)
] ]

22
src/providers/follow-list-context.tsx

@ -0,0 +1,22 @@
import { createContext, useContext } from 'react'
export type TFollowListContext = {
followings: string[]
follow: (pubkey: string) => Promise<void>
unfollow: (pubkey: string) => Promise<void>
}
export const FollowListContext = createContext<TFollowListContext | undefined>(undefined)
export const useFollowList = (): TFollowListContext => {
const context = useContext(FollowListContext)
if (!context) {
throw new Error('useFollowList must be used within a FollowListProvider')
}
return context
}
/** Same as {@link useFollowList} but returns undefined outside the provider (avoids HMR / refresh-boundary crashes). */
export function useFollowListOptional(): TFollowListContext | undefined {
return useContext(FollowListContext)
}

18
src/providers/group-list-context.tsx

@ -0,0 +1,18 @@
import { createContext, useContext } from 'react'
export interface GroupListContextType {
userGroups: string[]
isUserInGroup: (groupId: string) => boolean
refreshGroupList: () => Promise<void>
isLoading: boolean
}
export const GroupListContext = createContext<GroupListContextType | undefined>(undefined)
export const useGroupList = (): GroupListContextType => {
const context = useContext(GroupListContext)
if (context === undefined) {
throw new Error('useGroupList must be used within a GroupListProvider')
}
return context
}

27
src/providers/interest-list-context.tsx

@ -0,0 +1,27 @@
import { createContext, useContext } from 'react'
export type TInterestListContext = {
subscribedTopics: Set<string>
changing: boolean
isSubscribed: (topic: string) => boolean
subscribe: (topic: string) => Promise<void>
unsubscribe: (topic: string) => Promise<void>
getSubscribedTopics: () => string[]
}
export const InterestListContext = createContext<TInterestListContext | undefined>(undefined)
export const useInterestList = (): TInterestListContext => {
const context = useContext(InterestListContext)
if (!context) {
throw new Error('useInterestList must be used within an InterestListProvider')
}
return context
}
/**
* Optional variant for routes/components that can be mounted
* during transient navigation/HMR paths before providers settle.
*/
export const useInterestListOptional = (): TInterestListContext | undefined =>
useContext(InterestListContext)

132
src/services/client.service.ts

@ -106,7 +106,7 @@ import {
import { import {
IndexRelayTransportError, IndexRelayTransportError,
isIndexRelayTransportFailure, isIndexRelayTransportFailure,
publishEventToIndexRelay publishEventToHttpRelay
} from '@/lib/index-relay-http' } from '@/lib/index-relay-http'
import { import {
relayFiltersUseCapitalLetterTagKeys, relayFiltersUseCapitalLetterTagKeys,
@ -285,6 +285,14 @@ class ClientService extends EventTarget {
params?: { connectionTimeout?: number; abort?: AbortSignal } params?: { connectionTimeout?: number; abort?: AbortSignal }
) => { ) => {
const n = normalizeUrl(url) || url const n = normalizeUrl(url) || url
// ── DIAGNOSTIC: catch any local-network WS attempt so we can trace its origin ──
if (isLocalNetworkUrl(n)) {
logger.warn('[DIAG] pool.ensureRelay called with LOCAL-NETWORK WS URL', {
url,
normalizedUrl: n,
stack: new Error('stack').stack?.split('\n').slice(1, 8).join(' | ')
})
}
const base = params?.connectionTimeout ?? RELAY_POOL_CONNECTION_TIMEOUT_MS const base = params?.connectionTimeout ?? RELAY_POOL_CONNECTION_TIMEOUT_MS
const connectionTimeout = READ_ONLY_RELAY_CONNECT_BOOST_URLS.has(n) const connectionTimeout = READ_ONLY_RELAY_CONNECT_BOOST_URLS.has(n)
? Math.max(base, RELAY_READ_ONLY_POOL_CONNECT_TIMEOUT_MS) ? Math.max(base, RELAY_READ_ONLY_POOL_CONNECT_TIMEOUT_MS)
@ -429,7 +437,7 @@ class ClientService extends EventTarget {
*/ */
async fetchNip66DiscoveryForRelay(relayUrl: string): Promise<void> { async fetchNip66DiscoveryForRelay(relayUrl: string): Promise<void> {
const discoveryRelays = Array.from(new Set([...FAST_READ_RELAY_URLS, ...NIP66_DISCOVERY_RELAY_URLS])) const discoveryRelays = Array.from(new Set([...FAST_READ_RELAY_URLS, ...NIP66_DISCOVERY_RELAY_URLS]))
const dTag = normalizeUrl(relayUrl) || relayUrl const dTag = normalizeAnyRelayUrl(relayUrl) || relayUrl
const shortForm = simplifyUrl(dTag) const shortForm = simplifyUrl(dTag)
const dValues = dTag !== shortForm ? [dTag, shortForm] : [dTag] const dValues = dTag !== shortForm ? [dTag, shortForm] : [dTag]
try { try {
@ -576,11 +584,10 @@ class ClientService extends EventTarget {
let userWriteSet = new Set<string>() let userWriteSet = new Set<string>()
try { try {
const rl = await this.fetchRelayList(event.pubkey) const rl = await this.fetchRelayList(event.pubkey)
userWriteSet = new Set( userWriteSet = new Set([
(rl?.write ?? []) ...(rl?.write ?? []).map((u) => normalizeUrl(u) || u).filter((u): u is string => !!u),
.map((u) => normalizeUrl(u) || u) ...(rl?.httpWrite ?? []).map((u) => normalizeHttpRelayUrl(u) || u).filter((u): u is string => !!u)
.filter((u): u is string => !!u) ])
)
} catch { } catch {
// ignore // ignore
} }
@ -617,7 +624,7 @@ class ClientService extends EventTarget {
const t4: string[] = [] const t4: string[] = []
const t5: string[] = [] const t5: string[] = []
for (const u of relayUrls) { for (const u of relayUrls) {
const n = normalizeUrl(u) || u const n = normalizeAnyRelayUrl(u) || u
if (!n) continue if (!n) continue
if (userWriteSet.has(n)) t0.push(n) if (userWriteSet.has(n)) t0.push(n)
else if (authorReadSet.has(n)) t1.push(n) else if (authorReadSet.has(n)) t1.push(n)
@ -628,7 +635,7 @@ class ClientService extends EventTarget {
} }
return dedupeNormalizeRelayUrlsOrdered([...t0, ...t1, ...t2, ...t3, ...t4, ...t5]) return dedupeNormalizeRelayUrlsOrdered([...t0, ...t1, ...t2, ...t3, ...t4, ...t5])
.filter((url) => { .filter((url) => {
const n = normalizeUrl(url) || url const n = normalizeAnyRelayUrl(url) || url
if (readOnlySet.has(n)) return false if (readOnlySet.has(n)) return false
if (isSocialKindBlockedKind(event.kind) && socialKindBlockedSet.has(n)) return false if (isSocialKindBlockedKind(event.kind) && socialKindBlockedSet.has(n)) return false
return true return true
@ -673,9 +680,13 @@ class ClientService extends EventTarget {
if (event.kind === kinds.Report) { if (event.kind === kinds.Report) {
// Start with user's write relays (outboxes) - these are the primary targets for reports // Start with user's write relays (outboxes) - these are the primary targets for reports
const relayList = await this.fetchRelayList(event.pubkey) const relayList = await this.fetchRelayList(event.pubkey)
const userWriteRelays = dedupeNormalizeRelayUrlsOrdered( const reportHttpWrites = (relayList?.httpWrite ?? [])
(relayList?.write ?? []).map((url) => normalizeUrl(url) || url).filter((u): u is string => !!u) .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]
@ -685,9 +696,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 => normalizeUrl(url) || url)) const userWriteRelaySet = new Set(userWriteRelays.map(url => normalizeAnyRelayUrl(url) || url))
seenRelays.push(...allSeenRelays.filter(url => { seenRelays.push(...allSeenRelays.filter(url => {
const normalized = normalizeUrl(url) || url const normalized = normalizeAnyRelayUrl(url) || url
return userWriteRelaySet.has(normalized) return userWriteRelaySet.has(normalized)
})) }))
} }
@ -722,8 +733,14 @@ class ClientService extends EventTarget {
event.kind === ExtendedKind.PUBLIC_MESSAGE || event.kind === ExtendedKind.PUBLIC_MESSAGE ||
event.kind === ExtendedKind.CALENDAR_EVENT_RSVP event.kind === ExtendedKind.CALENDAR_EVENT_RSVP
) { ) {
const authorRelayList = await this.fetchRelayList(event.pubkey).catch(() => ({ write: [] as string[], read: [] as string[] })) const authorRelayList = await this.fetchRelayList(event.pubkey).catch(() => ({ write: [] as string[], read: [] as string[], httpWrite: [] as string[], httpRead: [] as string[] }))
let authorWrite = (authorRelayList?.write ?? []).map((url) => normalizeUrl(url)).filter(Boolean) as string[] const authorHttpWrites = (authorRelayList?.httpWrite ?? [])
.map((url) => normalizeHttpRelayUrl(url))
.filter((url): url is string => !!url)
const authorWsWrites = (authorRelayList?.write ?? [])
.map((url) => normalizeUrl(url))
.filter((url): url is string => !!url)
let authorWrite = dedupeNormalizeRelayUrlsOrdered([...authorHttpWrites, ...authorWsWrites])
if (authorWrite.length === 0) { if (authorWrite.length === 0) {
authorWrite = [...FAST_WRITE_RELAY_URLS] authorWrite = [...FAST_WRITE_RELAY_URLS]
} }
@ -735,10 +752,11 @@ class ClientService extends EventTarget {
let recipientRead: string[] = [] let recipientRead: string[] = []
if (recipientPubkeys.length > 0) { if (recipientPubkeys.length > 0) {
const recipientRelayLists = await this.fetchRelayLists(recipientPubkeys) const recipientRelayLists = await this.fetchRelayLists(recipientPubkeys)
recipientRead = recipientRelayLists.flatMap((rl) => rl?.read ?? []) recipientRead = recipientRelayLists.flatMap((rl) => [
recipientRead = recipientRead ...(rl?.httpRead ?? []).map((url) => normalizeHttpRelayUrl(url)).filter((u): u is string => !!u && !isLocalNetworkUrl(u)),
.map((url) => normalizeUrl(url)) ...(rl?.read ?? []).map((url) => normalizeUrl(url)).filter((u): u is string => !!u && !isLocalNetworkUrl(u))
.filter((url): url is string => !!url && !isLocalNetworkUrl(url)) ])
recipientRead = dedupeNormalizeRelayUrlsOrdered(recipientRead)
} }
let pubRelays = mergeRelayPriorityLayers( let pubRelays = mergeRelayPriorityLayers(
[relayUrlsLocalsFirst(authorWrite), dedupeNormalizeRelayUrlsOrdered(recipientRead)], [relayUrlsLocalsFirst(authorWrite), dedupeNormalizeRelayUrlsOrdered(recipientRead)],
@ -791,14 +809,16 @@ class ClientService extends EventTarget {
httpOriginalRelays: [] httpOriginalRelays: []
} }
} }
const normalizedWrite = dedupeNormalizeRelayUrlsOrdered( const spellHttpWrites = (spellRelayList?.httpWrite ?? [])
(spellRelayList?.write ?? []) .map((url) => normalizeHttpRelayUrl(url))
.map((url) => normalizeUrl(url)) .filter((url): url is string => !!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 = normalizedWrite.filter((url) => {
const n = normalizeUrl(url) || url const n = normalizeAnyRelayUrl(url) || url
return !readOnlySet.has(n) return !readOnlySet.has(n)
}) })
return this.filterPublishingRelays( return this.filterPublishingRelays(
@ -826,6 +846,10 @@ class ClientService extends EventTarget {
if (ctxPubkeys.length > 0) { if (ctxPubkeys.length > 0) {
const relayLists = await this.fetchRelayLists(ctxPubkeys) const relayLists = await this.fetchRelayLists(ctxPubkeys)
relayLists.forEach((relayList) => { relayLists.forEach((relayList) => {
for (const u of relayList.httpRead ?? []) {
const n = normalizeHttpRelayUrl(u) || u
if (n) authorInboxFromContext.push(n)
}
for (const u of relayList.read ?? []) { for (const u of relayList.read ?? []) {
const n = normalizeUrl(u) || u const n = normalizeUrl(u) || u
if (n) authorInboxFromContext.push(n) if (n) authorInboxFromContext.push(n)
@ -904,9 +928,13 @@ class ClientService extends EventTarget {
writeRelays: relayList?.write?.slice(0, MAX_PUBLISH_RELAYS) ?? [] writeRelays: relayList?.write?.slice(0, MAX_PUBLISH_RELAYS) ?? []
}) })
} }
const userWritesOrdered = dedupeNormalizeRelayUrlsOrdered( const wsWrites = (relayList?.write ?? [])
(relayList?.write ?? []).map((u) => normalizeUrl(u) || u).filter((u): u is string => !!u) .map((u) => normalizeUrl(u) || u)
) .filter((u): u is string => !!u)
const httpWrites = (relayList?.httpWrite ?? [])
.map((u) => normalizeHttpRelayUrl(u) || u)
.filter((u): u is string => !!u)
const userWritesOrdered = dedupeNormalizeRelayUrlsOrdered([...httpWrites, ...wsWrites])
relays = this.filterPublishingRelays( relays = this.filterPublishingRelays(
buildPrioritizedWriteRelayUrls({ buildPrioritizedWriteRelayUrls({
userWriteRelays: userWritesOrdered, userWriteRelays: userWritesOrdered,
@ -1005,6 +1033,14 @@ class ClientService extends EventTarget {
private recordSessionRelayFailure(url: string) { private recordSessionRelayFailure(url: string) {
const n = normalizeAnyRelayUrl(url) || url const n = normalizeAnyRelayUrl(url) || url
if (!n) return if (!n) return
// ── DIAGNOSTIC: trace who is recording failures for local-network relays ──
if (isLocalNetworkUrl(n)) {
logger.warn('[DIAG] recordSessionRelayFailure for LOCAL-NETWORK relay', {
url,
normalizedUrl: n,
stack: new Error('stack').stack?.split('\n').slice(1, 8).join(' | ')
})
}
const prev = this.publishStrikeCount.get(n) ?? 0 const prev = this.publishStrikeCount.get(n) ?? 0
if (prev >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) { if (prev >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) {
return return
@ -1373,7 +1409,7 @@ class ClientService extends EventTarget {
const base = normalizeHttpRelayUrl(url) || url const base = normalizeHttpRelayUrl(url) || url
logger.debug(`[PublishEvent] Publishing to HTTP index relay`, { url: base }) logger.debug(`[PublishEvent] Publishing to HTTP index relay`, { url: base })
await Promise.race([ await Promise.race([
publishEventToIndexRelay(base, event), publishEventToHttpRelay(base, event),
new Promise<never>((_, reject) => new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`HTTP publish timeout after ${publishTimeout}ms`)), publishTimeout) setTimeout(() => reject(new Error(`HTTP publish timeout after ${publishTimeout}ms`)), publishTimeout)
) )
@ -1906,6 +1942,15 @@ class ClientService extends EventTarget {
relayReqLog?: { groupId?: string; onBatchEnd?: (rows: RelayOpTerminalRow[]) => void } relayReqLog?: { groupId?: string; onBatchEnd?: (rows: RelayOpTerminalRow[]) => void }
) { ) {
const originalDedupedRelays = Array.from(new Set(urls)) const originalDedupedRelays = Array.from(new Set(urls))
// ── DIAGNOSTIC: trace local-network URLs entering subscribe() ──
const localInSubscribe = originalDedupedRelays.filter((u) => isLocalNetworkUrl(normalizeAnyRelayUrl(u) || u))
if (localInSubscribe.length > 0) {
logger.warn('[DIAG] subscribe() received LOCAL-NETWORK relay URLs', {
localUrls: localInSubscribe,
allUrls: originalDedupedRelays,
stack: new Error('stack').stack?.split('\n').slice(1, 8).join(' | ')
})
}
let relays = originalDedupedRelays.filter((url) => !isHttpRelayUrl(url)) let relays = originalDedupedRelays.filter((url) => !isHttpRelayUrl(url))
const filters = sanitizeSubscribeFiltersBeforeReq(filter) const filters = sanitizeSubscribeFiltersBeforeReq(filter)
if (filters.length === 0) { if (filters.length === 0) {
@ -2263,6 +2308,16 @@ class ClientService extends EventTarget {
} = {} } = {}
) { ) {
let relays = Array.from(new Set(urls)) let relays = Array.from(new Set(urls))
// ── DIAGNOSTIC: trace local-network URLs entering _subscribeTimeline ──
const localInTimeline = relays.filter((u) => isLocalNetworkUrl(normalizeAnyRelayUrl(u) || u))
if (localInTimeline.length > 0) {
logger.warn('[DIAG] _subscribeTimeline received LOCAL-NETWORK relay URLs', {
localUrls: localInTimeline,
allUrls: relays,
httpOnes: relays.filter((u) => isHttpRelayUrl(u)),
stack: new Error('stack').stack?.split('\n').slice(1, 10).join(' | ')
})
}
if (relayFiltersUseCapitalLetterTagKeys(filter as Filter)) { if (relayFiltersUseCapitalLetterTagKeys(filter as Filter)) {
relays = relayUrlsStripExtendedTagReqBlocked(relays) relays = relayUrlsStripExtendedTagReqBlocked(relays)
if (relays.length === 0) { if (relays.length === 0) {
@ -2605,7 +2660,7 @@ class ClientService extends EventTarget {
getSeenEventRelayUrls(eventId: string): string[] { getSeenEventRelayUrls(eventId: string): string[] {
const key = canonicalSeenOnEventId(eventId) const key = canonicalSeenOnEventId(eventId)
const poolUrls = this.getSeenEventRelays(key).map((r) => normalizeUrl(r.url) || r.url) const poolUrls = this.getSeenEventRelays(key).map((r) => normalizeUrl(r.url) || r.url)
const queryUrls = this.queryService.getSeenEventRelayUrls(key).map((u) => normalizeUrl(u) || u) const queryUrls = this.queryService.getSeenEventRelayUrls(key).map((u) => normalizeAnyRelayUrl(u) || u)
return Array.from(new Set([...poolUrls, ...queryUrls].filter(Boolean))) return Array.from(new Set([...poolUrls, ...queryUrls].filter(Boolean)))
} }
@ -2717,10 +2772,21 @@ class ClientService extends EventTarget {
filter: Filter | Filter[], filter: Filter | Filter[],
options?: { globalTimeout?: number } options?: { globalTimeout?: number }
): Promise<{ events: NEvent[]; connectionError?: string }> { ): Promise<{ events: NEvent[]; connectionError?: string }> {
const normalized = normalizeUrl(url) || url const normalized = normalizeAnyRelayUrl(url) || url
if (!normalized) { if (!normalized) {
return { events: [], connectionError: 'Invalid relay URL' } return { events: [], connectionError: 'Invalid relay URL' }
} }
if (isHttpRelayUrl(normalized)) {
// HTTP index relay: use HTTP API instead of WebSocket pool
try {
const events = await this.queryService.query([normalized], filter, undefined, {
globalTimeout: options?.globalTimeout ?? 25_000
})
return { events, connectionError: undefined }
} catch (e) {
return { events: [], connectionError: e instanceof Error ? e.message : String(e) }
}
}
const usableAfterStrikes = this.relayUrlsAfterStrikesOrRecover([normalized]) const usableAfterStrikes = this.relayUrlsAfterStrikesOrRecover([normalized])
if (usableAfterStrikes.length === 0) { if (usableAfterStrikes.length === 0) {
return { events: [], connectionError: 'Relay skipped this session (repeated failures)' } return { events: [], connectionError: 'Relay skipped this session (repeated failures)' }
@ -3476,8 +3542,6 @@ class ClientService extends EventTarget {
const urls = dedupeNormalizeRelayUrlsOrdered([ const urls = dedupeNormalizeRelayUrlsOrdered([
...relayList.write.map((u) => normalizeUrl(u) || u), ...relayList.write.map((u) => normalizeUrl(u) || u),
...relayList.read.map((u) => normalizeUrl(u) || u), ...relayList.read.map((u) => normalizeUrl(u) || u),
...relayList.httpRead.map((u) => normalizeHttpRelayUrl(u) || u),
...relayList.httpWrite.map((u) => normalizeHttpRelayUrl(u) || u),
...FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u), ...FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u),
...PROFILE_FETCH_RELAY_URLS.map((u) => normalizeUrl(u) || u) ...PROFILE_FETCH_RELAY_URLS.map((u) => normalizeUrl(u) || u)
]).filter(Boolean) ]).filter(Boolean)

8
src/services/relay-info.service.ts

@ -1,4 +1,4 @@
import { simplifyUrl } from '@/lib/url' import { devProxyLoopbackHttpRelayBase, normalizeHttpRelayUrl, simplifyUrl } from '@/lib/url'
import indexDb from '@/services/indexed-db.service' import indexDb from '@/services/indexed-db.service'
import { TAwesomeRelayCollection, TRelayInfo } from '@/types' import { TAwesomeRelayCollection, TRelayInfo } from '@/types'
import DataLoader from 'dataloader' import DataLoader from 'dataloader'
@ -148,10 +148,14 @@ class RelayInfoService {
private async fetchRelayNip11(url: string) { private async fetchRelayNip11(url: string) {
try { try {
logger.debug('Fetching NIP-11 metadata', { url }) logger.debug('Fetching NIP-11 metadata', { url })
const res = await fetchWithTimeout(url.replace('ws://', 'http://').replace('wss://', 'https://'), { const httpCandidate = url.trim().replace(/^ws:\/\//i, 'http://').replace(/^wss:\/\//i, 'https://')
const httpBase = normalizeHttpRelayUrl(httpCandidate) || httpCandidate
const fetchUrl = devProxyLoopbackHttpRelayBase(httpBase)
const res = await fetchWithTimeout(fetchUrl, {
headers: { Accept: 'application/nostr+json' }, headers: { Accept: 'application/nostr+json' },
timeoutMs: 12_000 timeoutMs: 12_000
}) })
if (!res.ok) return undefined
return res.json() as Omit<TRelayInfo, 'url' | 'shortUrl'> return res.json() as Omit<TRelayInfo, 'url' | 'shortUrl'>
} catch { } catch {
return undefined return undefined

4
src/services/relay-operation-log.service.ts

@ -1,11 +1,11 @@
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { normalizeUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import type { Filter } from 'nostr-tools' import type { Filter } from 'nostr-tools'
let batchSeq = 0 let batchSeq = 0
function relayHostForPublishLog(url: string): string { function relayHostForPublishLog(url: string): string {
const n = normalizeUrl(url) || url const n = normalizeAnyRelayUrl(url) || url
try { try {
const u = new URL(n.replace(/^wss:/i, 'https:').replace(/^ws:/i, 'http:')) const u = new URL(n.replace(/^wss:/i, 'https:').replace(/^ws:/i, 'http:'))
const path = u.pathname && u.pathname !== '/' ? u.pathname.replace(/\/$/, '') : '' const path = u.pathname && u.pathname !== '/' ? u.pathname.replace(/\/$/, '') : ''

2
vite.config.ts

@ -85,7 +85,7 @@ export default defineConfig(({ mode }) => {
// `.env.local` is not on `process.env` when this file is evaluated unless we load it. // `.env.local` is not on `process.env` when this file is evaluated unless we load it.
const env = loadEnv(mode, process.cwd(), '') const env = loadEnv(mode, process.cwd(), '')
const devIndexRelayTarget = const devIndexRelayTarget =
env.VITE_DEV_INDEX_RELAY_TARGET?.trim() || 'http://127.0.0.1:1122' env.VITE_DEV_INDEX_RELAY_TARGET?.trim() || 'http://127.0.0.1:4000'
return { return {
base: '/', base: '/',

Loading…
Cancel
Save