diff --git a/src/components/ContentPreview/FollowPackPreview.tsx b/src/components/ContentPreview/FollowPackPreview.tsx
index 962c9493..37e93575 100644
--- a/src/components/ContentPreview/FollowPackPreview.tsx
+++ b/src/components/ContentPreview/FollowPackPreview.tsx
@@ -3,7 +3,7 @@ import { getImetaInfosFromEvent } from '@/lib/event'
import { getPubkeysFromPTags } from '@/lib/tag'
import logger from '@/lib/logger'
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 { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider'
diff --git a/src/components/FollowButton/index.tsx b/src/components/FollowButton/index.tsx
index 295363f3..6f7c22ea 100644
--- a/src/components/FollowButton/index.tsx
+++ b/src/components/FollowButton/index.tsx
@@ -11,7 +11,7 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
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 { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider'
diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx
index 777ef2ee..3bf1990d 100644
--- a/src/components/NoteOptions/useMenuActions.tsx
+++ b/src/components/NoteOptions/useMenuActions.tsx
@@ -11,7 +11,7 @@ import {
parsePublicationATagCoordinate,
type PublicationSectionRef
} 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 {
buildPinListTagsAfterToggle,
@@ -104,25 +104,30 @@ export function useMenuActions({
// Use useContext directly to avoid error if provider is not available
const primaryPageContext = useContext(PrimaryPageContext)
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 { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays()
const { relaySets, favoriteRelays } = useFavoriteRelays()
+ const httpWriteRelayUrls = useMemo(() => {
+ return (relayList?.httpWrite ?? [])
+ .map(url => normalizeHttpRelayUrl(url) || url)
+ .filter(Boolean) as string[]
+ }, [relayList?.httpWrite])
const relayUrls = useMemo(() => {
return Array.from(new Set([
- ...currentBrowsingRelayUrls.map(url => normalizeUrl(url) || url),
- ...favoriteRelays.map(url => normalizeUrl(url) || url)
+ ...currentBrowsingRelayUrls.map(url => normalizeAnyRelayUrl(url) || url),
+ ...favoriteRelays.map(url => normalizeAnyRelayUrl(url) || url)
]))
}, [currentBrowsingRelayUrls, favoriteRelays])
/** All available relays: current feed, favorites, relay sets, defaults (BIG, FAST_READ, FAST_WRITE). */
const allAvailableRelayUrls = useMemo(() => {
const urls = [
- ...currentBrowsingRelayUrls.map(url => normalizeUrl(url) || url),
- ...favoriteRelays.map(url => normalizeUrl(url) || url),
- ...relaySets.flatMap(set => set.relayUrls.map(url => normalizeUrl(url) || url)),
- ...FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url),
- ...FAST_WRITE_RELAY_URLS.map(url => normalizeUrl(url) || url)
+ ...currentBrowsingRelayUrls.map(url => normalizeAnyRelayUrl(url) || url),
+ ...favoriteRelays.map(url => normalizeAnyRelayUrl(url) || url),
+ ...relaySets.flatMap(set => set.relayUrls.map(url => normalizeAnyRelayUrl(url) || url)),
+ ...FAST_READ_RELAY_URLS.map(url => normalizeAnyRelayUrl(url) || url),
+ ...FAST_WRITE_RELAY_URLS.map(url => normalizeAnyRelayUrl(url) || url)
].filter(Boolean) as string[]
return Array.from(new Set(urls))
}, [currentBrowsingRelayUrls, favoriteRelays, relaySets])
@@ -163,7 +168,7 @@ export function useMenuActions({
...FAST_WRITE_RELAY_URLS
]
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)
if (pinListEvent) {
@@ -196,7 +201,7 @@ export function useMenuActions({
]
const normalizedRelays = allRelays
- .map(url => normalizeUrl(url))
+ .map(url => normalizeAnyRelayUrl(url))
.filter((url): url is string => !!url)
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(
- ...relayUrls.map((relay, index) => ({
+ ...wsAndHttpRelayUrls.map((relay, index) => ({
label: (
@@ -418,7 +424,7 @@ export function useMenuActions({
}
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
const isArticleType = useMemo(() => {
diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx
index 63ca848c..3af4cb1d 100644
--- a/src/components/PostEditor/PostContent.tsx
+++ b/src/components/PostEditor/PostContent.tsx
@@ -89,7 +89,7 @@ import {
import { prefixNostrAddresses } from '@/lib/nostr-address'
import dayjs from 'dayjs'
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 { Switch } from '@/components/ui/switch'
import { DISCUSSION_TOPICS } from '@/pages/primary/DiscussionsPage/discussionTopics'
diff --git a/src/components/Profile/Followings.tsx b/src/components/Profile/Followings.tsx
index ea28fadb..980a3ad7 100644
--- a/src/components/Profile/Followings.tsx
+++ b/src/components/Profile/Followings.tsx
@@ -1,7 +1,7 @@
import { useFetchFollowings } from '@/hooks'
import { toFollowingList } from '@/lib/link'
import { SecondaryPageLink } from '@/PageManager'
-import { useFollowList } from '@/providers/FollowListProvider'
+import { useFollowList } from '@/providers/follow-list-context'
import { useNostr } from '@/providers/NostrProvider'
import { Skeleton } from '@/components/ui/skeleton'
import { useTranslation } from 'react-i18next'
diff --git a/src/components/Profile/SmartFollowings.tsx b/src/components/Profile/SmartFollowings.tsx
index 7f47aa3d..e82dc40d 100644
--- a/src/components/Profile/SmartFollowings.tsx
+++ b/src/components/Profile/SmartFollowings.tsx
@@ -1,7 +1,7 @@
import { useFetchFollowings } from '@/hooks'
import { toFollowingList } from '@/lib/link'
import { useSmartFollowingListNavigation } from '@/PageManager'
-import { useFollowListOptional } from '@/providers/FollowListProvider'
+import { useFollowListOptional } from '@/providers/follow-list-context'
import { useNostr } from '@/providers/NostrProvider'
import { Skeleton } from '@/components/ui/skeleton'
import { useTranslation } from 'react-i18next'
diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx
index 010ee837..ba31ab82 100644
--- a/src/components/Profile/index.tsx
+++ b/src/components/Profile/index.tsx
@@ -66,7 +66,7 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import { nip66Service } from '@/services/nip66.service'
-import { normalizeUrl } from '@/lib/url'
+import { normalizeAnyRelayUrl } from '@/lib/url'
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). */
const allAvailableRelayUrls = useMemo(() => {
const urls = [
- ...currentBrowsingRelayUrls.map(url => normalizeUrl(url) || url),
- ...favoriteRelays.map(url => normalizeUrl(url) || url),
- ...relaySets.flatMap(set => set.relayUrls.map(url => normalizeUrl(url) || url)),
- ...FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url),
- ...FAST_WRITE_RELAY_URLS.map(url => normalizeUrl(url) || url)
+ ...currentBrowsingRelayUrls.map(url => normalizeAnyRelayUrl(url) || url),
+ ...favoriteRelays.map(url => normalizeAnyRelayUrl(url) || url),
+ ...relaySets.flatMap(set => set.relayUrls.map(url => normalizeAnyRelayUrl(url) || url)),
+ ...FAST_READ_RELAY_URLS.map(url => normalizeAnyRelayUrl(url) || url),
+ ...FAST_WRITE_RELAY_URLS.map(url => normalizeAnyRelayUrl(url) || url)
].filter(Boolean) as string[]
return Array.from(new Set(urls))
}, [currentBrowsingRelayUrls, favoriteRelays, relaySets])
diff --git a/src/components/ProfileOptions/index.tsx b/src/components/ProfileOptions/index.tsx
index be150388..0592df0d 100644
--- a/src/components/ProfileOptions/index.tsx
+++ b/src/components/ProfileOptions/index.tsx
@@ -8,7 +8,7 @@ import {
} from '@/components/ui/dropdown-menu'
import { buildHiveTalkJoinUrl, roomIdForPubkeys } from '@/lib/hivetalk'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
-import { normalizeUrl } from '@/lib/url'
+import { normalizeAnyRelayUrl } from '@/lib/url'
import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
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). */
const allAvailableRelayUrls = useMemo(() => {
const urls = [
- ...currentBrowsingRelayUrls.map(url => normalizeUrl(url) || url),
- ...favoriteRelays.map(url => normalizeUrl(url) || url),
- ...relaySets.flatMap(set => set.relayUrls.map(url => normalizeUrl(url) || url)),
- ...FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url),
- ...FAST_WRITE_RELAY_URLS.map(url => normalizeUrl(url) || url)
+ ...currentBrowsingRelayUrls.map(url => normalizeAnyRelayUrl(url) || url),
+ ...favoriteRelays.map(url => normalizeAnyRelayUrl(url) || url),
+ ...relaySets.flatMap(set => set.relayUrls.map(url => normalizeAnyRelayUrl(url) || url)),
+ ...FAST_READ_RELAY_URLS.map(url => normalizeAnyRelayUrl(url) || url),
+ ...FAST_WRITE_RELAY_URLS.map(url => normalizeAnyRelayUrl(url) || url)
].filter(Boolean) as string[]
return Array.from(new Set(urls))
}, [currentBrowsingRelayUrls, favoriteRelays, relaySets])
diff --git a/src/components/Relay/index.tsx b/src/components/Relay/index.tsx
index 388df45a..da21b2b6 100644
--- a/src/components/Relay/index.tsx
+++ b/src/components/Relay/index.tsx
@@ -5,7 +5,7 @@ import SearchInput from '@/components/SearchInput'
import { useFetchRelayInfo } from '@/hooks'
import type { TPrimaryPageName } from '@/PageManager'
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 client, { JUMBLE_SESSION_RELAY_STRIKES_CHANGED } from '@/services/client.service'
import type { TFeedSubRequest } from '@/types'
@@ -19,7 +19,8 @@ const Relay = forwardRef<
>(function Relay({ url, className, hostPrimaryPageName }, ref) {
const { t } = useTranslation()
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 [searchInput, setSearchInput] = useState('')
const [debouncedInput, setDebouncedInput] = useState(searchInput)
@@ -82,7 +83,7 @@ const Relay = forwardRef<
const handleRelayRefresh = (event: CustomEvent) => {
const { relayUrl } = event.detail
- if (normalizeUrl(relayUrl) === normalizedUrl) {
+ if (normalizeAnyRelayUrl(relayUrl) === normalizedUrl) {
if (noteListRef && typeof noteListRef !== 'function') {
noteListRef.current?.refresh()
}
@@ -97,7 +98,7 @@ const Relay = forwardRef<
}, [normalizedUrl, noteListRef])
const relayFeedSubRequests = useMemo(() => {
- if (!normalizedUrl) return []
+ if (!normalizedUrl || isHttpRelay) return []
const q = debouncedInput.trim()
return [
{
@@ -107,7 +108,7 @@ const Relay = forwardRef<
: { limit: SINGLE_RELAY_KINDLESS_REQ_LIMIT }
}
]
- }, [normalizedUrl, debouncedInput])
+ }, [normalizedUrl, isHttpRelay, debouncedInput])
if (!normalizedUrl) {
return
diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx
index ee66b87f..464b4dea 100644
--- a/src/components/ReplyNoteList/index.tsx
+++ b/src/components/ReplyNoteList/index.tsx
@@ -19,7 +19,7 @@ import {
} from '@/lib/event'
import logger from '@/lib/logger'
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 { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
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
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 fromBrowsingFeed = browsingRelayUrls.map((u) => normalizeUrl(u) || u).filter(Boolean)
+ const fromBrowsingFeed = browsingRelayUrls.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean)
const threadRelayHints = [
...new Set([...relayHintsFromEventTags(event), ...seenOn, ...fromBrowsingFeed])
]
diff --git a/src/components/SaveRelayDropdownMenu/index.tsx b/src/components/SaveRelayDropdownMenu/index.tsx
index d8403aeb..d15d7b3b 100644
--- a/src/components/SaveRelayDropdownMenu/index.tsx
+++ b/src/components/SaveRelayDropdownMenu/index.tsx
@@ -26,7 +26,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
-import { normalizeUrl } from '@/lib/url'
+import { normalizeAnyRelayUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
@@ -47,7 +47,7 @@ export default function SaveRelayDropdownMenu({
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
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(() => {
return (
normalizedUrls.every((url) => favoriteRelays.includes(url)) ||
@@ -188,8 +188,8 @@ function RelaySetItem({ set, urls }: { set: TRelaySet; urls: string[] }) {
updateRelaySet({
...set,
relayUrls: Array.from(new Set([
- ...set.relayUrls.map(url => normalizeUrl(url) || url),
- ...urls.map(url => normalizeUrl(url) || url)
+ ...set.relayUrls.map(url => normalizeAnyRelayUrl(url) || url),
+ ...urls.map(url => normalizeAnyRelayUrl(url) || url)
]))
})
}
diff --git a/src/components/SearchBar/index.tsx b/src/components/SearchBar/index.tsx
index d54816de..4fb88de7 100644
--- a/src/components/SearchBar/index.tsx
+++ b/src/components/SearchBar/index.tsx
@@ -4,7 +4,7 @@ import { toNote, toNoteList } from '@/lib/link'
import client from '@/services/client.service'
import { eventService } from '@/services/client.service'
import { randomString } from '@/lib/random'
-import { normalizeUrl } from '@/lib/url'
+import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl } from '@/lib/url'
import { normalizeToDTag } from '@/lib/search-parser'
import { cn } from '@/lib/utils'
import { useSmartNoteNavigation, useSmartHashtagNavigation } from '@/PageManager'
@@ -53,7 +53,9 @@ const SearchBar = forwardRef<
return undefined
}
try {
- return normalizeUrl(input)
+ const n = normalizeAnyRelayUrl(input)
+ if (!n || (!isHttpRelayUrl(n) && !isWebsocketUrl(n))) return undefined
+ return n
} catch {
return undefined
}
diff --git a/src/components/TopicSubscribeButton/index.tsx b/src/components/TopicSubscribeButton/index.tsx
index ab1fcd9d..820c6a7d 100644
--- a/src/components/TopicSubscribeButton/index.tsx
+++ b/src/components/TopicSubscribeButton/index.tsx
@@ -1,6 +1,6 @@
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
-import { useInterestList } from '@/providers/InterestListProvider'
+import { useInterestList } from '@/providers/interest-list-context'
import { useNostr } from '@/providers/NostrProvider'
import { Bell, BellOff } from 'lucide-react'
import { useTranslation } from 'react-i18next'
diff --git a/src/hooks/useQuoteEvents.tsx b/src/hooks/useQuoteEvents.tsx
index 354393dd..88ca4c3b 100644
--- a/src/hooks/useQuoteEvents.tsx
+++ b/src/hooks/useQuoteEvents.tsx
@@ -6,7 +6,7 @@ import {
} from '@/constants'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { buildNormalizedBlockedRelaySet } from '@/lib/thread-response-filter'
-import { normalizeUrl } from '@/lib/url'
+import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
@@ -69,7 +69,7 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) {
}, INITIAL_QUOTE_LOAD_TIMEOUT_MS)
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 eTagBlockedSet = new Set(
E_TAG_FILTER_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)
diff --git a/src/lib/account-list-relay-urls.ts b/src/lib/account-list-relay-urls.ts
index 76eeebdd..d88c14ab 100644
--- a/src/lib/account-list-relay-urls.ts
+++ b/src/lib/account-list-relay-urls.ts
@@ -16,15 +16,15 @@ export async function buildAccountListRelayUrlsForMerge(options: {
const myRelayList = await client.fetchRelayList(accountPubkey)
const favoritesTier = getFavoritesFeedRelayUrls(favoriteRelays ?? [], blockedRelays)
const read = buildPrioritizedReadRelayUrls({
- userReadRelays: [...(myRelayList.httpRead ?? []), ...(myRelayList.read ?? [])],
- userWriteRelays: [...(myRelayList.httpWrite ?? []), ...(myRelayList.write ?? [])],
+ userReadRelays: myRelayList.read ?? [],
+ userWriteRelays: myRelayList.write ?? [],
favoriteRelays: favoritesTier,
blockedRelays,
maxRelays: 100,
applySocialKindBlockedFilter: false
})
const write = buildPrioritizedWriteRelayUrls({
- userWriteRelays: [...(myRelayList.httpWrite ?? []), ...(myRelayList.write ?? [])],
+ userWriteRelays: myRelayList.write ?? [],
favoriteRelays: favoritesTier,
blockedRelays,
maxRelays: 100,
diff --git a/src/lib/index-relay-http.ts b/src/lib/index-relay-http.ts
index deccdd84..b9472be8 100644
--- a/src/lib/index-relay-http.ts
+++ b/src/lib/index-relay-http.ts
@@ -8,7 +8,7 @@
*/
import { fetchWithTimeout } from '@/lib/fetch-with-timeout'
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 { verifyEvent } from 'nostr-tools'
@@ -16,24 +16,6 @@ function trimSlash(base: string): string {
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 {
return `${trimSlash(normalizeHttpRelayUrl(baseUrl) || baseUrl)}/api/events/filter`
}
@@ -162,7 +144,7 @@ export async function queryIndexRelay(
filter: Filter | Filter[],
options?: { signal?: AbortSignal; onHardFailure?: () => void }
): Promise {
- const base = devProxyLoopbackIndexRelayBase(normalizeHttpRelayUrl(baseUrl) || baseUrl)
+ const base = devProxyLoopbackHttpRelayBase(normalizeHttpRelayUrl(baseUrl) || baseUrl)
const endpoint = indexRelayFilterUrl(base)
const filters = Array.isArray(filter) ? filter : [filter]
const out: NEvent[] = []
@@ -225,12 +207,12 @@ function filterForIndexRelay(f: Filter): Filter {
return rest as Filter
}
-export async function publishEventToIndexRelay(
+export async function publishEventToHttpRelay(
baseUrl: string,
event: NEvent,
options?: { signal?: AbortSignal }
): Promise {
- const base = devProxyLoopbackIndexRelayBase(normalizeHttpRelayUrl(baseUrl) || baseUrl)
+ const base = devProxyLoopbackHttpRelayBase(normalizeHttpRelayUrl(baseUrl) || baseUrl)
const endpoint = indexRelayPublishUrl(base)
try {
const res = await fetchWithTimeout(endpoint, {
diff --git a/src/lib/replaceable-list-latest.ts b/src/lib/replaceable-list-latest.ts
index faa1d545..d23cf0fd 100644
--- a/src/lib/replaceable-list-latest.ts
+++ b/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 { normalizeHexPubkey } from '@/lib/pubkey'
-import { normalizeUrl } from '@/lib/url'
-import client, { queryService } from '@/services/client.service'
+import { normalizeAnyRelayUrl } from '@/lib/url'
+import client from '@/services/client.service'
import type { TPersonalListBech32Ref } from '@/lib/personal-list-mutations'
import type { Event } from 'nostr-tools'
@@ -15,17 +15,16 @@ export async function fetchLatestReplaceableListEvent(
relayUrls: string[]
): Promise {
const pk = normalizeHexPubkey(pubkeyHex)
- const urls = [...new Set(relayUrls.map((u) => normalizeUrl(u) || u).filter(Boolean))]
- if (!urls.length) return undefined
- const rows = await queryService.fetchEvents(
- urls,
- { authors: [pk], kinds: [kind], limit: 80 },
- {
- replaceableRace: true,
- eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS,
- globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS
- }
- )
+ const allUrls = [...new Set(relayUrls.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean))]
+ if (!allUrls.length) return undefined
+
+ // client.fetchEvents() handles both HTTP index relays and WebSocket relays internally.
+ const rows = await client.fetchEvents(allUrls, { authors: [pk], kinds: [kind], limit: 80 }, {
+ replaceableRace: true,
+ eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS,
+ globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS
+ })
+
if (!rows.length) return undefined
return rows.reduce((best, e) => (e.created_at > best.created_at ? e : best))
}
diff --git a/src/lib/url.ts b/src/lib/url.ts
index 9c41b461..51b5f91f 100644
--- a/src/lib/url.ts
+++ b/src/lib/url.ts
@@ -30,6 +30,24 @@ export function normalizeHttpRelayUrl(url: string): string {
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}.
*/
diff --git a/src/pages/primary/ExplorePage/index.tsx b/src/pages/primary/ExplorePage/index.tsx
index 4da1006c..2054c1fc 100644
--- a/src/pages/primary/ExplorePage/index.tsx
+++ b/src/pages/primary/ExplorePage/index.tsx
@@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { toRelay } from '@/lib/link'
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 PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions'
@@ -35,7 +35,7 @@ function dedupeNormalizedRelayUrls(urls: string[]): string[] {
const seen = new Set()
const out: string[] = []
for (const u of urls) {
- const k = normalizeUrl(u) || u
+ const k = normalizeAnyRelayUrl(u) || u.trim()
if (!k || seen.has(k)) continue
seen.add(k)
out.push(k)
@@ -232,8 +232,8 @@ function ExploreRelaySearchSection() {
const tryOpenRelay = () => {
const trimmed = relayQuery.trim()
if (!trimmed) return
- const normalized = normalizeUrl(trimmed)
- if (!normalized || !isWebsocketUrl(normalized)) {
+ const normalized = normalizeAnyRelayUrl(trimmed)
+ if (!normalized || (!isHttpRelayUrl(normalized) && !isWebsocketUrl(normalized))) {
toast.error(t('invalid relay URL'))
return
}
diff --git a/src/pages/primary/RelayPage/index.tsx b/src/pages/primary/RelayPage/index.tsx
index 998ad49e..70f57ef1 100644
--- a/src/pages/primary/RelayPage/index.tsx
+++ b/src/pages/primary/RelayPage/index.tsx
@@ -3,13 +3,13 @@ import { RefreshButton } from '@/components/RefreshButton'
import Relay from '@/components/Relay'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { TPageRef } from '@/types'
-import { normalizeUrl, simplifyUrl } from '@/lib/url'
+import { normalizeAnyRelayUrl, simplifyUrl } from '@/lib/url'
import client from '@/services/client.service'
import { Server } from 'lucide-react'
import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from 'react'
const RelayPage = forwardRef(({ url }, ref) => {
- const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url])
+ const normalizedUrl = useMemo(() => (url ? normalizeAnyRelayUrl(url) : undefined), [url])
const layoutRef = useRef(null)
const feedRef = useRef(null)
diff --git a/src/pages/secondary/InterestListPage/index.tsx b/src/pages/secondary/InterestListPage/index.tsx
index 962370e4..434e49e7 100644
--- a/src/pages/secondary/InterestListPage/index.tsx
+++ b/src/pages/secondary/InterestListPage/index.tsx
@@ -27,7 +27,7 @@ import { toNoteList } from '@/lib/link'
import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest'
import { cn } from '@/lib/utils'
import { useSmartHashtagNavigation } from '@/PageManager'
-import { useInterestList } from '@/providers/InterestListProvider'
+import { useInterestList } from '@/providers/interest-list-context'
import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import client from '@/services/client.service'
diff --git a/src/pages/secondary/NoteListPage/index.tsx b/src/pages/secondary/NoteListPage/index.tsx
index 52956118..e4560a28 100644
--- a/src/pages/secondary/NoteListPage/index.tsx
+++ b/src/pages/secondary/NoteListPage/index.tsx
@@ -17,7 +17,7 @@ import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { useSecondaryPage } from '@/PageManager'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
-import { useInterestListOptional } from '@/providers/InterestListProvider'
+import { useInterestListOptional } from '@/providers/interest-list-context'
import client from '@/services/client.service'
import { TFeedSubRequest } from '@/types'
import { UserRound, Plus } from 'lucide-react'
diff --git a/src/pages/secondary/RelayPage/index.tsx b/src/pages/secondary/RelayPage/index.tsx
index 7b00cdd1..a9058758 100644
--- a/src/pages/secondary/RelayPage/index.tsx
+++ b/src/pages/secondary/RelayPage/index.tsx
@@ -3,7 +3,7 @@ import Relay from '@/components/Relay'
import { RefreshButton } from '@/components/RefreshButton'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
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 { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react'
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 { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const feedRef = useRef(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 bumpFeed = useCallback(() => {
diff --git a/src/pages/secondary/RelayReviewsPage/index.tsx b/src/pages/secondary/RelayReviewsPage/index.tsx
index a3e47222..0a7cbcce 100644
--- a/src/pages/secondary/RelayReviewsPage/index.tsx
+++ b/src/pages/secondary/RelayReviewsPage/index.tsx
@@ -5,7 +5,7 @@ import { FAST_READ_RELAY_URLS, ExtendedKind } from '@/constants'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
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 { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
@@ -26,7 +26,7 @@ const RelayReviewsPage = forwardRef(({ url, index, hideTitlebar = false }: { url
return () => registerPrimaryPanelRefresh(null)
}, [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. */
const relayReviewDTags = useMemo(
() => (url ? relayReviewDTagsForRelayUrl(url) : []),
diff --git a/src/providers/FavoriteRelaysProvider.tsx b/src/providers/FavoriteRelaysProvider.tsx
index e181e764..c17ce435 100644
--- a/src/providers/FavoriteRelaysProvider.tsx
+++ b/src/providers/FavoriteRelaysProvider.tsx
@@ -3,7 +3,7 @@ import { createFavoriteRelaysDraftEvent, createBlockedRelaysDraftEvent, createRe
import { getReplaceableEventIdentifier } from '@/lib/event'
import { getRelaySetFromEvent } from '@/lib/event-metadata'
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 indexedDb from '@/services/indexed-db.service'
import storage from '@/services/local-storage.service'
@@ -54,7 +54,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
if (!tagValue) return
if (tagName === 'relay') {
- const normalizedUrl = normalizeUrl(tagValue)
+ const normalizedUrl = normalizeAnyRelayUrl(tagValue)
if (normalizedUrl && !relays.includes(normalizedUrl)) {
relays.push(normalizedUrl)
}
@@ -84,7 +84,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
setRelaySetEvents(storedRelaySetEvents.filter(Boolean) as Event[])
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)
]
const newRelaySetEvents = await queryService.fetchEvents(
@@ -133,7 +133,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
const relays: string[] = []
blockedRelaysEvent.tags.forEach(([tagName, tagValue]) => {
if (tagName === 'relay' && tagValue) {
- const normalizedUrl = normalizeUrl(tagValue)
+ const normalizedUrl = normalizeAnyRelayUrl(tagValue)
if (normalizedUrl && !relays.includes(normalizedUrl)) {
relays.push(normalizedUrl)
}
@@ -151,7 +151,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
const addFavoriteRelays = useCallback(
async (relayUrls: string[]) => {
const normalizedUrls = relayUrls
- .map((relayUrl) => normalizeUrl(relayUrl))
+ .map((relayUrl) => normalizeAnyRelayUrl(relayUrl))
.filter((url) => !!url && !favoriteRelays.includes(url))
if (!normalizedUrls.length) return
@@ -168,7 +168,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
const deleteFavoriteRelays = useCallback(
async (relayUrls: string[]) => {
const normalizedUrls = relayUrls
- .map((relayUrl) => normalizeUrl(relayUrl))
+ .map((relayUrl) => normalizeAnyRelayUrl(relayUrl))
.filter((url) => !!url && favoriteRelays.includes(url))
if (!normalizedUrls.length) return
@@ -185,8 +185,8 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
const createRelaySet = useCallback(
async (relaySetName: string, relayUrls: string[] = []) => {
const normalizedUrls = relayUrls
- .map((url) => normalizeUrl(url))
- .filter((url) => isWebsocketUrl(url))
+ .map((url) => normalizeAnyRelayUrl(url))
+ .filter((url) => isWebsocketUrl(url) || isHttpRelayUrl(url))
const id = randomString()
const relaySetDraftEvent = createRelaySetDraftEvent({
id,
@@ -271,7 +271,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
const addBlockedRelays = useCallback(
async (relayUrls: string[]) => {
const normalizedUrls = relayUrls
- .map((relayUrl) => normalizeUrl(relayUrl))
+ .map((relayUrl) => normalizeAnyRelayUrl(relayUrl))
.filter((url) => !!url && !blockedRelays.includes(url))
if (!normalizedUrls.length) return
const newBlockedRelays = [...blockedRelays, ...normalizedUrls]
@@ -285,7 +285,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
const deleteBlockedRelays = useCallback(
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))
setBlockedRelays(newBlockedRelays)
const draftEvent = createBlockedRelaysDraftEvent(newBlockedRelays)
diff --git a/src/providers/FeedProvider.tsx b/src/providers/FeedProvider.tsx
index 1f546541..0e2de612 100644
--- a/src/providers/FeedProvider.tsx
+++ b/src/providers/FeedProvider.tsx
@@ -2,7 +2,7 @@ import { DEFAULT_FAVORITE_RELAYS } from '@/constants'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { getRelaySetFromEvent } from '@/lib/event-metadata'
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 storage from '@/services/local-storage.service'
import { TFeedInfo, TFeedType } from '@/types'
@@ -38,10 +38,12 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
logger.debug('switchFeed called:', { feedType, options })
setIsReady(false)
if (feedType === 'relay') {
- const normalizedUrl = normalizeUrl(options.relay ?? '')
- logger.debug('Relay switchFeed:', { normalizedUrl, isWebsocketUrl: isWebsocketUrl(normalizedUrl), blockedRelays })
-
- if (!normalizedUrl || !isWebsocketUrl(normalizedUrl)) {
+ const normalizedUrl = normalizeAnyRelayUrl(options.relay ?? '')
+ const isRelayFeedUrl =
+ !!normalizedUrl && (isHttpRelayUrl(normalizedUrl) || isWebsocketUrl(normalizedUrl))
+ logger.debug('Relay switchFeed:', { normalizedUrl, isRelayFeedUrl, blockedRelays })
+
+ if (!isRelayFeedUrl) {
logger.debug('Invalid relay URL, setting isReady to true')
setIsReady(true)
return
diff --git a/src/providers/FollowListProvider.tsx b/src/providers/FollowListProvider.tsx
index b7d8a966..4881a13c 100644
--- a/src/providers/FollowListProvider.tsx
+++ b/src/providers/FollowListProvider.tsx
@@ -8,31 +8,11 @@ import {
import { getPubkeysFromPTags } from '@/lib/tag'
import client from '@/services/client.service'
import { kinds } from 'nostr-tools'
-import { createContext, useContext, useMemo, useCallback } from 'react'
+import { useMemo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useNostr } from './NostrProvider'
import { useFavoriteRelays } from './FavoriteRelaysProvider'
-
-type TFollowListContext = {
- followings: string[]
- follow: (pubkey: string) => Promise
- unfollow: (pubkey: string) => Promise
-}
-
-const FollowListContext = createContext(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)
-}
+import { FollowListContext } from './follow-list-context'
export function FollowListProvider({ children }: { children: React.ReactNode }) {
const { t } = useTranslation()
diff --git a/src/providers/GroupListProvider.tsx b/src/providers/GroupListProvider.tsx
index c58f7bca..b019b37c 100644
--- a/src/providers/GroupListProvider.tsx
+++ b/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 { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { ExtendedKind } from '@/constants'
@@ -7,23 +7,7 @@ import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest'
import { buildPrioritizedReadRelayUrls } from '@/lib/relay-url-priority'
import client from '@/services/client.service'
import logger from '@/lib/logger'
-
-interface GroupListContextType {
- userGroups: string[]
- isUserInGroup: (groupId: string) => boolean
- refreshGroupList: () => Promise
- isLoading: boolean
-}
-
-const GroupListContext = createContext(undefined)
-
-export const useGroupList = () => {
- const context = useContext(GroupListContext)
- if (context === undefined) {
- throw new Error('useGroupList must be used within a GroupListProvider')
- }
- return context
-}
+import { GroupListContext } from './group-list-context'
export function GroupListProvider({ children }: { children: React.ReactNode }) {
const { pubkey: accountPubkey } = useNostr()
diff --git a/src/providers/InterestListProvider.tsx b/src/providers/InterestListProvider.tsx
index 83d82c16..bd1a4ddc 100644
--- a/src/providers/InterestListProvider.tsx
+++ b/src/providers/InterestListProvider.tsx
@@ -4,36 +4,12 @@ import { normalizeTopic } from '@/lib/discussion-topics'
import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest'
import logger from '@/lib/logger'
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 { toast } from 'sonner'
import { useNostr } from '@/providers/nostr-context'
import { useFavoriteRelays } from './FavoriteRelaysProvider'
-
-type TInterestListContext = {
- subscribedTopics: Set
- changing: boolean
- isSubscribed: (topic: string) => boolean
- subscribe: (topic: string) => Promise
- unsubscribe: (topic: string) => Promise
- getSubscribedTopics: () => string[]
-}
-
-const InterestListContext = createContext(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)
+import { InterestListContext } from './interest-list-context'
export function InterestListProvider({ children }: { children: React.ReactNode }) {
const { t } = useTranslation()
diff --git a/src/providers/LiveActivitiesProvider.tsx b/src/providers/LiveActivitiesProvider.tsx
index 8c8c199c..56241be6 100644
--- a/src/providers/LiveActivitiesProvider.tsx
+++ b/src/providers/LiveActivitiesProvider.tsx
@@ -21,7 +21,7 @@ import {
useState
} from 'react'
import { useFavoriteRelays } from './FavoriteRelaysProvider'
-import { useFollowListOptional } from './FollowListProvider'
+import { useFollowListOptional } from './follow-list-context'
import { useNostr } from './NostrProvider'
import { useUserPreferencesOptional } from './UserPreferencesProvider'
diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx
index d8582b43..0e13b3d9 100644
--- a/src/providers/NostrProvider/index.tsx
+++ b/src/providers/NostrProvider/index.tsx
@@ -24,7 +24,7 @@ import { getLatestEvent, minePow } from '@/lib/event'
import { getHttpRelayListFromEvent, getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger'
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 { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback'
import client from '@/services/client.service'
@@ -70,7 +70,7 @@ function favoriteRelayUrlsForPublish(favoriteRelaysEvent: Event | null, pubkey:
const urls: string[] = []
favoriteRelaysEvent.tags.forEach(([name, v]) => {
if (name === 'relay' && v) {
- const n = normalizeUrl(v) || v
+ const n = normalizeAnyRelayUrl(v) || v
if (n && !urls.includes(n)) urls.push(n)
}
})
@@ -82,7 +82,7 @@ function blockedRelayUrlsFromEvent(blockedRelaysEvent: Event | null): string[] {
if (!blockedRelaysEvent) return out
blockedRelaysEvent.tags.forEach(([tagName, tagValue]) => {
if (tagName === 'relay' && tagValue) {
- const n = normalizeUrl(tagValue)
+ const n = normalizeAnyRelayUrl(tagValue)
if (n && !out.includes(n)) out.push(n)
}
})
@@ -477,8 +477,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const normalizedRelays = [
...mergedRelayList.write.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),
...PROFILE_FETCH_RELAY_URLS.map((url: string) => normalizeUrl(url) || url)
]
diff --git a/src/providers/follow-list-context.tsx b/src/providers/follow-list-context.tsx
new file mode 100644
index 00000000..c5bb55b0
--- /dev/null
+++ b/src/providers/follow-list-context.tsx
@@ -0,0 +1,22 @@
+import { createContext, useContext } from 'react'
+
+export type TFollowListContext = {
+ followings: string[]
+ follow: (pubkey: string) => Promise
+ unfollow: (pubkey: string) => Promise
+}
+
+export const FollowListContext = createContext(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)
+}
diff --git a/src/providers/group-list-context.tsx b/src/providers/group-list-context.tsx
new file mode 100644
index 00000000..8b4c20ff
--- /dev/null
+++ b/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
+ isLoading: boolean
+}
+
+export const GroupListContext = createContext(undefined)
+
+export const useGroupList = (): GroupListContextType => {
+ const context = useContext(GroupListContext)
+ if (context === undefined) {
+ throw new Error('useGroupList must be used within a GroupListProvider')
+ }
+ return context
+}
diff --git a/src/providers/interest-list-context.tsx b/src/providers/interest-list-context.tsx
new file mode 100644
index 00000000..31dcbe49
--- /dev/null
+++ b/src/providers/interest-list-context.tsx
@@ -0,0 +1,27 @@
+import { createContext, useContext } from 'react'
+
+export type TInterestListContext = {
+ subscribedTopics: Set
+ changing: boolean
+ isSubscribed: (topic: string) => boolean
+ subscribe: (topic: string) => Promise
+ unsubscribe: (topic: string) => Promise
+ getSubscribedTopics: () => string[]
+}
+
+export const InterestListContext = createContext(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)
diff --git a/src/services/client.service.ts b/src/services/client.service.ts
index b0e8e950..a87a1150 100644
--- a/src/services/client.service.ts
+++ b/src/services/client.service.ts
@@ -106,7 +106,7 @@ import {
import {
IndexRelayTransportError,
isIndexRelayTransportFailure,
- publishEventToIndexRelay
+ publishEventToHttpRelay
} from '@/lib/index-relay-http'
import {
relayFiltersUseCapitalLetterTagKeys,
@@ -285,6 +285,14 @@ class ClientService extends EventTarget {
params?: { connectionTimeout?: number; abort?: AbortSignal }
) => {
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 connectionTimeout = READ_ONLY_RELAY_CONNECT_BOOST_URLS.has(n)
? Math.max(base, RELAY_READ_ONLY_POOL_CONNECT_TIMEOUT_MS)
@@ -429,7 +437,7 @@ class ClientService extends EventTarget {
*/
async fetchNip66DiscoveryForRelay(relayUrl: string): Promise {
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 dValues = dTag !== shortForm ? [dTag, shortForm] : [dTag]
try {
@@ -576,11 +584,10 @@ class ClientService extends EventTarget {
let userWriteSet = new Set()
try {
const rl = await this.fetchRelayList(event.pubkey)
- userWriteSet = new Set(
- (rl?.write ?? [])
- .map((u) => normalizeUrl(u) || u)
- .filter((u): u is string => !!u)
- )
+ userWriteSet = new Set([
+ ...(rl?.write ?? []).map((u) => normalizeUrl(u) || u).filter((u): u is string => !!u),
+ ...(rl?.httpWrite ?? []).map((u) => normalizeHttpRelayUrl(u) || u).filter((u): u is string => !!u)
+ ])
} catch {
// ignore
}
@@ -617,7 +624,7 @@ class ClientService extends EventTarget {
const t4: string[] = []
const t5: string[] = []
for (const u of relayUrls) {
- const n = normalizeUrl(u) || u
+ const n = normalizeAnyRelayUrl(u) || u
if (!n) continue
if (userWriteSet.has(n)) t0.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])
.filter((url) => {
- const n = normalizeUrl(url) || url
+ const n = normalizeAnyRelayUrl(url) || url
if (readOnlySet.has(n)) return false
if (isSocialKindBlockedKind(event.kind) && socialKindBlockedSet.has(n)) return false
return true
@@ -673,9 +680,13 @@ class ClientService extends EventTarget {
if (event.kind === kinds.Report) {
// Start with user's write relays (outboxes) - these are the primary targets for reports
const relayList = await this.fetchRelayList(event.pubkey)
- const userWriteRelays = dedupeNormalizeRelayUrlsOrdered(
- (relayList?.write ?? []).map((url) => normalizeUrl(url) || url).filter((u): u is string => !!u)
- )
+ const reportHttpWrites = (relayList?.httpWrite ?? [])
+ .map((url) => normalizeHttpRelayUrl(url) || url)
+ .filter((u): u is string => !!u)
+ const reportWsWrites = (relayList?.write ?? [])
+ .map((url) => normalizeUrl(url) || url)
+ .filter((u): u is string => !!u)
+ const userWriteRelays = dedupeNormalizeRelayUrlsOrdered([...reportHttpWrites, ...reportWsWrites])
// Get seen relays where the reported event was found
const targetEventId = event.tags.find(tagNameEquals('e'))?.[1]
@@ -685,9 +696,9 @@ class ClientService extends EventTarget {
const allSeenRelays = this.getSeenEventRelayUrls(targetEventId)
// Filter seen relays: only include those that are in user's write list
// This ensures we don't try to publish to read-only relays
- const userWriteRelaySet = new Set(userWriteRelays.map(url => normalizeUrl(url) || url))
+ const userWriteRelaySet = new Set(userWriteRelays.map(url => normalizeAnyRelayUrl(url) || url))
seenRelays.push(...allSeenRelays.filter(url => {
- const normalized = normalizeUrl(url) || url
+ const normalized = normalizeAnyRelayUrl(url) || url
return userWriteRelaySet.has(normalized)
}))
}
@@ -722,8 +733,14 @@ class ClientService extends EventTarget {
event.kind === ExtendedKind.PUBLIC_MESSAGE ||
event.kind === ExtendedKind.CALENDAR_EVENT_RSVP
) {
- const authorRelayList = await this.fetchRelayList(event.pubkey).catch(() => ({ write: [] as string[], read: [] as string[] }))
- let authorWrite = (authorRelayList?.write ?? []).map((url) => normalizeUrl(url)).filter(Boolean) as string[]
+ const authorRelayList = await this.fetchRelayList(event.pubkey).catch(() => ({ write: [] as string[], read: [] as string[], httpWrite: [] as string[], httpRead: [] 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) {
authorWrite = [...FAST_WRITE_RELAY_URLS]
}
@@ -735,10 +752,11 @@ class ClientService extends EventTarget {
let recipientRead: string[] = []
if (recipientPubkeys.length > 0) {
const recipientRelayLists = await this.fetchRelayLists(recipientPubkeys)
- recipientRead = recipientRelayLists.flatMap((rl) => rl?.read ?? [])
- recipientRead = recipientRead
- .map((url) => normalizeUrl(url))
- .filter((url): url is string => !!url && !isLocalNetworkUrl(url))
+ recipientRead = recipientRelayLists.flatMap((rl) => [
+ ...(rl?.httpRead ?? []).map((url) => normalizeHttpRelayUrl(url)).filter((u): u is string => !!u && !isLocalNetworkUrl(u)),
+ ...(rl?.read ?? []).map((url) => normalizeUrl(url)).filter((u): u is string => !!u && !isLocalNetworkUrl(u))
+ ])
+ recipientRead = dedupeNormalizeRelayUrlsOrdered(recipientRead)
}
let pubRelays = mergeRelayPriorityLayers(
[relayUrlsLocalsFirst(authorWrite), dedupeNormalizeRelayUrlsOrdered(recipientRead)],
@@ -791,14 +809,16 @@ class ClientService extends EventTarget {
httpOriginalRelays: []
}
}
- const normalizedWrite = dedupeNormalizeRelayUrlsOrdered(
- (spellRelayList?.write ?? [])
- .map((url) => normalizeUrl(url))
- .filter((url): url is string => !!url)
- )
+ const spellHttpWrites = (spellRelayList?.httpWrite ?? [])
+ .map((url) => normalizeHttpRelayUrl(url))
+ .filter((url): url is string => !!url)
+ const spellWsWrites = (spellRelayList?.write ?? [])
+ .map((url) => normalizeUrl(url))
+ .filter((url): url is string => !!url)
+ const normalizedWrite = dedupeNormalizeRelayUrlsOrdered([...spellHttpWrites, ...spellWsWrites])
const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u))
const spellWriteFiltered = normalizedWrite.filter((url) => {
- const n = normalizeUrl(url) || url
+ const n = normalizeAnyRelayUrl(url) || url
return !readOnlySet.has(n)
})
return this.filterPublishingRelays(
@@ -826,6 +846,10 @@ class ClientService extends EventTarget {
if (ctxPubkeys.length > 0) {
const relayLists = await this.fetchRelayLists(ctxPubkeys)
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 ?? []) {
const n = normalizeUrl(u) || u
if (n) authorInboxFromContext.push(n)
@@ -904,9 +928,13 @@ class ClientService extends EventTarget {
writeRelays: relayList?.write?.slice(0, MAX_PUBLISH_RELAYS) ?? []
})
}
- const userWritesOrdered = dedupeNormalizeRelayUrlsOrdered(
- (relayList?.write ?? []).map((u) => normalizeUrl(u) || u).filter((u): u is string => !!u)
- )
+ const wsWrites = (relayList?.write ?? [])
+ .map((u) => normalizeUrl(u) || u)
+ .filter((u): u is string => !!u)
+ const httpWrites = (relayList?.httpWrite ?? [])
+ .map((u) => normalizeHttpRelayUrl(u) || u)
+ .filter((u): u is string => !!u)
+ const userWritesOrdered = dedupeNormalizeRelayUrlsOrdered([...httpWrites, ...wsWrites])
relays = this.filterPublishingRelays(
buildPrioritizedWriteRelayUrls({
userWriteRelays: userWritesOrdered,
@@ -1005,6 +1033,14 @@ class ClientService extends EventTarget {
private recordSessionRelayFailure(url: string) {
const n = normalizeAnyRelayUrl(url) || url
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
if (prev >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) {
return
@@ -1373,7 +1409,7 @@ class ClientService extends EventTarget {
const base = normalizeHttpRelayUrl(url) || url
logger.debug(`[PublishEvent] Publishing to HTTP index relay`, { url: base })
await Promise.race([
- publishEventToIndexRelay(base, event),
+ publishEventToHttpRelay(base, event),
new Promise((_, reject) =>
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 }
) {
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))
const filters = sanitizeSubscribeFiltersBeforeReq(filter)
if (filters.length === 0) {
@@ -2263,6 +2308,16 @@ class ClientService extends EventTarget {
} = {}
) {
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)) {
relays = relayUrlsStripExtendedTagReqBlocked(relays)
if (relays.length === 0) {
@@ -2605,7 +2660,7 @@ class ClientService extends EventTarget {
getSeenEventRelayUrls(eventId: string): string[] {
const key = canonicalSeenOnEventId(eventId)
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)))
}
@@ -2717,10 +2772,21 @@ class ClientService extends EventTarget {
filter: Filter | Filter[],
options?: { globalTimeout?: number }
): Promise<{ events: NEvent[]; connectionError?: string }> {
- const normalized = normalizeUrl(url) || url
+ const normalized = normalizeAnyRelayUrl(url) || url
if (!normalized) {
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])
if (usableAfterStrikes.length === 0) {
return { events: [], connectionError: 'Relay skipped this session (repeated failures)' }
@@ -3476,8 +3542,6 @@ class ClientService extends EventTarget {
const urls = dedupeNormalizeRelayUrlsOrdered([
...relayList.write.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),
...PROFILE_FETCH_RELAY_URLS.map((u) => normalizeUrl(u) || u)
]).filter(Boolean)
diff --git a/src/services/relay-info.service.ts b/src/services/relay-info.service.ts
index 6d794b2b..45e5ddf2 100644
--- a/src/services/relay-info.service.ts
+++ b/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 { TAwesomeRelayCollection, TRelayInfo } from '@/types'
import DataLoader from 'dataloader'
@@ -148,10 +148,14 @@ class RelayInfoService {
private async fetchRelayNip11(url: string) {
try {
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' },
timeoutMs: 12_000
})
+ if (!res.ok) return undefined
return res.json() as Omit
} catch {
return undefined
diff --git a/src/services/relay-operation-log.service.ts b/src/services/relay-operation-log.service.ts
index e560f0e9..aa923dc6 100644
--- a/src/services/relay-operation-log.service.ts
+++ b/src/services/relay-operation-log.service.ts
@@ -1,11 +1,11 @@
import logger from '@/lib/logger'
-import { normalizeUrl } from '@/lib/url'
+import { normalizeAnyRelayUrl } from '@/lib/url'
import type { Filter } from 'nostr-tools'
let batchSeq = 0
function relayHostForPublishLog(url: string): string {
- const n = normalizeUrl(url) || url
+ const n = normalizeAnyRelayUrl(url) || url
try {
const u = new URL(n.replace(/^wss:/i, 'https:').replace(/^ws:/i, 'http:'))
const path = u.pathname && u.pathname !== '/' ? u.pathname.replace(/\/$/, '') : ''
diff --git a/vite.config.ts b/vite.config.ts
index 12787969..7f81884b 100644
--- a/vite.config.ts
+++ b/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.
const env = loadEnv(mode, process.cwd(), '')
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 {
base: '/',