Browse Source

bug-fixes

imwald
Silberengel 3 weeks ago
parent
commit
8b6bfaadbf
  1. 169
      src/PageManager.tsx
  2. 71
      src/components/Embedded/EmbeddedNote.tsx
  3. 8
      src/components/ParentNotePreview/index.tsx
  4. 19
      src/components/RelayIcon/index.tsx
  5. 6
      src/components/SearchBar/index.tsx
  6. 7
      src/components/SessionRelaysTab/index.tsx
  7. 9
      src/features/feed/descriptor.ts
  8. 15
      src/features/feed/relay-policy.ts
  9. 12
      src/hooks/useFetchThreadContextEvent.tsx
  10. 3
      src/layouts/SecondaryPageLayout/index.tsx
  11. 11
      src/lib/event-metadata.ts
  12. 3
      src/lib/favorites-feed-relays.ts
  13. 5
      src/lib/feed-full-search-relays.ts
  14. 24
      src/lib/home-feed-relays.ts
  15. 3
      src/lib/index-relay-http.ts
  16. 54
      src/lib/nostr-land-relay-eligibility.test.ts
  17. 90
      src/lib/nostr-land-relay-eligibility.ts
  18. 21
      src/lib/read-only-relay-personal.test.ts
  19. 5
      src/lib/read-only-relay-personal.ts
  20. 14
      src/lib/relay-icon-source.test.ts
  21. 11
      src/lib/relay-icon-source.ts
  22. 37
      src/lib/relay-list-builder.ts
  23. 30
      src/lib/relay-list-sanitize.ts
  24. 11
      src/lib/relay-strikes.ts
  25. 29
      src/lib/relay-url-normalize.test.ts
  26. 109
      src/lib/url.ts
  27. 12
      src/pages/primary/ExplorePage/index.tsx
  28. 4
      src/providers/FavoriteRelaysProvider.tsx
  29. 22
      src/providers/FeedProvider.tsx
  30. 25
      src/services/client-query.service.ts
  31. 4
      src/services/client-replaceable-events.service.ts
  32. 82
      src/services/client.service.ts
  33. 21
      src/services/relay-selection.service.ts
  34. 8
      vite.config.ts

169
src/PageManager.tsx

