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: '/',