@ -7,7 +7,12 @@ import { RefreshButton } from '@/components/RefreshButton' @@ -7,7 +7,12 @@ import { RefreshButton } from '@/components/RefreshButton'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import logger from '@/lib/logger'
import { useMobileSwipeBackOnElement } from '@/lib/mobile-swipe-back'
import {
MOBILE_SWIPE_BACK_DOMINANCE,
MOBILE_SWIPE_BACK_EDGE_PX,
MOBILE_SWIPE_BACK_MIN_PX,
useMobileSwipeBackOnElement
} from '@/lib/mobile-swipe-back'
import { preventRadixSheetCloseForPortaledOverlay } from '@/lib/sheet-dismiss-guard'
import { ChevronLeft } from 'lucide-react'
import { NavigationService } from '@/services/navigation.service'
@ -42,7 +47,6 @@ import { @@ -42,7 +47,6 @@ import {
useState
} from 'react'
import { useEventCallback } from '@/hooks/use-event-callback'
import { flushSync } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { KeyboardShortcutsHelpProvider } from '@/components/KeyboardShortcutsHelp'
import {
@ -1151,7 +1155,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1151,7 +1155,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}, [currentPrimaryPage])
const navigationCounterRef = useRef(0)
const goBackRef = useRef<() => void>(() => {})
const drawerOpenRef = useRef(drawerOpen)
const [mobilePrimarySwipeRoot, setMobilePrimarySwipeRoot] = useState<HTMLElement | null>(null)
useLayoutEffect(() => {
drawerOpenRef.current = drawerOpen
}, [drawerOpen])
const primaryPanelRefreshRef = useRef<(() => void) | null>(null)
const registerPrimaryPanelRefresh = useCallback((fn: (() => void) | null) => {
primaryPanelRefreshRef.current = fn
@ -1988,6 +1996,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1988,6 +1996,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
return
}
if (isCurrentPage(secondaryStackRef.current, url)) {
const top = secondaryStackRef.current[secondaryStackRef.current.length - 1]
if (isSmallScreen && top) {
window.history.pushState({ index: top.index, url }, '', url)
}
logger.component('PageManager', 'pushSecondaryPage skipped (already on stack)', { url })
return
}
@ -2032,8 +2044,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2032,8 +2044,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}
if (isCurrentPage(prevStack, url)) {
const top = prevStack[prevStack.length - 1]
if (isSmallScreen && top) {
window.history.pushState({ index: top.index, url }, '', url)
}
logger.component('PageManager', 'Page already exists, not scrolling')
// NEVER scroll to top - maintain scroll position
return prevStack
}
@ -2055,16 +2070,56 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2055,16 +2070,56 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
})
}
const restorePrimaryTabAfterSecondaryClose = () => {
const page = currentPrimaryPageRef.current
const savedFeedState = savedFeedStateRef.current.get(page)
if (savedFeedState?.tab) {
window.dispatchEvent(
new CustomEvent('restorePageTab', {
detail: { page, tab: savedFeedState.tab }
})
)
currentTabStateRef.current.set(page, savedFeedState.tab)
}
}
const hardCloseSecondaryPanel = () => {
if (secondaryStackRef.current.length === 0 && !drawerOpenRef.current) {
return
}
if (drawerOpenRef.current) setDrawerOpen(false)
setSinglePaneSheetOpen(false)
secondaryStackRef.current = []
queueMicrotask(() => {
setSecondaryStack([])
})
replaceHistoryWithPrimaryPageUrl(
currentPrimaryPageRef.current,
primaryPagePropsRef.current.get(currentPrimaryPageRef.current) as { spell?: string } | undefined
)
restorePrimaryTabAfterSecondaryClose()
}
const popSecondaryPage = () => {
const stackLen = secondaryStackRef.current.length
// Mobile / single-pane: one code path — drawer + stack share the same close behavior
if (isSmallScreen || panelMode === 'single') {
if (stackLen > 1) {
window.history.back()
} else {
hardCloseSecondaryPanel()
}
return
}
// In double-pane mode, never open drawer - just pop from stack
if (panelMode === 'double' && !isSmallScreen) {
if (stackLen === 1) {
flushSync(() => {
secondaryStackRef.current = []
queueMicrotask(() => {
setSecondaryStack([])
})
secondaryStackRef.current = []
replaceHistoryWithPrimaryPageUrl(
currentPrimaryPage,
primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined
@ -2095,83 +2150,51 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2095,83 +2150,51 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}
return
}
}
// Single-pane mode or mobile: check if drawer is open and stack is empty - close drawer instead
if (drawerOpen && stackLen === 0) {
// Close drawer and reveal the background page
setDrawerOpen(false)
return
}
const clearSecondaryPages = () => {
hardCloseSecondaryPanel()
}
// On mobile or single-pane: if stack has 1 item and drawer is open, close drawer and clear stack
if ((isSmallScreen || panelMode === 'single') && stackLen === 1 && drawerOpen) {
setDrawerOpen(false)
flushSync(() => {
setSecondaryStack([])
})
secondaryStackRef.current = []
replaceHistoryWithPrimaryPageUrl(
currentPrimaryPage,
primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined
)
const mobileSecondaryOpen = isSmallScreen && (drawerOpen || secondaryStack.length > 0)
useEffect(() => {
if (!mobileSecondaryOpen) return
const savedFeedState = savedFeedStateRef.current.get(currentPrimaryPage)
let grab: { x: number; y: number; pointerId: number } | null = null
if (savedFeedState?.tab) {
logger.info('PageManager: Mobile/Single-pane - Restoring tab state', { page: currentPrimaryPage, tab: savedFeedState.tab })
window.dispatchEvent(new CustomEvent('restorePageTab', {
detail: { page: currentPrimaryPage, tab: savedFeedState.tab }
}))
currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab)
}
return
const onPointerDown = (e: PointerEvent) => {
if (e.button !== 0 || e.clientX > MOBILE_SWIPE_BACK_EDGE_PX) return
grab = { x: e.clientX, y: e.clientY, pointerId: e.pointerId }
}
if (stackLen === 1) {
flushSync(() => {
setSecondaryStack([])
})
secondaryStackRef.current = []
replaceHistoryWithPrimaryPageUrl(
currentPrimaryPage,
primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined
)
const savedFeedState = savedFeedStateRef.current.get(currentPrimaryPage)
if (savedFeedState?.tab) {
logger.info('PageManager: Desktop - Restoring tab state', { page: currentPrimaryPage, tab: savedFeedState.tab })
window.dispatchEvent(new CustomEvent('restorePageTab', {
detail: { page: currentPrimaryPage, tab: savedFeedState.tab }
}))
currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab)
const onPointerUp = (e: PointerEvent) => {
if (!grab || grab.pointerId !== e.pointerId) return
const dx = e.clientX - grab.x
const dy = e.clientY - grab.y
grab = null
const ax = Math.abs(dx)
const ay = Math.abs(dy)
if (dx < MOBILE_SWIPE_BACK_MIN_PX || ax < ay * MOBILE_SWIPE_BACK_DOMINANCE) return
if (secondaryStackRef.current.length > 1) {
window.history.back()
} else {
hardCloseSecondaryPanel()
}
} else if (stackLen > 1) {
// Same as double-pane: let popstate shrink the stack so it matches history.
window.history.back()
} else {
replaceHistoryWithPrimaryPageUrl(
currentPrimaryPage,
primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined
)
}
}
const hardCloseSecondaryPanel = useCallback(() => {
if (drawerOpen) setDrawerOpen(false)
setSinglePaneSheetOpen(false)
setSecondaryStack((prev) => (prev.length ? [] : prev))
secondaryStackRef.current = []
const page = currentPrimaryPageRef.current
replaceHistoryWithPrimaryPageUrl(
page,
primaryPagePropsRef.current.get(page) as { spell?: string } | undefined
)
}, [drawerOpen])
const onPointerCancel = () => {
grab = null
}
const clearSecondaryPages = () => {
hardCloseSecondaryPanel()
}
document.addEventListener('pointerdown', onPointerDown, { capture: true })
document.addEventListener('pointerup', onPointerUp, { capture: true })
document.addEventListener('pointercancel', onPointerCancel, { capture: true })
return () => {
document.removeEventListener('pointerdown', onPointerDown, { capture: true })
document.removeEventListener('pointerup', onPointerUp, { capture: true })
document.removeEventListener('pointercancel', onPointerCancel, { capture: true })
}
}, [mobileSecondaryOpen])
useEffect(() => {
const shouldBeOpen =
@ -2312,7 +2335,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2312,7 +2335,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
setDrawerOpen(true)
return
}
popSecondaryPage()
hardCloseSecondaryPanel()
}}
noteId={drawerNoteId}
/>

71
src/components/Embedded/EmbeddedNote.tsx

@ -1,11 +1,6 @@ @@ -1,11 +1,6 @@
import { Skeleton } from '@/components/ui/skeleton'
import ExternalLink from '@/components/ExternalLink'
import {
FAST_READ_RELAY_URLS,
PROFILE_RELAY_URLS,
SEARCHABLE_RELAY_URLS,
ExtendedKind
} from '@/constants'
import { FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS, ExtendedKind } from '@/constants'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { LIVE_ACTIVITY_KINDS } from '@/lib/live-activities'
import { isCalendarEventKind } from '@/lib/calendar-event'
@ -19,6 +14,11 @@ import nip66Service from '@/services/nip66.service' @@ -19,6 +14,11 @@ import nip66Service from '@/services/nip66.service'
import { navigationEventStore } from '@/services/navigation-event-store'
import { useViewerInboxRelayUrls } from '@/hooks/useViewerInboxRelayUrls'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import {
getAggrAwareSearchRelayUrls,
syncViewerRelayStackNostrLandAggrEligible,
urlsForViewerNostrLandAggrEligibilitySync
} from '@/lib/nostr-land-relay-eligibility'
import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal'
import { useFavoriteRelays } from '@/providers/favorite-relays-context'
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
@ -234,6 +234,15 @@ function EmbeddedNoteFetched({ @@ -234,6 +234,15 @@ function EmbeddedNoteFetched({
.filter((url): url is string => Boolean(url)),
[favoriteRelays, blockedRelays]
)
useEffect(() => {
syncViewerRelayStackNostrLandAggrEligible(
urlsForViewerNostrLandAggrEligibilitySync({
favoriteRelayUrls: favoriteRelays,
relayListRead: inboxRelayUrls
})
)
}, [favoriteRelays, inboxRelayUrls])
const wideRelaysStatic = useMemo(
() =>
buildEmbedWideRelayUrlsStatic(
@ -303,27 +312,31 @@ function EmbeddedNoteFetched({ @@ -303,27 +312,31 @@ function EmbeddedNoteFetched({
}
const runParallelFetch = async () => {
const { fetchRelayOpts: opts } = embedFetchCtxRef.current
const { fetchRelayOpts: opts, wideRelaysStatic: wideUrls } = embedFetchCtxRef.current
const hex = hexEventIdFromNoteId(noteKey)
const isUsable = (e: Event) =>
!isEventDeletedRef.current(e) && !shouldDropEventOnIngest(e)
const chosen = await firstResolvedUsableEmbedEvent(
[
() => client.fetchEvent(noteKey, opts),
() =>
hex && /^[0-9a-f]{64}$/i.test(hex)
? indexedDb
.getEventFromPublicationStore(hex.toLowerCase())
.catch(() => undefined)
: Promise.resolve(undefined)
],
isUsable
)
if (cancelled) return
if (chosen) {
resolve(chosen)
try {
const chosen = await firstResolvedUsableEmbedEvent(
[
() => promiseWithTimeout(client.fetchEvent(noteKey, opts), 12_000),
() =>
hex && /^[0-9a-f]{64}$/i.test(hex)
? indexedDb
.getEventFromPublicationStore(hex.toLowerCase())
.catch(() => undefined)
: Promise.resolve(undefined),
() => runWidePass(wideUrls)
],
isUsable
)
if (cancelled) return
if (chosen) {
resolve(chosen)
}
} finally {
if (!cancelled) setIsFetching(false)
}
setIsFetching(false)
}
if (tryShortcuts()) {
@ -348,6 +361,7 @@ function EmbeddedNoteFetched({ @@ -348,6 +361,7 @@ function EmbeddedNoteFetched({
)
if (cancelled || !ev) return
resolve(ev)
if (!cancelled) setIsFetching(false)
})()
if (eventRef.current) {
@ -543,10 +557,10 @@ function buildEmbedWideRelayUrlsStatic( @@ -543,10 +557,10 @@ function buildEmbedWideRelayUrlsStatic(
source: 'fallback',
urls: preferPublicIndexRelaysFirst(
dedupeRelayUrls([
...getAggrAwareSearchRelayUrls(),
...relayHintsFromParent,
...viewerInboxRelayUrls,
...nip66Service.getSearchableRelayUrls(),
...SEARCHABLE_RELAY_URLS,
...FAST_READ_RELAY_URLS,
...PROFILE_RELAY_URLS,
...menuRelayUrls
@ -626,6 +640,15 @@ async function loadAsyncEmbedRelayHints(noteId: string, containingEvent?: Event) @@ -626,6 +640,15 @@ async function loadAsyncEmbedRelayHints(noteId: string, containingEvent?: Event)
return dedupeRelayUrls(hintRelays)
}
function promiseWithTimeout<T>(promise: Promise<T>, ms: number): Promise<T | undefined> {
return Promise.race([
promise.catch(() => undefined),
new Promise<undefined>((resolve) => {
setTimeout(() => resolve(undefined), ms)
})
])
}
/** Resolve as soon as any fetch path returns a usable event (do not wait for slow wide-relay fan-out). */
function firstResolvedUsableEmbedEvent(
tasks: Array<() => Promise<Event | undefined>>,

8
src/components/ParentNotePreview/index.tsx

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
import { Skeleton } from '@/components/ui/skeleton'
import { SEARCHABLE_RELAY_URLS } from '@/constants'
import { useFetchEvent } from '@/hooks'
import { getAggrAwareSearchRelayUrls } from '@/lib/nostr-land-relay-eligibility'
import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal'
import { cn } from '@/lib/utils'
import client from '@/services/client.service'
import { useTranslation } from 'react-i18next'
@ -42,7 +43,10 @@ export default function ParentNotePreview({ @@ -42,7 +43,10 @@ export default function ParentNotePreview({
setIsFetchingFallback(true)
try {
const foundEvent = await client.fetchEventWithExternalRelays(eventId, SEARCHABLE_RELAY_URLS)
const foundEvent = await client.fetchEventWithExternalRelays(
eventId,
sanitizeRelayUrlsForFetch(getAggrAwareSearchRelayUrls())
)
if (foundEvent) {
client.addEventToCache(foundEvent)
setFallbackEvent(foundEvent)

19
src/components/RelayIcon/index.tsx

@ -1,6 +1,10 @@ @@ -1,6 +1,10 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { useFetchRelayInfo } from '@/hooks'
import { getRelayIconOverrideSrc, relayUrlFingerprintColors } from '@/lib/relay-icon-source'
import {
getRelayIconFallbackGlyph,
getRelayIconOverrideSrc,
relayUrlFingerprintColors
} from '@/lib/relay-icon-source'
import { cn } from '@/lib/utils'
import type { TRelayInfo } from '@/types'
import { Server } from 'lucide-react'
@ -72,6 +76,7 @@ export default function RelayIcon({ @@ -72,6 +76,7 @@ export default function RelayIcon({
}, [url, relayInfo])
const fallbackColors = useMemo(() => relayUrlFingerprintColors(url), [url])
const fallbackGlyph = useMemo(() => getRelayIconFallbackGlyph(url), [url])
return (
<Avatar className={cn('w-6 h-6', className)}>
@ -86,7 +91,17 @@ export default function RelayIcon({ @@ -86,7 +91,17 @@ export default function RelayIcon({
className="bg-transparent"
style={{ backgroundColor: fallbackColors.background, color: fallbackColors.color }}
>
<Server size={iconSize} className="opacity-95" aria-hidden />
{fallbackGlyph ? (
<span
className="leading-none select-none"
style={{ fontSize: Math.max(12, iconSize + 4) }}
aria-hidden
>
{fallbackGlyph}
</span>
) : (
<Server size={iconSize} className="opacity-95" aria-hidden />
)}
</AvatarFallback>
</Avatar>
)

6
src/components/SearchBar/index.tsx

@ -4,7 +4,7 @@ import { toNote, toNoteList } from '@/lib/link' @@ -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 { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl } from '@/lib/url'
import { isKind10243HttpRelayTagUrl, isWebsocketUrl, normalizeAnyRelayUrl, normalizeHttpRelayUrl } from '@/lib/url'
import { normalizeToDTag } from '@/lib/search-parser'
import { cn } from '@/lib/utils'
import { useSmartNoteNavigation, useSmartHashtagNavigation } from '@/PageManager'
@ -56,8 +56,8 @@ const SearchBar = forwardRef< @@ -56,8 +56,8 @@ const SearchBar = forwardRef<
return undefined
}
try {
const n = normalizeAnyRelayUrl(input)
if (!n || (!isHttpRelayUrl(n) && !isWebsocketUrl(n))) return undefined
const n = normalizeAnyRelayUrl(input) || normalizeHttpRelayUrl(input)
if (!n || (!isWebsocketUrl(n) && !isKind10243HttpRelayTagUrl(n))) return undefined
return n
} catch {
return undefined

7
src/components/SessionRelaysTab/index.tsx

@ -4,7 +4,7 @@ import { @@ -4,7 +4,7 @@ import {
isRelayStrikeEntryActive,
type RelayStrikeDebugSnapshot
} from '@/lib/relay-strikes'
import { isHttpRelayUrl } from '@/lib/url'
import { isKind10243HttpRelayTagUrl } from '@/lib/url'
import { useTranslation } from 'react-i18next'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { RefreshCw, CheckCircle2, Zap, AlertTriangle } from 'lucide-react'
@ -145,16 +145,13 @@ export default function SessionRelaysTab() { @@ -145,16 +145,13 @@ export default function SessionRelaysTab() {
for (const tag of httpRelayListEvent.tags) {
if (tag[0] !== 'r' || !tag[1]) continue
const raw = tag[1].trim()
if (!isHttpRelayUrl(raw)) continue
if (!isKind10243HttpRelayTagUrl(raw)) continue
out.add(formatRelayAddress(raw).toLowerCase())
}
return out
}, [httpRelayListEvent])
const isHttpRelayEntry = (url: string): boolean => {
if (isHttpRelayUrl(url)) return true
const infoUrl = relayInfoByUrl[url]?.url
if (infoUrl && isHttpRelayUrl(infoUrl)) return true
return configuredHttpRelayAddresses.has(formatRelayAddress(url).toLowerCase())
}

9
src/features/feed/descriptor.ts

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { normalizeAnyRelayUrl } from '@/lib/url'
import { canonicalRelaySessionKey, normalizeAnyRelayUrl } from '@/lib/url'
import type { TFeedSubRequest, TSubRequestFilter } from '@/types'
import type { Filter } from 'nostr-tools'
@ -92,12 +92,7 @@ export function canonicalFeedFilter(filter: Omit<Filter, 'since' | 'until'> | TS @@ -92,12 +92,7 @@ export function canonicalFeedFilter(filter: Omit<Filter, 'since' | 'until'> | TS
export function canonicalRelayUrls(urls: readonly string[]): string[] {
return Array.from(
new Set(
urls
.map((u) => normalizeAnyRelayUrl(u) || u.trim())
.filter(Boolean)
.map((u) => u.toLowerCase())
)
new Set(urls.map((u) => canonicalRelaySessionKey(u)).filter(Boolean))
).sort((a, b) => a.localeCompare(b))
}

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

@ -11,7 +11,7 @@ import { @@ -11,7 +11,7 @@ import {
relayUrlsStripExtendedTagReqBlocked
} from '@/lib/relay-extended-tag-req-blocks'
import { isRelayBlockedByUser } from '@/lib/relay-blocked'
import { isLocalNetworkUrl, normalizeAnyRelayUrl } from '@/lib/url'
import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeHttpRelayUrl } from '@/lib/url'
import type { TSubRequestFilter } from '@/types'
export type FeedRelayOperation = 'read' | 'write' | 'publish-picker' | 'favorites-feed'
@ -92,16 +92,17 @@ export type FeedRelayPolicyResult = { @@ -92,16 +92,17 @@ export type FeedRelayPolicyResult = {
dropped: FeedRelayDrop[]
}
function canonicalRelayUrl(url: string | undefined | null): string {
return (normalizeAnyRelayUrl(url ?? '') || (url ?? '').trim()).toLowerCase()
function canonicalRelayUrl(url: string | undefined | null, layerSource?: FeedRelayLayerSource | string): string {
return normalizedRelayUrl(url ?? '', layerSource).toLowerCase()
}
function normalizedRelayUrl(url: string): string {
function normalizedRelayUrl(url: string, layerSource?: FeedRelayLayerSource | string): string {
if (layerSource === 'http-index') return normalizeHttpRelayUrl(url) || url.trim()
return normalizeAnyRelayUrl(url) || url.trim()
}
function normalizedSet(urls: readonly string[] | undefined): Set<string> {
return new Set((urls ?? []).map(canonicalRelayUrl).filter(Boolean))
return new Set((urls ?? []).map((u) => canonicalRelayUrl(u)).filter(Boolean))
}
function shouldApplySocialFilter(ctx: FeedRelayPolicyContext): boolean {
@ -172,8 +173,8 @@ export function applyFeedRelayPolicy( @@ -172,8 +173,8 @@ export function applyFeedRelayPolicy(
for (const layer of layers) {
for (const raw of layer.urls) {
const normalized = normalizedRelayUrl(raw)
const key = canonicalRelayUrl(normalized)
const normalized = normalizedRelayUrl(raw, layer.source)
const key = canonicalRelayUrl(normalized, layer.source)
if (!normalized || !key) {
addDrop(dropped, raw, layer.source, 'invalid')
continue

12
src/hooks/useFetchThreadContextEvent.tsx

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { SEARCHABLE_RELAY_URLS, THREAD_CONTEXT_EVENT_FETCH_GLOBAL_TIMEOUT_MS } from '@/constants'
import { THREAD_CONTEXT_EVENT_FETCH_GLOBAL_TIMEOUT_MS } from '@/constants'
import { getAggrAwareSearchRelayUrls } from '@/lib/nostr-land-relay-eligibility'
import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
@ -124,13 +125,10 @@ export function useFetchThreadContextEvent( @@ -124,13 +125,10 @@ export function useFetchThreadContextEvent(
})
])
if (
!fetchedEvent &&
!searchableAttemptedRef.current &&
SEARCHABLE_RELAY_URLS.length > 0
) {
const aggrAwareSearch = getAggrAwareSearchRelayUrls()
if (!fetchedEvent && !searchableAttemptedRef.current && aggrAwareSearch.length > 0) {
searchableAttemptedRef.current = true
const searchable = sanitizeRelayUrlsForFetch([...SEARCHABLE_RELAY_URLS])
const searchable = sanitizeRelayUrlsForFetch(aggrAwareSearch)
fetchedEvent = await Promise.race([
client.fetchEventWithExternalRelays(eventId, searchable),
new Promise<undefined>((resolve) => {

3
src/layouts/SecondaryPageLayout/index.tsx

@ -44,7 +44,8 @@ const SecondaryPageLayout = forwardRef( @@ -44,7 +44,8 @@ const SecondaryPageLayout = forwardRef(
const { isSmallScreen } = useScreenSize()
const { currentIndex, pop } = useSecondaryPage()
const [mobileSwipeRoot, setMobileSwipeRoot] = useState<HTMLElement | null>(null)
const mobileSwipeActive = isSmallScreen && currentIndex === index
const mobileSwipeActive =
isSmallScreen && (index === undefined || currentIndex === index)
useMobileSwipeBackOnElement(mobileSwipeActive ? mobileSwipeRoot : null, pop, {
enabled: mobileSwipeActive
})

11
src/lib/event-metadata.ts

@ -7,7 +7,14 @@ import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightnin @@ -7,7 +7,14 @@ import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightnin
import { formatPubkey, pubkeyToNpub } from './pubkey'
import { generateBech32IdFromATag, generateBech32IdFromETag, getImetaInfoFromImetaTag, tagNameEquals } from './tag'
import { isRelayBlockedByUser } from '@/lib/relay-blocked'
import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl, normalizeHttpRelayUrl, normalizeHttpUrl, normalizeUrl } from './url'
import {
isKind10243HttpRelayTagUrl,
isWebsocketUrl,
normalizeAnyRelayUrl,
normalizeHttpRelayUrl,
normalizeHttpUrl,
normalizeUrl
} from './url'
import { isTorBrowser } from './utils'
import logger from '@/lib/logger'
import { buildPaytoUri } from '@/lib/payto'
@ -168,7 +175,7 @@ export function getHttpRelayListFromEvent(event?: Event | null, blockedRelays?: @@ -168,7 +175,7 @@ export function getHttpRelayListFromEvent(event?: Event | null, blockedRelays?:
const torBrowserDetected = isTorBrowser()
event.tags.filter(tagNameEquals('r')).forEach(([, url, type]) => {
if (!url || typeof url !== 'string' || url.trim() === '') return
if (!isHttpRelayUrl(url)) return
if (!isKind10243HttpRelayTagUrl(url)) return
const normalizedUrl = normalizeHttpRelayUrl(url)
if (!normalizedUrl) return

3
src/lib/favorites-feed-relays.ts

@ -19,6 +19,7 @@ import { @@ -19,6 +19,7 @@ import {
import { feedRelayPolicyUrls, type FeedRelayLayer } from '@/features/feed/relay-policy'
import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize'
import { relaySessionStrikes } from '@/lib/relay-strikes'
import { prependAggrNostrLandIfViewerEligible } from '@/lib/nostr-land-relay-eligibility'
import { profileFetchRelayUrlsWithoutFastReadLayer } from '@/lib/viewer-relay-defaults'
function isBlockedRelay(url: string, blockedRelays: string[]): boolean {
@ -39,7 +40,7 @@ export function userReadRelaysWithHttp( @@ -39,7 +40,7 @@ export function userReadRelaysWithHttp(
): string[] {
const http = relayList?.httpRead ?? []
const read = relayList?.read ?? []
return dedupeNormalizeRelayUrlsOrdered([...http, ...read])
return prependAggrNostrLandIfViewerEligible(dedupeNormalizeRelayUrlsOrdered([...http, ...read]))
}
export function getFavoritesFeedRelayUrls(

5
src/lib/feed-full-search-relays.ts

@ -1,10 +1,10 @@ @@ -1,10 +1,10 @@
import { SEARCHABLE_RELAY_URLS } from '@/constants'
import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls'
import {
getFavoritesFeedRelayUrls,
mergeRelayUrlLayers
} from '@/lib/favorites-feed-relays'
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder'
import { getAggrAwareSearchRelayUrls } from '@/lib/nostr-land-relay-eligibility'
import { normalizeUrl } from '@/lib/url'
/**
@ -23,8 +23,7 @@ export async function buildFeedFullSearchRelayUrls(options: { @@ -23,8 +23,7 @@ export async function buildFeedFullSearchRelayUrls(options: {
const blocked = blockedRelays ?? []
const layers: string[][] = []
const searchable = SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean)
layers.push(searchable)
layers.push(getAggrAwareSearchRelayUrls().map((u) => normalizeUrl(u) || u).filter(Boolean))
layers.push(getFavoritesFeedRelayUrls(favoriteRelays ?? [], blocked))

24
src/lib/home-feed-relays.ts

@ -2,33 +2,19 @@ import { MAX_REQ_RELAY_URLS } from '@/constants' @@ -2,33 +2,19 @@ import { MAX_REQ_RELAY_URLS } from '@/constants'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { getHttpRelayListFromEvent, getRelayListReadFromEventNoFastFallback } from '@/lib/event-metadata'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr'
import { normalizeAnyRelayUrl } from '@/lib/url'
import { relayUrlIsAggrNostrLand } from '@/lib/nostr-land-relay-eligibility'
import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import type { Event } from 'nostr-tools'
function relayUrlIsNostrLandAggr(url: string): boolean {
const raw = url.trim()
if (!raw) return false
const normalized = (normalizeAnyRelayUrl(raw) || raw).toLowerCase()
const aggrCanon = (normalizeAnyRelayUrl(AGGR_NOSTR_LAND_WSS) || AGGR_NOSTR_LAND_WSS).toLowerCase()
if (normalized === aggrCanon) return true
try {
const u = new URL(normalized)
return u.hostname.toLowerCase() === 'aggr.nostr.land'
} catch {
return /^wss:\/\/aggr\.nostr\.land\/?$/i.test(normalized)
}
}
/** Drop nostr.land aggregate from REQ stacks where it must not appear (e.g. home feeds). */
export function stripNostrLandAggrFromRelayUrls(urls: readonly string[]): string[] {
return urls.filter((url) => !relayUrlIsNostrLandAggr(url))
return urls.filter((url) => !relayUrlIsAggrNostrLand(url))
}
/**
* Home Lieblings-Relays feed must never open timeline REQs to nostr.lands aggregate relay (reserved for
* threads / profiles / spells). Strips aggr from every shard after mapping, including trailing-slash variants.
* Home timeline REQs (Notes, Replies, and Gallery tabs on `home-all-favorites`) must never hit aggr only
* favorites + Wisp trending (+ widened read layers on Replies/Gallery without aggr). Side-panel threads,
* reply blurbs, backlinks, embeds, and profiles use {@link feedRelayPolicyUrls} / comprehensive lists instead.
*/
export function stripNostrLandAggrFromTimelineSubRequests<T extends { urls: string[] }>(
feedSubscriptionKey: string | undefined,

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

@ -174,7 +174,8 @@ function handleFilterTransportFailure(endpoint: string, err?: unknown): void { @@ -174,7 +174,8 @@ function handleFilterTransportFailure(endpoint: string, err?: unknown): void {
maybeLogDevIndexRelayUnreachableHint()
return
}
warnIndexRelayHttpThrottled(endpoint, '[IndexRelayHttp] filter request error', {
// CORS / offline index relays are optional; strikes will skip after repeated failures.
logger.debug('[IndexRelayHttp] filter transport failure', {
endpoint,
error: err ?? 'unreachable'
})

54
src/lib/nostr-land-relay-eligibility.test.ts

@ -0,0 +1,54 @@ @@ -0,0 +1,54 @@
import { describe, expect, it, afterEach } from 'vitest'
import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr'
import {
filterAggrNostrLandUnlessViewerEligible,
getAggrAwareSearchRelayUrls,
getViewerNostrLandAggrSearchRelayUrls,
prependAggrNostrLandIfViewerEligible,
relayUrlsIncludeCanonicalNostrLandRelay,
syncViewerRelayStackNostrLandAggrEligible
} from '@/lib/nostr-land-relay-eligibility'
afterEach(() => {
syncViewerRelayStackNostrLandAggrEligible([])
})
describe('nostr.land aggr eligibility', () => {
it('enables aggr only when wss://nostr.land is on favorites or relay lists', () => {
expect(relayUrlsIncludeCanonicalNostrLandRelay(['wss://nostr.land/'])).toBe(true)
expect(relayUrlsIncludeCanonicalNostrLandRelay(['wss://aggr.nostr.land/'])).toBe(false)
expect(relayUrlsIncludeCanonicalNostrLandRelay(['wss://hist.nostr.land/'])).toBe(false)
syncViewerRelayStackNostrLandAggrEligible(['wss://nostr.land/'])
expect(getViewerNostrLandAggrSearchRelayUrls()[0]).toMatch(/^wss:\/\/aggr\.nostr\.land\/?$/)
expect(prependAggrNostrLandIfViewerEligible(['wss://inbox.example/'])[0]).toMatch(
/^wss:\/\/aggr\.nostr\.land\/?$/
)
syncViewerRelayStackNostrLandAggrEligible(['wss://relay.example/'])
expect(getViewerNostrLandAggrSearchRelayUrls()).toEqual([])
expect(prependAggrNostrLandIfViewerEligible(['wss://inbox.example/'])).toEqual([
'wss://inbox.example/'
])
})
it('getAggrAwareSearchRelayUrls prepends aggr when eligible', () => {
syncViewerRelayStackNostrLandAggrEligible(['wss://nostr.land/'])
const urls = getAggrAwareSearchRelayUrls()
expect(urls[0]).toMatch(/^wss:\/\/aggr\.nostr\.land\/?$/)
expect(urls.length).toBeGreaterThan(1)
syncViewerRelayStackNostrLandAggrEligible([])
expect(getAggrAwareSearchRelayUrls().some((u) => /aggr\.nostr\.land/i.test(u))).toBe(false)
})
it('strips aggr from fetch stacks when viewer does not list nostr.land', () => {
syncViewerRelayStackNostrLandAggrEligible([])
expect(
filterAggrNostrLandUnlessViewerEligible([
'wss://relay.example/',
AGGR_NOSTR_LAND_WSS
])
).toEqual(['wss://relay.example/'])
})
})

90
src/lib/nostr-land-relay-eligibility.ts

@ -1,17 +1,36 @@ @@ -1,17 +1,36 @@
import { SEARCHABLE_RELAY_URLS } from '@/constants'
import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr'
import { normalizeAnyRelayUrl } from '@/lib/url'
/**
* True when any URLs host is `nostr.land` (e.g. `wss://nostr.land`, `wss://aggr.nostr.land`).
* Used to decide whether read fetches should prepend {@link AGGR_NOSTR_LAND_WSS} (home OP / Replies / Gallery
* never prepend aggr via {@link FeedProvider}; side-panel threads, profiles, spells, and other reads still use it).
* True when the URL is `wss://aggr.nostr.land` (aggregator read/search endpoint).
*/
export function relayUrlsMentionNostrLandDomain(urls: readonly string[]): boolean {
export function relayUrlIsAggrNostrLand(url: string): boolean {
const raw = url.trim()
if (!raw) return false
const normalized = (normalizeAnyRelayUrl(raw) || raw).toLowerCase()
const aggrCanon = (normalizeAnyRelayUrl(AGGR_NOSTR_LAND_WSS) || AGGR_NOSTR_LAND_WSS).toLowerCase()
if (normalized === aggrCanon) return true
try {
const u = new URL(normalized.replace(/^ws:\/\//i, 'http://').replace(/^wss:\/\//i, 'https://'))
return u.hostname.toLowerCase() === 'aggr.nostr.land'
} catch {
return /^wss:\/\/aggr\.nostr\.land\/?$/i.test(normalized)
}
}
/**
* True when any URL is the canonical nostr.land **inbox** relay (`wss://nostr.land`), i.e. host `nostr.land`
* exactly not `aggr.nostr.land`, `hist.nostr.land`, or other subdomains.
*/
export function relayUrlsIncludeCanonicalNostrLandRelay(urls: readonly string[]): boolean {
return urls.some((url) => {
const normalized = normalizeAnyRelayUrl(url) || String(url).trim()
if (!normalized) return false
try {
const parsed = new URL(normalized.replace(/^ws:\/\//i, 'http://').replace(/^wss:\/\//i, 'https://'))
const parsed = new URL(
normalized.replace(/^ws:\/\//i, 'http://').replace(/^wss:\/\//i, 'https://')
)
return parsed.hostname.toLowerCase() === 'nostr.land'
} catch {
return false
@ -19,14 +38,19 @@ export function relayUrlsMentionNostrLandDomain(urls: readonly string[]): boolea @@ -19,14 +38,19 @@ export function relayUrlsMentionNostrLandDomain(urls: readonly string[]): boolea
})
}
/** @deprecated Use {@link relayUrlsIncludeCanonicalNostrLandRelay}. */
export function relayUrlsMentionNostrLandDomain(urls: readonly string[]): boolean {
return relayUrlsIncludeCanonicalNostrLandRelay(urls)
}
let viewerStackMentionsNostrLand = false
/**
* Synced from the logged-in viewers relay stack (favorites, relay sets, NIP-65, cache, HTTP lists).
* Service-layer reads use {@link getViewerRelayStackNostrLandAggrEligible} when building REQ targets.
* Synced from the logged-in viewers favorites and NIP-65 / cache / HTTP relay lists.
* When true, {@link AGGR_NOSTR_LAND_WSS} is treated as a search relay and inbox-tier read relay.
*/
export function syncViewerRelayStackNostrLandAggrEligible(urls: readonly string[]): boolean {
viewerStackMentionsNostrLand = relayUrlsMentionNostrLandDomain(urls)
viewerStackMentionsNostrLand = relayUrlsIncludeCanonicalNostrLandRelay(urls)
return viewerStackMentionsNostrLand
}
@ -34,6 +58,56 @@ export function getViewerRelayStackNostrLandAggrEligible(): boolean { @@ -34,6 +58,56 @@ export function getViewerRelayStackNostrLandAggrEligible(): boolean {
return viewerStackMentionsNostrLand
}
/** Aggr URL to merge into NIP-50 / searchable relay sets when the viewer lists `wss://nostr.land`. */
export function getViewerNostrLandAggrSearchRelayUrls(): string[] {
if (!viewerStackMentionsNostrLand) return []
const n = normalizeAnyRelayUrl(AGGR_NOSTR_LAND_WSS) || AGGR_NOSTR_LAND_WSS
return n ? [n] : []
}
/**
* Search / wide-id fetch relays: aggr first when eligible, then {@link SEARCHABLE_RELAY_URLS}.
* Use for reply-to blurbs, thread parent/root fallback, embed wide pass, etc. not home timeline OP REQs.
*/
export function getAggrAwareSearchRelayUrls(): string[] {
const seen = new Set<string>()
const out: string[] = []
const add = (raw: string) => {
const n = normalizeAnyRelayUrl(raw) || raw.trim()
if (!n) return
const k = n.toLowerCase()
if (seen.has(k)) return
seen.add(k)
out.push(n)
}
for (const u of getViewerNostrLandAggrSearchRelayUrls()) add(u)
for (const u of SEARCHABLE_RELAY_URLS) add(u)
return out
}
/** Drop aggr unless the viewer has `wss://nostr.land` on favorites or relay lists. */
export function filterAggrNostrLandUnlessViewerEligible(urls: readonly string[]): string[] {
if (viewerStackMentionsNostrLand) return [...urls]
return urls.filter((u) => !relayUrlIsAggrNostrLand(u))
}
/** URLs to pass to {@link syncViewerRelayStackNostrLandAggrEligible} (favorites + NIP-65 + cache + HTTP lists). */
export function urlsForViewerNostrLandAggrEligibilitySync(options: {
favoriteRelayUrls?: readonly string[]
relayListRead?: readonly string[]
relayListWrite?: readonly string[]
cacheRelayRead?: readonly string[]
httpRelayRead?: readonly string[]
}): string[] {
return [
...(options.favoriteRelayUrls ?? []),
...(options.relayListRead ?? []),
...(options.relayListWrite ?? []),
...(options.cacheRelayRead ?? []),
...(options.httpRelayRead ?? [])
]
}
/** Deduped prepend of aggr when the viewer opted into nostr.land relays (see sync…). */
export function prependAggrNostrLandIfViewerEligible(relayUrls: readonly string[]): string[] {
if (!viewerStackMentionsNostrLand) return [...relayUrls]

21
src/lib/read-only-relay-personal.test.ts

@ -1,15 +1,22 @@ @@ -1,15 +1,22 @@
import { describe, expect, it, beforeEach } from 'vitest'
import { describe, expect, it, beforeEach, afterEach } from 'vitest'
import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr'
import { syncViewerRelayStackNostrLandAggrEligible } from '@/lib/nostr-land-relay-eligibility'
import {
buildPersonalRelayKeySet,
filterReadOnlyRelaysUnlessPersonal,
isPersonalListRequiredReadOnlyRelay,
sanitizeRelayUrlsForFetch,
setViewerPersonalRelayKeys
} from './read-only-relay-personal'
describe('read-only-relay-personal', () => {
beforeEach(() => {
setViewerPersonalRelayKeys(new Set())
syncViewerRelayStackNostrLandAggrEligible([])
})
afterEach(() => {
syncViewerRelayStackNostrLandAggrEligible([])
})
it('requires personal list only for filter.nostr.wine', () => {
@ -19,7 +26,7 @@ describe('read-only-relay-personal', () => { @@ -19,7 +26,7 @@ describe('read-only-relay-personal', () => {
expect(isPersonalListRequiredReadOnlyRelay('wss://search.nos.today/')).toBe(false)
})
it('strips unlisted filter.nostr.wine but keeps aggr and search indexers', () => {
it('strips unlisted filter.nostr.wine but keeps search indexers; aggr only when nostr.land is listed', () => {
const urls = [
'wss://relay.damus.io/',
'wss://filter.nostr.wine/',
@ -31,6 +38,16 @@ describe('read-only-relay-personal', () => { @@ -31,6 +38,16 @@ describe('read-only-relay-personal', () => {
AGGR_NOSTR_LAND_WSS,
'wss://search.nos.today/'
])
expect(sanitizeRelayUrlsForFetch(urls)).toEqual([
'wss://relay.damus.io/',
'wss://search.nos.today/'
])
syncViewerRelayStackNostrLandAggrEligible(['wss://nostr.land/'])
expect(sanitizeRelayUrlsForFetch(urls).map((u) => u.replace(/\/$/, ''))).toEqual([
'wss://relay.damus.io',
'wss://aggr.nostr.land',
'wss://search.nos.today'
])
})
it('keeps filter.nostr.wine when on the viewer personal list', () => {

5
src/lib/read-only-relay-personal.ts

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { READ_ONLY_PERSONAL_LIST_REQUIRED_RELAY_URLS } from '@/constants'
import { filterAggrNostrLandUnlessViewerEligible } from '@/lib/nostr-land-relay-eligibility'
import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize'
import { normalizeAnyRelayUrl } from '@/lib/url'
@ -81,5 +82,7 @@ export function sanitizeRelayUrlsForFetch( @@ -81,5 +82,7 @@ export function sanitizeRelayUrlsForFetch(
const key = relayUrlKey(u)
return key.length > 0 && keys.has(key)
})
return filterReadOnlyRelaysUnlessPersonal(withoutThirdPartyLocals, keys)
return filterAggrNostrLandUnlessViewerEligible(
filterReadOnlyRelaysUnlessPersonal(withoutThirdPartyLocals, keys)
)
}

14
src/lib/relay-icon-source.test.ts

@ -0,0 +1,14 @@ @@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { getRelayIconFallbackGlyph, getRelayIconOverrideSrc } from '@/lib/relay-icon-source'
describe('relay icon branding', () => {
it('uses favicon override for sovbit hosts', () => {
expect(getRelayIconOverrideSrc('wss://nostr.sovbit.host/')).toContain('nostr.sovbit.host')
expect(getRelayIconOverrideSrc('wss://freelay.sovbit.host/')).toContain('freelay.sovbit.host')
})
it('uses purple circle fallback glyph for purplepag.es', () => {
expect(getRelayIconFallbackGlyph('wss://purplepag.es/')).toBe('🟣')
expect(getRelayIconOverrideSrc('wss://purplepag.es/')).toBeUndefined()
})
})

11
src/lib/relay-icon-source.ts

@ -53,6 +53,17 @@ export function getRelayIconOverrideSrc(url: string | undefined): string | undef @@ -53,6 +53,17 @@ export function getRelayIconOverrideSrc(url: string | undefined): string | undef
return undefined
}
/**
* Unicode fallback when NIP-11 / favicon is missing or failed to load (shown in {@link RelayIcon}).
* Sovbit hosts use {@link getRelayIconOverrideSrc} favicons instead; purplepag uses the purple circle.
*/
export function getRelayIconFallbackGlyph(url: string | undefined): string | undefined {
const host = parseRelayHostname(url ?? '')
if (!host) return undefined
if (host === 'purplepag.es') return '🟣'
return undefined
}
/** FNV-1a-ish fingerprint → HSL for a per-relay fallback swatch (no network). */
export function relayUrlFingerprintColors(url: string | undefined): {
background: string

37
src/lib/relay-list-builder.ts

@ -13,7 +13,14 @@ import { FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS, SEARCHABLE_RELAY_URLS } from @@ -13,7 +13,14 @@ import { FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS, SEARCHABLE_RELAY_URLS } from
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { mergeRelayUrlLayers, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize'
import { isHttpRelayUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import {
canonicalRelaySessionKey,
httpIndexRelayBasesInUrlBatch,
isKind10243HttpRelayTagUrl,
normalizeAnyRelayUrl,
normalizeHttpRelayUrl,
normalizeUrl
} from '@/lib/url'
import { buildPersonalRelayKeySet, sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal'
import { getCacheRelayUrls } from './private-relays'
import { defaultFavoriteRelaysForViewer, viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
@ -25,7 +32,7 @@ import type { Event } from 'nostr-tools' @@ -25,7 +32,7 @@ import type { Event } from 'nostr-tools'
export const AUTHOR_NIP65_RELAY_CAP = 2
function relayKey(url: string): string {
return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase()
return canonicalRelaySessionKey(url)
}
/**
@ -65,7 +72,7 @@ function dedupeNormalizedRelayUrls(urls: string[]): string[] { @@ -65,7 +72,7 @@ function dedupeNormalizedRelayUrls(urls: string[]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const u of urls) {
if (isHttpRelayUrl(u)) continue
if (isKind10243HttpRelayTagUrl(u)) continue
const n = normalizeAnyRelayUrl(u) || u.trim()
if (!n || seen.has(n)) continue
seen.add(n)
@ -166,8 +173,8 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio @@ -166,8 +173,8 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
const addRelay = (url: string | undefined) => {
if (!url) return
// This builder feeds WebSocket REQ/publish lists; keep HTTP relays separate.
if (isHttpRelayUrl(url)) return
// This builder feeds WebSocket REQ/publish lists; kind 10243 HTTP index relays use addHttpRelay.
if (isKind10243HttpRelayTagUrl(url)) return
const normalized = normalizeAnyRelayUrl(url)
if (!normalized) return
// Filter blocked (case-insensitive comparison)
@ -182,8 +189,8 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio @@ -182,8 +189,8 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
}
const addHttpRelay = (url: string | undefined) => {
if (!url || !isHttpRelayUrl(url)) return
const normalized = normalizeAnyRelayUrl(url) || url.trim()
if (!url) return
const normalized = normalizeHttpRelayUrl(url)
if (!normalized || normalizedBlocked.has(normalized.toLowerCase())) return
if (httpRelayUrls.some((u) => relayKey(u) === relayKey(normalized))) return
httpRelayUrls.push(normalized)
@ -424,8 +431,18 @@ export async function buildProfileAndUserRelayList( @@ -424,8 +431,18 @@ export async function buildProfileAndUserRelayList(
includeViewerHttpIndexRelays: true,
blockedRelays
})
const httpPart = userStack.filter((u) => isHttpRelayUrl(u))
const wsPart = userStack.filter((u) => !isHttpRelayUrl(u))
let httpBases: string[] = []
try {
const rl = await client.peekRelayListFromStorage(userPubkey)
httpBases = [...(rl?.httpRead ?? []), ...(rl?.httpWrite ?? [])]
.map((u) => normalizeHttpRelayUrl(u) || u)
.filter(Boolean)
} catch {
httpBases = []
}
const httpPart = httpIndexRelayBasesInUrlBatch(userStack, httpBases)
const httpKeys = new Set(httpPart.map((u) => relayKey(u)))
const wsPart = userStack.filter((u) => !httpKeys.has(relayKey(u)))
const seen = new Set<string>()
const mergedWs: string[] = []
for (const u of [...profileWs, ...wsPart]) {
@ -545,7 +562,7 @@ export async function buildPollResultsReadRelayUrls(options: { @@ -545,7 +562,7 @@ export async function buildPollResultsReadRelayUrls(options: {
const pushLayer = (urls: string[]) => {
for (const raw of urls) {
if (isHttpRelayUrl(raw)) continue
if (isKind10243HttpRelayTagUrl(raw)) continue
const normalized = normalizeUrl(raw) || raw?.trim()
if (!normalized || normalizedBlocked.has(normalized.toLowerCase())) continue
if (seenNorm.has(normalized)) continue

30
src/lib/relay-list-sanitize.ts

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { isHttpRelayUrl, isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeHttpRelayUrl, normalizeUrl } from '@/lib/url'
import type { TMailboxRelay, TMailboxRelayScope, TRelayList } from '@/types'
/** True if this URL is not loopback / LAN (safe to open from another user's browser as a REQ target). */
@ -44,17 +44,21 @@ export function stripMailboxLocalUrlsForRemoteViewers(list: { @@ -44,17 +44,21 @@ export function stripMailboxLocalUrlsForRemoteViewers(list: {
* Still use when merging **another user's** 10002 so we never open their LAN relays.
*/
export function stripLocalNetworkRelaysFromRelayList(list: TRelayList): TRelayList {
const keepUrl = (u: string): boolean => {
const n = isHttpRelayUrl(u) ? normalizeAnyRelayUrl(u) || u : normalizeUrl(u) || u
return Boolean(n && !isLocalNetworkUrl(isHttpRelayUrl(u) ? u : n))
const keepWsUrl = (u: string): boolean => {
const n = normalizeUrl(u) || u
return Boolean(n && !isLocalNetworkUrl(n))
}
const keepHttpIndexUrl = (u: string): boolean => {
const n = normalizeHttpRelayUrl(u) || u
return Boolean(n && !isLocalNetworkUrl(n))
}
return {
write: list.write.filter(keepUrl),
read: list.read.filter(keepUrl),
originalRelays: list.originalRelays.filter((r) => keepUrl(r.url)),
httpWrite: (list.httpWrite ?? []).filter(keepUrl),
httpRead: (list.httpRead ?? []).filter(keepUrl),
httpOriginalRelays: (list.httpOriginalRelays ?? []).filter((r) => keepUrl(r.url))
write: list.write.filter(keepWsUrl),
read: list.read.filter(keepWsUrl),
originalRelays: list.originalRelays.filter((r) => keepWsUrl(r.url)),
httpWrite: (list.httpWrite ?? []).filter(keepHttpIndexUrl),
httpRead: (list.httpRead ?? []).filter(keepHttpIndexUrl),
httpOriginalRelays: (list.httpOriginalRelays ?? []).filter((r) => keepHttpIndexUrl(r.url))
}
}
@ -66,7 +70,7 @@ export function stripLocalNetworkRelaysForWssReq(urls: readonly string[]): strin @@ -66,7 +70,7 @@ export function stripLocalNetworkRelaysForWssReq(urls: readonly string[]): strin
const seen = new Set<string>()
const out: string[] = []
for (const raw of urls) {
if (isHttpRelayUrl(raw)) continue
if (/^https?:\/\//i.test(raw.trim())) continue
const n = normalizeAnyRelayUrl(raw) || raw.trim()
if (!n || isLocalNetworkUrl(n)) continue
const key = (normalizeUrl(n) || n).toLowerCase()
@ -80,7 +84,9 @@ export function stripLocalNetworkRelaysForWssReq(urls: readonly string[]): strin @@ -80,7 +84,9 @@ export function stripLocalNetworkRelaysForWssReq(urls: readonly string[]): strin
const normRelayKey = (u: string): string => {
const t = typeof u === 'string' ? u.trim() : ''
if (!t) return ''
return (isHttpRelayUrl(t) ? normalizeAnyRelayUrl(t) : normalizeUrl(t)) || t
if (/^wss?:\/\//i.test(t)) return normalizeUrl(t) || t
if (/^https?:\/\//i.test(t)) return normalizeHttpRelayUrl(t) || t
return normalizeUrl(t) || normalizeHttpRelayUrl(t) || t
}
/**

11
src/lib/relay-strikes.ts

@ -7,7 +7,7 @@ import { @@ -7,7 +7,7 @@ import {
import type { Event } from 'nostr-tools'
import { getRelayListFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger'
import { canonicalRelaySessionKey, isHttpRelayUrl } from '@/lib/url'
import { canonicalRelaySessionKey, httpIndexRelayBasesInUrlBatch } from '@/lib/url'
import type { RelayOpTerminalRow } from '@/services/relay-operation-log.service'
/** Conservative: 5 read/publish failures → skip until this many ms after last qualifying failure. */
@ -189,7 +189,7 @@ class RelaySessionStrikes { @@ -189,7 +189,7 @@ class RelaySessionStrikes {
e.readFailures += 1
if (e.readFailures >= STRIKE_FAILURES_THRESHOLD) {
e.readStrikeSkipUntil = Math.max(e.readStrikeSkipUntil, now + STRIKE_COOLDOWN_MS)
logger.warn('[RelayStrikes] read path strike skip', { key, readFailures: e.readFailures })
logger.debug('[RelayStrikes] read path strike skip', { key, readFailures: e.readFailures })
}
}
@ -301,9 +301,10 @@ class RelaySessionStrikes { @@ -301,9 +301,10 @@ class RelaySessionStrikes {
return out.length > 0 ? out : [...urls]
}
filterReadHttpUrls(urls: readonly string[]): string[] {
const ws = urls.filter((u) => !isHttpRelayUrl(u))
const http = urls.filter((u) => isHttpRelayUrl(u))
filterReadHttpUrls(urls: readonly string[], httpIndexBases: readonly string[] = []): string[] {
const http = httpIndexRelayBasesInUrlBatch(urls, httpIndexBases)
const httpKeys = new Set(http.map((u) => canonicalRelaySessionKey(u)))
const ws = urls.filter((u) => !httpKeys.has(canonicalRelaySessionKey(u)))
const singleWsRelay = ws.length <= 1
const wsOut = singleWsRelay ? [...ws] : ws.filter((u) => !this.isReadHttpSkipped(u))
const httpOut = http.filter((u) => !this.isReadHttpSkipped(u))

29
src/lib/relay-url-normalize.test.ts

@ -1,20 +1,19 @@ @@ -1,20 +1,19 @@
import { describe, expect, it } from 'vitest'
import {
canonicalRelaySessionKey,
httpIndexRelayBasesInUrlBatch,
normalizeAnyRelayUrl,
normalizeHttpRelayUrl,
normalizeUrl
} from '@/lib/url'
describe('relay URL normalization', () => {
it('keeps https index relays as https (no wss conversion)', () => {
const https = normalizeAnyRelayUrl('https://mercury-relay.imwald.eu/')
expect(https).toMatch(/^https:\/\/mercury-relay\.imwald\.eu\/?$/)
expect(https.startsWith('wss://')).toBe(false)
it('does not treat arbitrary https URLs as WebSocket relays', () => {
expect(normalizeAnyRelayUrl('https://mercury-relay.imwald.eu/')).toBe('')
expect(normalizeUrl('https://mercury-relay.imwald.eu/')).toBe('')
expect(normalizeHttpRelayUrl('https://mercury-relay.imwald.eu/')).toMatch(
/^https:\/\/mercury-relay\.imwald\.eu\/?$/
)
expect(normalizeUrl('https://mercury-relay.imwald.eu/')).toBe('')
})
it('keeps wss relays as wss', () => {
@ -28,6 +27,26 @@ describe('relay URL normalization', () => { @@ -28,6 +27,26 @@ describe('relay URL normalization', () => {
expect(normalizeAnyRelayUrl('nostr.land')).toBe('')
})
it('only treats configured kind 10243 bases as HTTP index in a URL batch', () => {
const batch = [
'wss://nostr.land/',
'https://mercury-relay.imwald.eu/',
'https://profile-website.example/'
]
const configured = ['https://mercury-relay.imwald.eu/']
expect(httpIndexRelayBasesInUrlBatch(batch, configured)).toEqual([
'https://mercury-relay.imwald.eu/'
])
expect(httpIndexRelayBasesInUrlBatch(batch, [])).toEqual([])
})
it('canonicalRelaySessionKey routes by scheme without cross-normalizing', () => {
expect(canonicalRelaySessionKey('wss://nostr.land/')).toMatch(/^wss:\/\/nostr\.land/)
expect(canonicalRelaySessionKey('https://mercury-relay.imwald.eu/')).toMatch(
/^https:\/\/mercury-relay\.imwald\.eu/
)
})
it('does not alias https session keys to wss', () => {
const https = canonicalRelaySessionKey('https://mercury-relay.imwald.eu/')
const wss = canonicalRelaySessionKey('wss://mercury-relay.imwald.eu/')

109
src/lib/url.ts

@ -16,12 +16,28 @@ export function isWebsocketUrl(url: string): boolean { @@ -16,12 +16,28 @@ export function isWebsocketUrl(url: string): boolean {
return /^wss?:\/\/.+$/.test(url)
}
/** Nostr relay over HTTPS (index relay JSON API), not WebSocket. */
export function isHttpRelayUrl(url: string): boolean {
export function isWebSocketRelayScheme(url: string): boolean {
return /^wss?:\/\//i.test(url.trim())
}
export function isHttpOrHttpsScheme(url: string): boolean {
return /^https?:\/\//i.test(url.trim())
}
/**
* Kind **10243** `r` tag values use http(s) for the index-relay JSON API.
* Do not use this to classify arbitrary https:// URLs (profile websites, etc.).
*/
export function isKind10243HttpRelayTagUrl(url: string): boolean {
const u = url.trim()
return /^https?:\/\/.+/i.test(u)
}
/** @deprecated Prefer {@link isKind10243HttpRelayTagUrl} only when parsing kind 10243. */
export function isHttpRelayUrl(url: string): boolean {
return isKind10243HttpRelayTagUrl(url)
}
/**
* Normalize https/http relay base URL without converting to WebSocket.
* Use for kind 10243 and index-relay HTTP API calls (not for NIP-01 WS pool).
@ -54,11 +70,25 @@ export function devProxyLoopbackHttpRelayBase(normalizedBase: string): string { @@ -54,11 +70,25 @@ export function devProxyLoopbackHttpRelayBase(normalizedBase: string): string {
* In dev, `index-relay-http` rewrites the base to same-origin `/dev-cors-index-relay` (see `vite.config.ts`).
* Keep this list tiny each entry needs a matching Vite `proxy` target.
*/
const DEV_HTTPS_INDEX_RELAY_CORS_PROXY_HOSTS = new Set(['nos.lol'])
const DEV_HTTPS_INDEX_RELAY_CORS_PROXY_HOSTS = new Set(['nos.lol', 'mercury-relay.imwald.eu'])
function devIndexRelayTargetHostname(): string | null {
const raw = import.meta.env.VITE_DEV_INDEX_RELAY_TARGET
if (typeof raw !== 'string' || !raw.trim()) return null
try {
const withScheme = /^https?:\/\//i.test(raw.trim()) ? raw.trim() : `https://${raw.trim()}`
return new URL(withScheme).hostname.toLowerCase()
} catch {
return null
}
}
/**
* Rewrite `https://nos.lol/...` index relay bases to the Vite dev proxy so POST /api/events/filter works.
* Rewrite HTTPS index relay bases to a same-origin Vite proxy so POST /api/events/filter works in dev.
* Chain after `devProxyLoopbackHttpRelayBase`: `devProxyCorsProblematicHttpsIndexRelayBase(devProxyLoopbackHttpRelayBase(url))`.
*
* - Host matching `VITE_DEV_INDEX_RELAY_TARGET` `/dev-index-relay`
* - Allowlisted hosts (e.g. nos.lol, mercury-relay.imwald.eu) `/dev-cors-index-relay`
*/
export function devProxyCorsProblematicHttpsIndexRelayBase(normalizedBase: string): string {
if (import.meta.env.PROD || typeof window === 'undefined') return normalizedBase
@ -69,7 +99,12 @@ export function devProxyCorsProblematicHttpsIndexRelayBase(normalizedBase: strin @@ -69,7 +99,12 @@ export function devProxyCorsProblematicHttpsIndexRelayBase(normalizedBase: strin
return normalizedBase
}
if (u.protocol !== 'https:') return normalizedBase
if (!DEV_HTTPS_INDEX_RELAY_CORS_PROXY_HOSTS.has(u.hostname.toLowerCase())) return normalizedBase
const host = u.hostname.toLowerCase()
const devTargetHost = devIndexRelayTargetHostname()
if (devTargetHost && host === devTargetHost) {
return `${window.location.origin}/dev-index-relay`
}
if (!DEV_HTTPS_INDEX_RELAY_CORS_PROXY_HOSTS.has(host)) return normalizedBase
return `${window.location.origin}/dev-cors-index-relay`
}
@ -78,25 +113,59 @@ export function relayUrlHasExplicitScheme(url: string): boolean { @@ -78,25 +113,59 @@ export function relayUrlHasExplicitScheme(url: string): boolean {
return /^(https?|wss?):\/\//i.test(url.trim())
}
/**
* Normalize a relay URL without changing its transport: `https://` stays HTTPS, `wss://` stays WebSocket.
*/
/** Normalize WebSocket relay URLs (`ws:` / `wss:`) for REQ pools and feed layers. */
export function normalizeAnyRelayUrl(url: string): string {
return normalizeUrl(url)
}
/** Stable key for per-relay session stats (scheme preserved; no https→wss aliasing). */
export function canonicalRelaySessionKey(url: string): string {
const trimmed = url.trim()
if (!trimmed) return ''
if (!relayUrlHasExplicitScheme(trimmed)) {
logger.warn('Relay URL requires http:, https:, ws:, or wss: prefix', { url: trimmed })
return ''
if (isWebSocketRelayScheme(trimmed)) {
return (normalizeUrl(trimmed) || trimmed).toLowerCase()
}
if (isHttpRelayUrl(trimmed)) return normalizeHttpRelayUrl(trimmed) || ''
if (isWebsocketUrl(trimmed)) return normalizeUrl(trimmed) || ''
logger.warn('Unsupported relay URL scheme', { url: trimmed })
return ''
if (isHttpOrHttpsScheme(trimmed)) {
return (normalizeHttpRelayUrl(trimmed) || trimmed).toLowerCase()
}
return trimmed.toLowerCase()
}
/** Stable key for per-relay session stats (scheme preserved; no https→wss aliasing). */
export function canonicalRelaySessionKey(url: string): string {
return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase()
/**
* HTTP index relay bases present in `urls` that are also listed in kind **10243** storage
* (`httpRead` / `httpWrite`). URLs with https scheme that are not configured are ignored.
*/
export function httpIndexRelayBasesInUrlBatch(
urls: readonly string[],
configuredHttpIndexBases: readonly string[]
): string[] {
const configured = new Set(
configuredHttpIndexBases
.map((u) => normalizeHttpRelayUrl(u) || u.trim())
.filter(Boolean)
.map((u) => u.toLowerCase())
)
if (configured.size === 0) return []
const out = new Set<string>()
for (const raw of urls) {
const n = normalizeHttpRelayUrl(raw) || raw.trim()
if (!n) continue
if (configured.has(n.toLowerCase())) out.add(n)
}
return [...out]
}
export function urlMatchesConfiguredHttpIndexRelay(
url: string,
configuredHttpIndexBases: readonly string[]
): boolean {
const n = normalizeHttpRelayUrl(url) || url.trim()
if (!n) return false
const key = n.toLowerCase()
return configuredHttpIndexBases.some((b) => {
const nb = normalizeHttpRelayUrl(b) || b.trim()
return nb && nb.toLowerCase() === key
})
}
// copy from nostr-tools/utils — WebSocket relays only (`ws:` / `wss:`); never rewrite http(s) schemes.
@ -113,7 +182,6 @@ export function normalizeUrl(url: string): string { @@ -113,7 +182,6 @@ export function normalizeUrl(url: string): string {
stripTrailingCommasFromHostname(p)
if (p.protocol !== 'ws:' && p.protocol !== 'wss:') {
logger.warn('normalizeUrl expects ws: or wss: (use normalizeHttpRelayUrl for http(s))', { url: trimmed })
return ''
}
@ -162,13 +230,12 @@ export function normalizeHttpUrl(url: string): string { @@ -162,13 +230,12 @@ export function normalizeHttpUrl(url: string): string {
const trimmed = url.trim()
if (!trimmed) return ''
if (!trimmed.includes('://')) {
logger.warn('HTTP relay URL requires http: or https: prefix', { url: trimmed })
logger.debug('HTTP URL requires http: or https: prefix', { url: trimmed })
return ''
}
const p = new URL(trimmed)
stripTrailingCommasFromHostname(p)
if (p.protocol !== 'http:' && p.protocol !== 'https:') {
logger.warn('normalizeHttpUrl expects http: or https: (use normalizeUrl for ws(s))', { url: trimmed })
return ''
}
p.pathname = p.pathname.replace(/\/+/g, '/')

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

@ -25,7 +25,13 @@ import { Button } from '@/components/ui/button' @@ -25,7 +25,13 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { toRelay } from '@/lib/link'
import { cn } from '@/lib/utils'
import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl, simplifyUrl } from '@/lib/url'
import {
isKind10243HttpRelayTagUrl,
isWebsocketUrl,
normalizeAnyRelayUrl,
normalizeHttpRelayUrl,
simplifyUrl
} from '@/lib/url'
const RELAY_SUGGESTION_LIMIT = 20
@ -177,8 +183,8 @@ function ExploreRelaySearchSection({ @@ -177,8 +183,8 @@ function ExploreRelaySearchSection({
const tryOpenRelay = () => {
const trimmed = listFilter.trim()
if (!trimmed) return
const normalized = normalizeAnyRelayUrl(trimmed)
if (!normalized || (!isHttpRelayUrl(normalized) && !isWebsocketUrl(normalized))) {
const normalized = normalizeAnyRelayUrl(trimmed) || normalizeHttpRelayUrl(trimmed)
if (!normalized || (!isWebsocketUrl(normalized) && !isKind10243HttpRelayTagUrl(normalized))) {
toast.error(t('invalid relay URL'))
return
}

4
src/providers/FavoriteRelaysProvider.tsx

@ -5,7 +5,7 @@ import { createFavoriteRelaysDraftEvent, createBlockedRelaysDraftEvent, createRe @@ -5,7 +5,7 @@ import { createFavoriteRelaysDraftEvent, createBlockedRelaysDraftEvent, createRe
import { getReplaceableEventIdentifier } from '@/lib/event'
import { getRelaySetFromEvent } from '@/lib/event-metadata'
import { randomString } from '@/lib/random'
import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { isWebsocketUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { queryService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { TRelaySet } from '@/types'
@ -200,7 +200,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode @@ -200,7 +200,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
async (relaySetName: string, relayUrls: string[] = []) => {
const normalizedUrls = relayUrls
.map((url) => normalizeAnyRelayUrl(url))
.filter((url) => isWebsocketUrl(url) || isHttpRelayUrl(url))
.filter((url) => isWebsocketUrl(url))
const id = randomString()
const relaySetDraftEvent = createRelaySetDraftEvent({
id,

22
src/providers/FeedProvider.tsx

@ -3,7 +3,10 @@ import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' @@ -3,7 +3,10 @@ import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { getRelayListFromEvent, getHttpRelayListFromEvent } from '@/lib/event-metadata'
import { buildAllFavoritesFeedRelayUrls, stripNostrLandAggrFromRelayUrls } from '@/lib/home-feed-relays'
import logger from '@/lib/logger'
import { syncViewerRelayStackNostrLandAggrEligible } from '@/lib/nostr-land-relay-eligibility'
import {
syncViewerRelayStackNostrLandAggrEligible,
urlsForViewerNostrLandAggrEligibilitySync
} from '@/lib/nostr-land-relay-eligibility'
import { normalizeAnyRelayUrl } from '@/lib/url'
import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
@ -133,14 +136,15 @@ export function FeedProvider({ children }: { children: ReactNode }) { @@ -133,14 +136,15 @@ export function FeedProvider({ children }: { children: ReactNode }) {
/** Keeps {@link getViewerRelayStackNostrLandAggrEligible} in sync for non-home reads (threads, profiles, etc.). */
useEffect(() => {
const urls = [
...favoriteFeedRelayUrls,
...replyExtraRelayLayers.inboxRelayUrls,
...replyExtraRelayLayers.outboxRelayUrls,
...replyExtraRelayLayers.cacheRelayUrls,
...replyExtraRelayLayers.httpRelayUrls
]
syncViewerRelayStackNostrLandAggrEligible(urls)
syncViewerRelayStackNostrLandAggrEligible(
urlsForViewerNostrLandAggrEligibilitySync({
favoriteRelayUrls: favoriteFeedRelayUrls,
relayListRead: replyExtraRelayLayers.inboxRelayUrls,
relayListWrite: replyExtraRelayLayers.outboxRelayUrls,
cacheRelayRead: replyExtraRelayLayers.cacheRelayUrls,
httpRelayRead: replyExtraRelayLayers.httpRelayUrls
})
)
}, [favoriteFeedRelayUrls, replyExtraRelayLayers])
const lastHomeFeedUrlLogRef = useRef({ primary: '', reply: '' })

25
src/services/client-query.service.ts

@ -28,9 +28,10 @@ import { @@ -28,9 +28,10 @@ import {
import { applyRelayNip42AckTimeout } from '@/lib/relay-nip42-tuning'
import { isIndexRelayTransportFailure, queryIndexRelay } from '@/lib/index-relay-http'
import logger from '@/lib/logger'
import { getViewerNostrLandAggrSearchRelayUrls } from '@/lib/nostr-land-relay-eligibility'
import {
canonicalRelaySessionKey,
isHttpRelayUrl,
httpIndexRelayBasesInUrlBatch,
normalizeAnyRelayUrl,
normalizeHttpRelayUrl,
normalizeUrl
@ -230,6 +231,8 @@ export interface QueryOptions { @@ -230,6 +231,8 @@ export interface QueryOptions {
firstRelayResultGraceMs?: number | false
/** Label for {@link RelaySubscribeOpBatch} when this query opens REQs. */
relayOpSource?: string
/** Kind 10243 HTTP index bases; only matching URLs in `urls` use the index JSON API. */
httpIndexRelayBases?: readonly string[]
/**
* When aborted (e.g. React effect cleanup / HMR), closes WS + HTTP index work promptly instead of waiting for
* {@link globalTimeout}. Prevents overlapping NIP-50 shards from stacking until the tab OOMs.
@ -476,15 +479,11 @@ export class QueryService { @@ -476,15 +479,11 @@ export class QueryService {
? FIRST_RELAY_RESULT_GRACE_MS
: null
const httpRelayBases = Array.from(
new Set(
urls
.filter((u) => isHttpRelayUrl(u))
.map((u) => normalizeHttpRelayUrl(u) || u)
.filter((u): u is string => Boolean(u) && !relaySessionStrikes.isReadHttpSkipped(u))
)
const httpRelayBases = httpIndexRelayBasesInUrlBatch(urls, options?.httpIndexRelayBases ?? []).filter(
(u) => !relaySessionStrikes.isReadHttpSkipped(u)
)
const wsQueryUrls = urls.filter((u) => !isHttpRelayUrl(u))
const httpKeys = new Set(httpRelayBases.map((u) => canonicalRelaySessionKey(u)))
const wsQueryUrls = urls.filter((u) => !httpKeys.has(canonicalRelaySessionKey(u)))
const reqId = ++queryReqSeq
const source = options?.relayOpSource ?? 'QueryService.query'
@ -807,11 +806,12 @@ export class QueryService { @@ -807,11 +806,12 @@ export class QueryService {
relays = relayUrlsStripExtendedTagReqBlocked([...FAST_READ_RELAY_URLS])
}
}
relays = relays.filter((url) => !isHttpRelayUrl(url))
// WebSocket REQ only — drop https URLs (index relays use HTTP polling elsewhere).
relays = relays.filter((url) => !/^https?:\/\//i.test(url.trim()))
const wsCountBeforeStrikes = relays.length
if (wsCountBeforeStrikes > 1) {
relays = relaySessionStrikes.filterReadHttpUrls(relays)
relays = relaySessionStrikes.filterReadHttpUrls(relays, [])
}
if (relays.length === 0) {
@ -830,10 +830,11 @@ export class QueryService { @@ -830,10 +830,11 @@ export class QueryService {
const searchableSet = new Set(
[
...SEARCHABLE_RELAY_URLS,
...getViewerNostrLandAggrSearchRelayUrls(),
...nip66Service.getSearchableRelayUrls(),
...PROFILE_RELAY_URLS
]
.map((u) => canonicalRelaySessionKey(normalizeUrl(u) || String(u).trim()))
.map((u) => canonicalRelaySessionKey(String(u).trim()))
.filter((k): k is string => k.length > 0)
)

4
src/services/client-replaceable-events.service.ts

@ -14,7 +14,7 @@ import { @@ -14,7 +14,7 @@ import {
import { kinds, nip19 } from 'nostr-tools'
import type { Event as NEvent, Filter } from 'nostr-tools'
import DataLoader from 'dataloader'
import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl, normalizeHttpUrl, normalizeUrl } from '@/lib/url'
import { isWebsocketUrl, normalizeAnyRelayUrl, normalizeHttpUrl, normalizeUrl } from '@/lib/url'
import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
import { LEGACY_PROFILE_BADGES_D_TAG } from '@/lib/nip58-profile-badges'
import { formatPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey'
@ -194,7 +194,7 @@ export class ReplaceableEventService { @@ -194,7 +194,7 @@ export class ReplaceableEventService {
...profileStack,
...hintLayer
.map((u) => normalizeAnyRelayUrl(u) || normalizeUrl(u) || u.trim())
.filter((u): u is string => !!u && !isHttpRelayUrl(u))
.filter((u): u is string => !!u && !/^https?:\/\//i.test(u))
])
)
}

82
src/services/client.service.ts

@ -149,14 +149,21 @@ import { @@ -149,14 +149,21 @@ import {
stripLocalNetworkRelaysForWssReq,
urlIsNonLocalForRemoteViewer
} from '@/lib/relay-list-sanitize'
import {
getViewerNostrLandAggrSearchRelayUrls,
syncViewerRelayStackNostrLandAggrEligible
} from '@/lib/nostr-land-relay-eligibility'
import {
canonicalRelaySessionKey,
isHttpRelayUrl,
httpIndexRelayBasesInUrlBatch,
isKind10243HttpRelayTagUrl,
isLocalNetworkUrl,
isWebsocketUrl,
normalizeAnyRelayUrl,
normalizeHttpRelayUrl,
normalizeUrl,
simplifyUrl
simplifyUrl,
urlMatchesConfiguredHttpIndexRelay
} from '@/lib/url'
import { canonicalFeedFilter, canonicalRelayUrls } from '@/features/feed/descriptor'
import { initRelayPoolIdle, touchRelayPoolActivity } from '@/lib/relay-pool-idle'
@ -356,6 +363,8 @@ class ClientService extends EventTarget { @@ -356,6 +363,8 @@ class ClientService extends EventTarget {
/** Set with signer from NostrProvider; used to skip relay AUTH when read-only (e.g. npub). */
signerType?: TSignerType
pubkey?: string
/** Normalized kind **10243** index relay bases for the logged-in viewer (not arbitrary https URLs). */
private viewerHttpIndexRelayBases: string[] = []
private pool: SimplePool
// Sub-services (public for direct access)
@ -427,8 +436,8 @@ class ClientService extends EventTarget { @@ -427,8 +436,8 @@ class ClientService extends EventTarget {
if (!navigator.onLine && !isLocalNetworkUrl(url)) {
throw new Error(`[offline] skipping non-local relay ${url}`)
}
if (isHttpRelayUrl(url)) {
throw new Error(`[http-relay] ${url} uses the HTTPS index API, not WebSocket`)
if (!isWebsocketUrl(url) && isKind10243HttpRelayTagUrl(url)) {
throw new Error(`[http-index-relay] ${url} uses the HTTPS index API, not WebSocket`)
}
const n = normalizeUrl(url) || url
const base = params?.connectionTimeout ?? RELAY_POOL_CONNECTION_TIMEOUT_MS
@ -604,12 +613,17 @@ class ClientService extends EventTarget { @@ -604,12 +613,17 @@ class ClientService extends EventTarget {
async syncViewerPersonalRelayKeys(pubkey?: string): Promise<void> {
const pk = pubkey?.trim() || this.pubkey?.trim()
if (!pk) {
this.viewerHttpIndexRelayBases = []
setViewerPersonalRelayKeys(new Set())
syncViewerRelayStackNostrLandAggrEligible([])
return
}
const urls: string[] = []
try {
const rl = await this.peekRelayListFromStorage(pk)
this.viewerHttpIndexRelayBases = [...(rl.httpRead ?? []), ...(rl.httpWrite ?? [])]
.map((u) => normalizeHttpRelayUrl(u) || u)
.filter(Boolean)
urls.push(
...(rl.read ?? []),
...(rl.write ?? []),
@ -617,6 +631,7 @@ class ClientService extends EventTarget { @@ -617,6 +631,7 @@ class ClientService extends EventTarget {
...(rl.httpWrite ?? [])
)
} catch {
this.viewerHttpIndexRelayBases = []
// ignore
}
try {
@ -630,6 +645,7 @@ class ClientService extends EventTarget { @@ -630,6 +645,7 @@ class ClientService extends EventTarget {
// ignore
}
setViewerPersonalRelayKeys(buildPersonalRelayKeySet(urls))
syncViewerRelayStackNostrLandAggrEligible(urls)
}
/** NIP-66: fetch relay discovery events (30166) in background to supplement search/NIP support. */
@ -1707,9 +1723,9 @@ class ClientService extends EventTarget { @@ -1707,9 +1723,9 @@ class ClientService extends EventTarget {
}, connectionTimeout + publishAckBudgetMs + 2_000)
try {
if (isHttpRelayUrl(url)) {
if (urlMatchesConfiguredHttpIndexRelay(url, this.viewerHttpIndexRelayBases)) {
const base = normalizeHttpRelayUrl(url) || url
logger.debug(`[PublishEvent] Publishing to HTTP index relay`, { url: base })
logger.debug(`[PublishEvent] Publishing to kind 10243 HTTP index relay`, { url: base })
await Promise.race([
publishEventToHttpRelay(base, event),
new Promise<never>((_, reject) =>
@ -1846,7 +1862,7 @@ class ClientService extends EventTarget { @@ -1846,7 +1862,7 @@ class ClientService extends EventTarget {
}
} catch (error) {
const softHttpDown =
isHttpRelayUrl(url) &&
urlMatchesConfiguredHttpIndexRelay(url, this.viewerHttpIndexRelayBases) &&
(error instanceof IndexRelayTransportError || isIndexRelayTransportFailure(error))
if (softHttpDown) {
logger.debug('[PublishEvent] HTTP index relay unreachable', {
@ -2421,8 +2437,13 @@ class ClientService extends EventTarget { @@ -2421,8 +2437,13 @@ class ClientService extends EventTarget {
relayReqLog?: { groupId?: string; onBatchEnd?: (rows: RelayOpTerminalRow[]) => void }
) {
const originalDedupedRelays = Array.from(new Set(urls))
const httpKeys = new Set(
httpIndexRelayBasesInUrlBatch(originalDedupedRelays, this.viewerHttpIndexRelayBases).map((u) =>
canonicalRelaySessionKey(u)
)
)
let relays = sanitizeRelayUrlsForFetch(
originalDedupedRelays.filter((url) => !isHttpRelayUrl(url))
originalDedupedRelays.filter((url) => !httpKeys.has(canonicalRelaySessionKey(url)))
)
if (navigator.onLine) {
relays = stripLocalNetworkRelaysForWssReq(relays)
@ -2465,7 +2486,7 @@ class ClientService extends EventTarget { @@ -2465,7 +2486,7 @@ class ClientService extends EventTarget {
const wsRelayCountBeforeStrikes = relays.length
if (wsRelayCountBeforeStrikes > 1) {
relays = relaySessionStrikes.filterReadHttpUrls(relays)
relays = relaySessionStrikes.filterReadHttpUrls(relays, this.viewerHttpIndexRelayBases)
}
// eslint-disable-next-line @typescript-eslint/no-this-alias
@ -2481,6 +2502,7 @@ class ClientService extends EventTarget { @@ -2481,6 +2502,7 @@ class ClientService extends EventTarget {
}
const searchableSet = new Set([
...SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u),
...getViewerNostrLandAggrSearchRelayUrls().map((u) => normalizeUrl(u) || u),
...nip66Service.getSearchableRelayUrls().map((u) => normalizeUrl(u) || u)
])
const groupedRequests = Array.from(grouped.entries()).map(([url, f]) => {
@ -2865,14 +2887,7 @@ class ClientService extends EventTarget { @@ -2865,14 +2887,7 @@ class ClientService extends EventTarget {
let eosedAt: number | null = null
let eventIds = new Set<string>()
const httpTimelinePollBases = Array.from(
new Set(
relays
.filter((u) => isHttpRelayUrl(u))
.map((u) => normalizeHttpRelayUrl(u) || u)
.filter(Boolean)
)
)
const httpTimelinePollBases = httpIndexRelayBasesInUrlBatch(relays, this.viewerHttpIndexRelayBases)
let httpPollIntervalId: ReturnType<typeof setInterval> | null = null
let httpPollCursorUnix = 0
const clearHttpTimelinePoll = () => {
@ -3108,7 +3123,12 @@ class ClientService extends EventTarget { @@ -3108,7 +3123,12 @@ class ClientService extends EventTarget {
}
// HTTP index relays are handled via httpTimelinePollBases above — never pass them to the WS subscribe path.
const wsRelays = relays.filter((u) => !isHttpRelayUrl(u))
const httpPollKeys = new Set(
httpIndexRelayBasesInUrlBatch(relays, this.viewerHttpIndexRelayBases).map((u) =>
canonicalRelaySessionKey(u)
)
)
const wsRelays = relays.filter((u) => !httpPollKeys.has(canonicalRelaySessionKey(u)))
// When there are HTTP relays but NO WS relays, subscribe([]) would fire oneose + onBatchEnd
// immediately (via microtask) — before the HTTP initial poll returns any events. That causes:
@ -3300,7 +3320,10 @@ class ClientService extends EventTarget { @@ -3300,7 +3320,10 @@ class ClientService extends EventTarget {
firstRelayResultGraceMs?: number | false
}
) {
return this.queryService.query(sanitizeRelayUrlsForFetch(urls), filter, onevent, options)
return this.queryService.query(sanitizeRelayUrlsForFetch(urls), filter, onevent, {
...options,
httpIndexRelayBases: this.viewerHttpIndexRelayBases
})
}
// Legacy query implementation removed - now delegated to QueryService
@ -3330,16 +3353,13 @@ class ClientService extends EventTarget { @@ -3330,16 +3353,13 @@ class ClientService extends EventTarget {
} = {}
) {
const originalDedupedRelays = Array.from(new Set(urls))
const httpRelayBases = Array.from(
new Set(
originalDedupedRelays
.filter((u) => isHttpRelayUrl(u))
.map((u) => normalizeHttpRelayUrl(u) || u)
.filter(Boolean)
)
const httpRelayBases = httpIndexRelayBasesInUrlBatch(
originalDedupedRelays,
this.viewerHttpIndexRelayBases
)
const httpKeys = new Set(httpRelayBases.map((u) => canonicalRelaySessionKey(u)))
const wsOriginal = sanitizeRelayUrlsForFetch(
originalDedupedRelays.filter((url) => !isHttpRelayUrl(url))
originalDedupedRelays.filter((url) => !httpKeys.has(canonicalRelaySessionKey(url)))
)
let relays = [...wsOriginal]
if (relays.length === 0 && httpRelayBases.length === 0) {
@ -3367,7 +3387,8 @@ class ClientService extends EventTarget { @@ -3367,7 +3387,8 @@ class ClientService extends EventTarget {
firstRelayResultGraceMs,
replaceableRace,
immediateReturn,
foreground
foreground,
httpIndexRelayBases: this.viewerHttpIndexRelayBases
})
if (cache) {
events.forEach((evt) => {
@ -3411,8 +3432,8 @@ class ClientService extends EventTarget { @@ -3411,8 +3432,8 @@ class ClientService extends EventTarget {
})
}
if (isHttpRelayUrl(normalized)) {
// HTTP index relay: use HTTP API instead of WebSocket pool
if (urlMatchesConfiguredHttpIndexRelay(normalized, this.viewerHttpIndexRelayBases)) {
// Kind 10243 index relay: use HTTP API instead of WebSocket pool
try {
const events = await this.queryService.query([normalized], filter, undefined, queryOpts)
return { events, connectionError: undefined }
@ -3686,6 +3707,7 @@ class ClientService extends EventTarget { @@ -3686,6 +3707,7 @@ class ClientService extends EventTarget {
)
const searchableSet = new Set([
...SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u),
...getViewerNostrLandAggrSearchRelayUrls().map((u) => normalizeUrl(u) || u),
...nip66Service.getSearchableRelayUrls().map((u) => normalizeUrl(u) || u),
...PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean)
])

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

@ -9,7 +9,13 @@ import storage from '@/services/local-storage.service' @@ -9,7 +9,13 @@ import storage from '@/services/local-storage.service'
import { NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX } from '@/lib/content-patterns'
import client from '@/services/client.service'
import { eventService } from '@/services/client.service'
import { normalizeAnyRelayUrl, isLocalNetworkUrl } from '@/lib/url'
import {
canonicalRelaySessionKey,
isHttpOrHttpsScheme,
isLocalNetworkUrl,
normalizeAnyRelayUrl,
normalizeHttpRelayUrl
} from '@/lib/url'
import { TRelaySet, TRelayList } from '@/types'
import logger from '@/lib/logger'
import indexedDb from '@/services/indexed-db.service'
@ -123,7 +129,9 @@ class RelaySelectionService { @@ -123,7 +129,9 @@ class RelaySelectionService {
)
const relayTypes: Record<string, RelaySourceType> = {}
const httpSet = new Set(
(userHttpWriteRelays ?? []).map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean)
(userHttpWriteRelays ?? [])
.map((u) => canonicalRelaySessionKey(u))
.filter(Boolean)
)
filtered.forEach((url) => {
relayTypes[url] = httpSet.has(url) ? 'http_relay_list' : 'relay_list'
@ -136,9 +144,12 @@ class RelaySelectionService { @@ -136,9 +144,12 @@ class RelaySelectionService {
const addRelay = (url: string, type: RelaySourceType) => {
if (!url) return
const normalized = normalizeAnyRelayUrl(url)
if (normalized && !seen.has(normalized)) {
seen.add(normalized)
const normalized = isHttpOrHttpsScheme(url)
? normalizeHttpRelayUrl(url)
: normalizeAnyRelayUrl(url)
const key = normalized ? canonicalRelaySessionKey(normalized) : ''
if (key && !seen.has(key)) {
seen.add(key)
order.push({ url: normalized, type })
} else if (!normalized) {
logger.warn('Skipping invalid relay URL', { url })

8
vite.config.ts

@ -162,6 +162,12 @@ export default defineConfig(({ mode }) => { @@ -162,6 +162,12 @@ export default defineConfig(({ mode }) => {
/** gc_index_relay (or compatible) HTTP API; app POSTs to /api/events/filter. HTTP 500 in the browser means this process errored, not that Vite failed. */
const devIndexRelayTarget =
env.VITE_DEV_INDEX_RELAY_TARGET?.trim() || 'http://127.0.0.1:4000'
/** Target for `/dev-cors-index-relay` (allowlisted HTTPS index hosts in dev). */
const devCorsIndexRelayTarget =
env.VITE_DEV_CORS_INDEX_RELAY_TARGET?.trim()?.replace(/\/+$/, '') ||
(/^https:\/\//i.test(devIndexRelayTarget)
? devIndexRelayTarget.replace(/\/+$/, '')
: 'https://mercury-relay.imwald.eu')
/**
* Desktop shell (`vite build --mode electron`): always bake public Imwald API origins into the bundle.
@ -261,7 +267,7 @@ export default defineConfig(({ mode }) => { @@ -261,7 +267,7 @@ export default defineConfig(({ mode }) => {
* Same-origin proxy only allowlisted hosts in {@link devProxyCorsProblematicHttpsIndexRelayBase}.
*/
'/dev-cors-index-relay': {
target: 'https://nos.lol',
target: devCorsIndexRelayTarget,
changeOrigin: true,
secure: true,
rewrite: (p) => p.replace(/^\/dev-cors-index-relay/, '') || '/'

Loading…
Cancel
Save