Browse Source

bug-fixes

imwald
Silberengel 3 weeks ago
parent
commit
9b72943214
  1. 27
      src/PageManager.tsx
  2. 4
      src/components/FavoriteRelaysSetting/AddBlockedRelay.tsx
  3. 4
      src/components/FavoriteRelaysSetting/AddNewRelay.tsx
  4. 4
      src/components/FavoriteRelaysSetting/RelayUrl.tsx
  5. 17
      src/components/HelpAndAccountMenu.tsx
  6. 6
      src/components/MailboxSetting/DiscoveredRelays.tsx
  7. 4
      src/components/MailboxSetting/index.tsx
  8. 8
      src/components/NoteDrawer/index.tsx
  9. 29
      src/contexts/cache-browser-context.tsx
  10. 4
      src/features/feed/relay-policy.ts
  11. 11
      src/layouts/SecondaryPageLayout/index.tsx
  12. 21
      src/lib/event-metadata.ts
  13. 12
      src/lib/favorites-feed-relays.ts
  14. 79
      src/lib/mobile-swipe-back.ts
  15. 26
      src/lib/relay-blocked.ts
  16. 11
      src/lib/relay-list-builder.ts
  17. 38
      src/lib/relay-url-normalize.test.ts
  18. 91
      src/lib/url.ts
  19. 18
      src/services/client-query.service.ts
  20. 3
      src/services/client.service.ts

27
src/PageManager.tsx

@ -7,6 +7,7 @@ import { RefreshButton } from '@/components/RefreshButton' @@ -7,6 +7,7 @@ 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 { preventRadixSheetCloseForPortaledOverlay } from '@/lib/sheet-dismiss-guard'
import { ChevronLeft } from 'lucide-react'
import { NavigationService } from '@/services/navigation.service'
@ -1149,6 +1150,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1149,6 +1150,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
currentPrimaryPageRef.current = currentPrimaryPage
}, [currentPrimaryPage])
const navigationCounterRef = useRef(0)
const goBackRef = useRef<() => void>(() => {})
const [mobilePrimarySwipeRoot, setMobilePrimarySwipeRoot] = useState<HTMLElement | null>(null)
const primaryPanelRefreshRef = useRef<(() => void) | null>(null)
const registerPrimaryPanelRefresh = useCallback((fn: (() => void) | null) => {
primaryPanelRefreshRef.current = fn
@ -1967,6 +1970,13 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1967,6 +1970,13 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}
window.history.back()
}
goBackRef.current = goBack
useMobileSwipeBackOnElement(
isSmallScreen && primaryNoteView ? mobilePrimarySwipeRoot : null,
() => goBackRef.current(),
{ enabled: Boolean(isSmallScreen && primaryNoteView) }
)
const pushSecondaryPage = (url: string, index?: number) => {
logger.component('PageManager', 'pushSecondaryPage called', { url })
@ -2100,6 +2110,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2100,6 +2110,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
setSecondaryStack([])
})
secondaryStackRef.current = []
replaceHistoryWithPrimaryPageUrl(
currentPrimaryPage,
primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined
)
const savedFeedState = savedFeedStateRef.current.get(currentPrimaryPage)
@ -2228,7 +2242,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2228,7 +2242,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
<LiveActivitiesStrip placement="mobile" />
{primaryNoteView ? (
// Show primary note view with back button on mobile
<div className="flex min-h-0 flex-1 flex-col h-full w-full">
<div
ref={setMobilePrimarySwipeRoot}
className="flex min-h-0 flex-1 flex-col h-full w-full touch-pan-y"
>
<ImwaldBrandBar />
<div className="flex gap-1 border-b border-border p-1 items-center justify-between font-semibold">
<div className="flex min-w-0 flex-1 items-center">
@ -2290,7 +2307,13 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2290,7 +2307,13 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
<NoteDrawer
open={drawerOpen}
initialEvent={drawerInitialEvent}
onOpenChange={setDrawerOpen}
onOpenChange={(open) => {
if (open) {
setDrawerOpen(true)
return
}
popSecondaryPage()
}}
noteId={drawerNoteId}
/>
)}

4
src/components/FavoriteRelaysSetting/AddBlockedRelay.tsx

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { normalizeUrl } from '@/lib/url'
import { normalizeAnyRelayUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -18,7 +18,7 @@ export default function AddBlockedRelay() { @@ -18,7 +18,7 @@ export default function AddBlockedRelay() {
const saveRelay = async () => {
if (!input || isLoading) return
const normalizedUrl = normalizeUrl(input)
const normalizedUrl = normalizeAnyRelayUrl(input)
if (!normalizedUrl) {
setErrorMsg(t('Invalid URL'))
setSuccessMsg('')

4
src/components/FavoriteRelaysSetting/AddNewRelay.tsx

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { normalizeUrl } from '@/lib/url'
import { normalizeAnyRelayUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -15,7 +15,7 @@ export default function AddNewRelay() { @@ -15,7 +15,7 @@ export default function AddNewRelay() {
const saveRelay = async () => {
if (!input || isLoading) return
const normalizedUrl = normalizeUrl(input)
const normalizedUrl = normalizeAnyRelayUrl(input)
if (!normalizedUrl) {
setErrorMsg(t('Invalid URL'))
return

4
src/components/FavoriteRelaysSetting/RelayUrl.tsx

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { toRelay } from '@/lib/link'
import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
import { isWebsocketUrl, normalizeAnyRelayUrl } from '@/lib/url'
import { useSecondaryPage } from '@/PageManager'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { CircleX } from 'lucide-react'
@ -36,7 +36,7 @@ export default function RelayUrls({ relaySetId }: { relaySetId: string }) { @@ -36,7 +36,7 @@ export default function RelayUrls({ relaySetId }: { relaySetId: string }) {
const saveNewRelayUrl = async () => {
if (newRelayUrl === '' || isLoading) return
const normalizedUrl = normalizeUrl(newRelayUrl)
const normalizedUrl = normalizeAnyRelayUrl(newRelayUrl)
if (!normalizedUrl) {
return setNewRelayUrlError(t('Invalid relay URL'))
}

17
src/components/HelpAndAccountMenu.tsx

@ -14,12 +14,14 @@ import { Skeleton } from '@/components/ui/skeleton' @@ -14,12 +14,14 @@ import { Skeleton } from '@/components/ui/skeleton'
import { formatPubkey, formatNpub, generateImageByPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { isVideo } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useCacheBrowser } from '@/contexts/cache-browser-context'
import { openBrowseCacheFromRegistry } from '@/contexts/cache-browser-context'
import { toCacheSettings } from '@/lib/link'
import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useSmartSettingsNavigation } from '@/PageManager'
import { useFetchProfile } from '@/hooks/useFetchProfile'
import { useNostr } from '@/providers/NostrProvider'
import { ArrowDownUp, Database, LogIn, LogOut, Settings, User, UserRound } from 'lucide-react'
import { useMemo, useState, type ReactNode } from 'react'
import { useCallback, useMemo, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
export type HelpAndAccountMenuVariant = 'sidebar' | 'titlebar'
@ -191,7 +193,12 @@ function TitlebarAccountMenu({ @@ -191,7 +193,12 @@ function TitlebarAccountMenu({
export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccountMenuVariant }) {
const { t } = useTranslation()
const { pubkey, checkLogin } = useNostr()
const { openBrowseCache } = useCacheBrowser()
const { navigateToSettings } = useSmartSettingsNavigation()
const onBrowseCache = useCallback(() => {
if (!openBrowseCacheFromRegistry()) {
navigateToSettings(toCacheSettings())
}
}, [navigateToSettings])
const [loginDialogOpen, setLoginDialogOpen] = useState(false)
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false)
@ -202,13 +209,13 @@ export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccoun @@ -202,13 +209,13 @@ export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccoun
<SidebarAccountMenu
onSwitchAccount={() => setLoginDialogOpen(true)}
onLogoutClick={() => setLogoutDialogOpen(true)}
onBrowseCache={openBrowseCache}
onBrowseCache={onBrowseCache}
/>
) : (
<TitlebarAccountMenu
onSwitchAccount={() => setLoginDialogOpen(true)}
onLogoutClick={() => setLogoutDialogOpen(true)}
onBrowseCache={openBrowseCache}
onBrowseCache={onBrowseCache}
/>
)
} else if (variant === 'sidebar') {

6
src/components/MailboxSetting/DiscoveredRelays.tsx

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Checkbox } from '@/components/ui/checkbox'
import { normalizeUrl, isLocalNetworkUrl } from '@/lib/url'
import { isLocalNetworkUrl, normalizeHttpRelayUrl } from '@/lib/url'
import { getRelaysFromNip07Extension, verifyNip05 } from '@/lib/nip05'
import { useNostr } from '@/providers/NostrProvider'
import { TMailboxRelay } from '@/types'
@ -44,7 +44,7 @@ export default function DiscoveredRelays({ onAdd, localOnly = false }: { onAdd: @@ -44,7 +44,7 @@ export default function DiscoveredRelays({ onAdd, localOnly = false }: { onAdd:
const nip05Result = await verifyNip05(profile.nip05, account.pubkey)
if (nip05Result.isVerified && nip05Result.relays) {
nip05Result.relays.forEach(url => {
const normalized = normalizeUrl(url)
const normalized = normalizeHttpRelayUrl(url)
if (normalized && !discovered.has(normalized)) {
discovered.set(normalized, {
url: normalized,
@ -64,7 +64,7 @@ export default function DiscoveredRelays({ onAdd, localOnly = false }: { onAdd: @@ -64,7 +64,7 @@ export default function DiscoveredRelays({ onAdd, localOnly = false }: { onAdd:
try {
const extensionRelays = await getRelaysFromNip07Extension()
extensionRelays.forEach(url => {
const normalized = normalizeUrl(url)
const normalized = normalizeHttpRelayUrl(url)
if (normalized && !discovered.has(normalized)) {
discovered.set(normalized, {
url: normalized,

4
src/components/MailboxSetting/index.tsx

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { Button } from '@/components/ui/button'
import { normalizeUrl, isLocalNetworkUrl } from '@/lib/url'
import { isLocalNetworkUrl, normalizeHttpRelayUrl } from '@/lib/url'
import { useNostr } from '@/providers/NostrProvider'
import { TMailboxRelay, TMailboxRelayScope } from '@/types'
import { useEffect, useState } from 'react'
@ -98,7 +98,7 @@ export default function MailboxSetting() { @@ -98,7 +98,7 @@ export default function MailboxSetting() {
const saveNewMailboxRelay = (url: string) => {
if (url === '') return null
const normalizedUrl = normalizeUrl(url)
const normalizedUrl = normalizeHttpRelayUrl(url)
if (!normalizedUrl) {
return t('Invalid relay URL')
}

8
src/components/NoteDrawer/index.tsx

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react'
import { useMobileSwipeBackOnElement } from '@/lib/mobile-swipe-back'
import { preventRadixSheetCloseForPortaledOverlay } from '@/lib/sheet-dismiss-guard'
import { Sheet, SheetContent } from '@/components/ui/sheet'
import NotePage from '@/pages/secondary/NotePage'
@ -13,10 +14,13 @@ interface NoteDrawerProps { @@ -13,10 +14,13 @@ interface NoteDrawerProps {
}
export default function NoteDrawer({ open, onOpenChange, noteId, initialEvent }: NoteDrawerProps) {
const { currentIndex } = useSecondaryPage()
const { currentIndex, pop } = useSecondaryPage()
const [displayNoteId, setDisplayNoteId] = useState<string | null>(noteId)
const [swipeRoot, setSwipeRoot] = useState<HTMLElement | null>(null)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
useMobileSwipeBackOnElement(open ? swipeRoot : null, pop, { enabled: open })
useEffect(() => {
// Clear any pending timeout
if (timeoutRef.current) {
@ -57,7 +61,7 @@ export default function NoteDrawer({ open, onOpenChange, noteId, initialEvent }: @@ -57,7 +61,7 @@ export default function NoteDrawer({ open, onOpenChange, noteId, initialEvent }:
onPointerDownOutside={(e) => preventRadixSheetCloseForPortaledOverlay(e)}
onInteractOutside={(e) => preventRadixSheetCloseForPortaledOverlay(e)}
>
<div className="min-h-full">
<div ref={setSwipeRoot} className="min-h-full touch-pan-y">
<NotePage
id={displayNoteId}
index={currentIndex}

29
src/contexts/cache-browser-context.tsx

@ -1,5 +1,13 @@ @@ -1,5 +1,13 @@
import CacheBrowserDialog from '../components/CacheBrowser/CacheBrowserDialog'
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode
} from 'react'
type CacheBrowserContextValue = {
openBrowseCache: () => void
@ -7,11 +15,30 @@ type CacheBrowserContextValue = { @@ -7,11 +15,30 @@ type CacheBrowserContextValue = {
const CacheBrowserContext = createContext<CacheBrowserContextValue | undefined>(undefined)
/** Survives React Fast Refresh when context hooks temporarily lose their provider. */
let browseCacheOpener: (() => void) | null = null
export function registerBrowseCacheOpener(fn: (() => void) | null): void {
browseCacheOpener = fn
}
/** Open the cache browser dialog when the provider is mounted; safe during HMR. */
export function openBrowseCacheFromRegistry(): boolean {
if (!browseCacheOpener) return false
browseCacheOpener()
return true
}
export function CacheBrowserProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false)
const openBrowseCache = useCallback(() => setOpen(true), [])
const value = useMemo(() => ({ openBrowseCache }), [openBrowseCache])
useEffect(() => {
registerBrowseCacheOpener(openBrowseCache)
return () => registerBrowseCacheOpener(null)
}, [openBrowseCache])
return (
<CacheBrowserContext.Provider value={value}>
{children}

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

@ -10,6 +10,7 @@ import { @@ -10,6 +10,7 @@ import {
relayFiltersUseCapitalLetterTagKeys,
relayUrlsStripExtendedTagReqBlocked
} from '@/lib/relay-extended-tag-req-blocks'
import { isRelayBlockedByUser } from '@/lib/relay-blocked'
import { isLocalNetworkUrl, normalizeAnyRelayUrl } from '@/lib/url'
import type { TSubRequestFilter } from '@/types'
@ -158,7 +159,6 @@ export function applyFeedRelayPolicy( @@ -158,7 +159,6 @@ export function applyFeedRelayPolicy(
inputLayers: readonly FeedRelayLayer[],
context: FeedRelayPolicyContext
): FeedRelayPolicyResult {
const blocked = normalizedSet(context.blockedRelays)
const socialExempt = normalizedSet(context.socialKindBlockedExemptRelays)
const socialFilter = shouldApplySocialFilter(context)
const extendedFilter = shouldApplyExtendedTagFilter(context)
@ -182,7 +182,7 @@ export function applyFeedRelayPolicy( @@ -182,7 +182,7 @@ export function applyFeedRelayPolicy(
addDrop(dropped, normalized, layer.source, 'duplicate')
continue
}
if (blocked.has(key)) {
if (isRelayBlockedByUser(normalized, context.blockedRelays)) {
addDrop(dropped, normalized, layer.source, 'user-blocked')
continue
}

11
src/layouts/SecondaryPageLayout/index.tsx

@ -8,12 +8,13 @@ import { @@ -8,12 +8,13 @@ import {
isRadixDialogOpen,
shouldIgnoreKeyboardShortcutEvent
} from '@/lib/keyboard-shortcuts'
import { useMobileSwipeBackOnElement } from '@/lib/mobile-swipe-back'
import { useSecondaryPage } from '@/PageManager'
import { DeepBrowsingProvider } from '@/providers/DeepBrowsingProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { cn } from '@/lib/utils'
import { ChevronLeft } from 'lucide-react'
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
const SecondaryPageLayout = forwardRef(
@ -41,7 +42,12 @@ const SecondaryPageLayout = forwardRef( @@ -41,7 +42,12 @@ const SecondaryPageLayout = forwardRef(
) => {
const scrollAreaRef = useRef<HTMLDivElement>(null)
const { isSmallScreen } = useScreenSize()
const { currentIndex } = useSecondaryPage()
const { currentIndex, pop } = useSecondaryPage()
const [mobileSwipeRoot, setMobileSwipeRoot] = useState<HTMLElement | null>(null)
const mobileSwipeActive = isSmallScreen && currentIndex === index
useMobileSwipeBackOnElement(mobileSwipeActive ? mobileSwipeRoot : null, pop, {
enabled: mobileSwipeActive
})
useImperativeHandle(
ref,
@ -87,6 +93,7 @@ const SecondaryPageLayout = forwardRef( @@ -87,6 +93,7 @@ const SecondaryPageLayout = forwardRef(
return (
<DeepBrowsingProvider active={currentIndex === index}>
<div
ref={setMobileSwipeRoot}
style={{
paddingBottom: 'calc(env(safe-area-inset-bottom) + 3rem)'
}}

21
src/lib/event-metadata.ts

@ -6,6 +6,7 @@ import { getLatestEvent, getReplaceableEventIdentifier } from './event' @@ -6,6 +6,7 @@ import { getLatestEvent, getReplaceableEventIdentifier } from './event'
import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning'
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 { isTorBrowser } from './utils'
import logger from '@/lib/logger'
@ -72,9 +73,6 @@ export function getRelayListFromEvent( @@ -72,9 +73,6 @@ export function getRelayListFromEvent(
const torBrowserDetected = isTorBrowser()
const relayList = { write: [], read: [], originalRelays: [] } as Pick<TRelayList, 'write' | 'read' | 'originalRelays'>
// Normalize blocked relays for comparison
const normalizedBlockedRelays = (blockedRelays || []).map(url => normalizeUrl(url) || url)
event.tags.filter(tagNameEquals('r')).forEach(([, url, type]) => {
// Filter out empty, invalid, or malformed URLs
if (!url || typeof url !== 'string' || url.trim() === '' || url === 'ws://' || url === 'wss://') return
@ -83,8 +81,7 @@ export function getRelayListFromEvent( @@ -83,8 +81,7 @@ export function getRelayListFromEvent(
const normalizedUrl = normalizeUrl(url)
if (!normalizedUrl) return
// Filter out blocked relays
if (normalizedBlockedRelays.includes(normalizedUrl)) return
if (isRelayBlockedByUser(normalizedUrl, blockedRelays)) return
const scope = type === 'read' ? 'read' : type === 'write' ? 'write' : 'both'
relayList.originalRelays.push({ url: normalizedUrl, scope })
@ -135,7 +132,6 @@ export function getRelayListReadFromEventNoFastFallback( @@ -135,7 +132,6 @@ export function getRelayListReadFromEventNoFastFallback(
if (!event) return []
const torBrowserDetected = isTorBrowser()
const normalizedBlockedRelays = (blockedRelays || []).map((url) => normalizeUrl(url) || url)
const read: string[] = []
event.tags.filter(tagNameEquals('r')).forEach(([, url, type]) => {
@ -144,7 +140,7 @@ export function getRelayListReadFromEventNoFastFallback( @@ -144,7 +140,7 @@ export function getRelayListReadFromEventNoFastFallback(
const normalizedUrl = normalizeUrl(url)
if (!normalizedUrl) return
if (normalizedBlockedRelays.includes(normalizedUrl)) return
if (isRelayBlockedByUser(normalizedUrl, blockedRelays)) return
if (normalizedUrl.endsWith('.onion/') && !torBrowserDetected) return
if (type === 'write') return
@ -170,8 +166,6 @@ export function getHttpRelayListFromEvent(event?: Event | null, blockedRelays?: @@ -170,8 +166,6 @@ export function getHttpRelayListFromEvent(event?: Event | null, blockedRelays?:
if (!event) return out
const torBrowserDetected = isTorBrowser()
const normalizedBlockedRelays = (blockedRelays || []).map((url) => normalizeUrl(url) || url)
event.tags.filter(tagNameEquals('r')).forEach(([, url, type]) => {
if (!url || typeof url !== 'string' || url.trim() === '') return
if (!isHttpRelayUrl(url)) return
@ -179,9 +173,7 @@ export function getHttpRelayListFromEvent(event?: Event | null, blockedRelays?: @@ -179,9 +173,7 @@ export function getHttpRelayListFromEvent(event?: Event | null, blockedRelays?:
const normalizedUrl = normalizeHttpRelayUrl(url)
if (!normalizedUrl) return
const asWs = normalizeUrl(url)
if (asWs && normalizedBlockedRelays.includes(asWs)) return
if (normalizedBlockedRelays.includes(normalizedUrl)) return
if (isRelayBlockedByUser(normalizedUrl, blockedRelays)) return
const scope = type === 'read' ? 'read' : type === 'write' ? 'write' : 'both'
out.httpOriginalRelays.push({ url: normalizedUrl, scope })
@ -450,15 +442,12 @@ export function getPaymentInfoFromEvent(event: Event): TPaymentInfo | null { @@ -450,15 +442,12 @@ export function getPaymentInfoFromEvent(event: Event): TPaymentInfo | null {
export function getRelaySetFromEvent(event: Event, blockedRelays?: string[]): TRelaySet {
const id = getReplaceableEventIdentifier(event)
// Normalize blocked relays for comparison
const normalizedBlockedRelays = (blockedRelays || []).map(url => normalizeUrl(url) || url)
const relayUrls = event.tags
.filter(tagNameEquals('relay'))
.map((tag) => tag[1])
.filter((url) => url && isWebsocketUrl(url))
.map((url) => normalizeUrl(url))
.filter((url) => !normalizedBlockedRelays.includes(url)) // Filter out blocked relays
.filter((url): url is string => !!url && !isRelayBlockedByUser(url, blockedRelays))
let name = event.tags.find(tagNameEquals('title'))?.[1]
if (!name) {

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

@ -7,6 +7,7 @@ import { @@ -7,6 +7,7 @@ import {
relayFilterIncludesSocialKindBlockedKind
} from '@/constants'
import type { TFeedSubRequest } from '@/types'
import { isRelayBlockedByUser } from '@/lib/relay-blocked'
import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import {
buildPrioritizedReadRelayUrls,
@ -20,8 +21,9 @@ import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize @@ -20,8 +21,9 @@ import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize
import { relaySessionStrikes } from '@/lib/relay-strikes'
import { profileFetchRelayUrlsWithoutFastReadLayer } from '@/lib/viewer-relay-defaults'
const blockedSet = (blockedRelays: string[]) =>
new Set(blockedRelays.map((b) => normalizeAnyRelayUrl(b) || b))
function isBlockedRelay(url: string, blockedRelays: string[]): boolean {
return isRelayBlockedByUser(url, blockedRelays)
}
/**
* Logged-in users favorite relays (kind 10012 `relay` tags via {@link useFavoriteRelays}, plus bootstrap defaults
@ -45,10 +47,9 @@ export function getFavoritesFeedRelayUrls( @@ -45,10 +47,9 @@ export function getFavoritesFeedRelayUrls(
blockedRelays: string[],
useGlobalFavoriteDefaults = true
): string[] {
const blocked = blockedSet(blockedRelays)
const visible = favoriteRelays.filter((r) => {
const k = normalizeAnyRelayUrl(r) || r
return k && !blocked.has(k)
return k && !isBlockedRelay(r, blockedRelays)
})
const base = visible.length > 0 ? visible : useGlobalFavoriteDefaults ? DEFAULT_FAVORITE_RELAYS : []
return feedRelayPolicyUrls(
@ -70,13 +71,12 @@ export function mergeRelayUrlLayers( @@ -70,13 +71,12 @@ export function mergeRelayUrlLayers(
layers: readonly (readonly string[])[],
blockedRelays: string[]
): string[] {
const blocked = blockedSet(blockedRelays)
const seen = new Set<string>()
const out: string[] = []
for (const layer of layers) {
for (const u of layer) {
const k = normalizeAnyRelayUrl(u) || u
if (!k || blocked.has(k) || seen.has(k)) continue
if (!k || isBlockedRelay(u, blockedRelays) || seen.has(k)) continue
seen.add(k)
out.push(k)
}

79
src/lib/mobile-swipe-back.ts

@ -0,0 +1,79 @@ @@ -0,0 +1,79 @@
import { useCallback, useEffect, useRef } from 'react'
/** Swipes must start within this distance of the left screen edge (iOS-style back). */
export const MOBILE_SWIPE_BACK_EDGE_PX = 28
export const MOBILE_SWIPE_BACK_MIN_PX = 56
export const MOBILE_SWIPE_BACK_DOMINANCE = 1.25
export type UseMobileSwipeBackOnElementOptions = {
enabled?: boolean
edgePx?: number
}
/**
* Detect a rightward swipe from the left edge and invoke `onBack` (close secondary / drawer).
* Radix sheets and SPA history often block the native browser back gesture on mobile.
*/
export function useMobileSwipeBackOnElement(
element: HTMLElement | null,
onBack: () => void,
options: UseMobileSwipeBackOnElementOptions = {}
) {
const { enabled = true, edgePx = MOBILE_SWIPE_BACK_EDGE_PX } = options
const onBackRef = useRef(onBack)
onBackRef.current = onBack
const grabRef = useRef<{ x: number; y: number; pointerId: number } | null>(null)
const releaseCapture = (el: HTMLElement, pointerId: number) => {
try {
if (el.hasPointerCapture?.(pointerId)) el.releasePointerCapture(pointerId)
} catch {
/* ignore */
}
}
const finishSwipe = useCallback((clientX: number, clientY: number, pointerId: number, el: HTMLElement) => {
const grab = grabRef.current
grabRef.current = null
releaseCapture(el, pointerId)
if (!grab || grab.pointerId !== pointerId) return
const dx = clientX - grab.x
const dy = clientY - grab.y
const ax = Math.abs(dx)
const ay = Math.abs(dy)
if (dx < MOBILE_SWIPE_BACK_MIN_PX || ax < ay * MOBILE_SWIPE_BACK_DOMINANCE) return
onBackRef.current()
}, [])
useEffect(() => {
if (!element || !enabled) return
const onPointerDown = (e: PointerEvent) => {
if (e.button !== 0 || e.clientX > edgePx) return
grabRef.current = { x: e.clientX, y: e.clientY, pointerId: e.pointerId }
try {
element.setPointerCapture(e.pointerId)
} catch {
/* ignore */
}
}
const onPointerUp = (e: PointerEvent) => {
finishSwipe(e.clientX, e.clientY, e.pointerId, element)
}
const onPointerCancel = (e: PointerEvent) => {
grabRef.current = null
releaseCapture(element, e.pointerId)
}
element.addEventListener('pointerdown', onPointerDown)
element.addEventListener('pointerup', onPointerUp)
element.addEventListener('pointercancel', onPointerCancel)
return () => {
element.removeEventListener('pointerdown', onPointerDown)
element.removeEventListener('pointerup', onPointerUp)
element.removeEventListener('pointercancel', onPointerCancel)
}
}, [element, enabled, edgePx, finishSwipe])
}

26
src/lib/relay-blocked.ts

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
import { normalizeAnyRelayUrl } from '@/lib/url'
function relayHostname(url: string): string | null {
const normalized = normalizeAnyRelayUrl(url) || url.trim()
if (!normalized) return null
try {
return new URL(normalized).hostname.toLowerCase()
} catch {
return null
}
}
/** True when the relay matches a blocked URL or shares its hostname (https vs wss). */
export function isRelayBlockedByUser(url: string, blockedRelays?: readonly string[]): boolean {
if (!blockedRelays?.length) return false
const normalized = normalizeAnyRelayUrl(url) || url.trim()
if (!normalized) return false
const host = relayHostname(normalized)
for (const b of blockedRelays) {
const blockedNorm = normalizeAnyRelayUrl(b) || b.trim()
if (!blockedNorm) continue
if (blockedNorm === normalized) return true
if (host && relayHostname(blockedNorm) === host) return true
}
return false
}

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

@ -25,7 +25,7 @@ import type { Event } from 'nostr-tools' @@ -25,7 +25,7 @@ import type { Event } from 'nostr-tools'
export const AUTHOR_NIP65_RELAY_CAP = 2
function relayKey(url: string): string {
return (normalizeUrl(url) || normalizeAnyRelayUrl(url) || url.trim()).toLowerCase()
return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase()
}
/**
@ -159,10 +159,9 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio @@ -159,10 +159,9 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
personalRelayUrls.push(url)
}
const normalizedBlocked = new Set(
(blockedRelays || []).map(url => {
const normalized = normalizeUrl(url) || url
return normalized.toLowerCase()
}).filter((url): url is string => !!url)
(blockedRelays || [])
.map((url) => (normalizeAnyRelayUrl(url) || url).toLowerCase())
.filter(Boolean)
)
const addRelay = (url: string | undefined) => {
@ -537,7 +536,7 @@ export async function buildPollResultsReadRelayUrls(options: { @@ -537,7 +536,7 @@ export async function buildPollResultsReadRelayUrls(options: {
const normalizedBlocked = new Set(
blockedRelays
.map((url) => (normalizeUrl(url) || url).toLowerCase())
.map((url) => (normalizeAnyRelayUrl(url) || url).toLowerCase())
.filter(Boolean)
)

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

@ -0,0 +1,38 @@ @@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import {
canonicalRelaySessionKey,
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)
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', () => {
const wss = normalizeAnyRelayUrl('wss://nostr.land/')
expect(wss).toMatch(/^wss:\/\/nostr\.land\/?$/)
expect(normalizeUrl('wss://nostr.land/')).toMatch(/^wss:\/\/nostr\.land\/?$/)
})
it('rejects bare hostnames', () => {
expect(normalizeAnyRelayUrl('mercury-relay.imwald.eu')).toBe('')
expect(normalizeAnyRelayUrl('nostr.land')).toBe('')
})
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/')
expect(https).not.toBe(wss)
expect(https.startsWith('https://mercury-relay.imwald.eu')).toBe(true)
expect(wss.startsWith('wss://mercury-relay.imwald.eu')).toBe(true)
})
})

91
src/lib/url.ts

@ -73,70 +73,61 @@ export function devProxyCorsProblematicHttpsIndexRelayBase(normalizedBase: strin @@ -73,70 +73,61 @@ export function devProxyCorsProblematicHttpsIndexRelayBase(normalizedBase: strin
return `${window.location.origin}/dev-cors-index-relay`
}
/** Relay URLs must include an explicit `http:`, `https:`, `ws:`, or `wss:` scheme (no bare hostnames). */
export function relayUrlHasExplicitScheme(url: string): boolean {
return /^(https?|wss?):\/\//i.test(url.trim())
}
/**
* Normalize relay URL for deduplication: WebSocket URLs via {@link normalizeUrl}, HTTPS index relays via {@link normalizeHttpRelayUrl}.
* Normalize a relay URL without changing its transport: `https://` stays HTTPS, `wss://` stays WebSocket.
*/
export function normalizeAnyRelayUrl(url: string): string {
if (isHttpRelayUrl(url)) return normalizeHttpRelayUrl(url) || ''
return normalizeUrl(url) || ''
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 (isHttpRelayUrl(trimmed)) return normalizeHttpRelayUrl(trimmed) || ''
if (isWebsocketUrl(trimmed)) return normalizeUrl(trimmed) || ''
logger.warn('Unsupported relay URL scheme', { url: trimmed })
return ''
}
/**
* Stable key for per-relay session stats: HTTP NIP-86 bases map to the same hosts
* `wss://…` URL so `https://nos.lol` and `wss://nos.lol` share one bucket.
*/
/** Stable key for per-relay session stats (scheme preserved; no https→wss aliasing). */
export function canonicalRelaySessionKey(url: string): string {
const stepped = (normalizeAnyRelayUrl(url) || url.trim()).trim()
if (!stepped) return ''
if (isHttpRelayUrl(stepped)) {
const base = normalizeHttpRelayUrl(stepped) || stepped
try {
const u = new URL(base)
const host = u.hostname + (u.port ? `:${u.port}` : '')
return normalizeUrl(`wss://${host}`) || normalizeAnyRelayUrl(stepped) || base
} catch {
return normalizeAnyRelayUrl(stepped) || stepped
}
}
return stepped
return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase()
}
// copy from nostr-tools/utils
// copy from nostr-tools/utils — WebSocket relays only (`ws:` / `wss:`); never rewrite http(s) schemes.
export function normalizeUrl(url: string): string {
try {
if (url.indexOf('://') === -1) {
if (url.startsWith('localhost:') || url.startsWith('localhost/')) {
url = 'ws://' + url
} else {
url = 'wss://' + url
}
const trimmed = url.trim()
if (!trimmed) return ''
if (!trimmed.includes('://')) {
logger.warn('WebSocket relay URL requires ws: or wss: prefix', { url: trimmed })
return ''
}
// Parse the URL first to validate it
const p = new URL(url)
const p = new URL(trimmed)
stripTrailingCommasFromHostname(p)
// Check if URL has hash fragments (these are not valid for relay URLs)
// Note: Query parameters are allowed (e.g., filter.nostr.wine uses ?broadcast=true/false)
const hasHashFragment = url.includes('#')
if (p.protocol !== 'ws:' && p.protocol !== 'wss:') {
logger.warn('normalizeUrl expects ws: or wss: (use normalizeHttpRelayUrl for http(s))', { url: trimmed })
return ''
}
// Block URLs with hash fragments (these are not valid for relays)
const hasHashFragment = trimmed.includes('#')
if (hasHashFragment) {
logger.warn('Skipping URL with hash fragment (not a relay)', { url })
logger.warn('Skipping URL with hash fragment (not a relay)', { url: trimmed })
return ''
}
p.pathname = p.pathname.replace(/\/+/g, '/')
if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1)
if (p.protocol === 'https:') {
p.protocol = 'wss:'
} else if (p.protocol === 'http:') {
p.protocol = 'ws:'
}
// After protocol normalization, validate it's actually a websocket URL
if (!isWebsocketUrl(p.toString())) {
logger.warn('Skipping non-websocket URL', { url })
logger.warn('Skipping non-websocket URL', { url: trimmed })
return ''
}
@ -168,16 +159,20 @@ export function normalizeUrl(url: string): string { @@ -168,16 +159,20 @@ export function normalizeUrl(url: string): string {
export function normalizeHttpUrl(url: string): string {
try {
if (url.indexOf('://') === -1) url = 'https://' + url
const p = new URL(url)
const trimmed = url.trim()
if (!trimmed) return ''
if (!trimmed.includes('://')) {
logger.warn('HTTP relay 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, '/')
if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1)
if (p.protocol === 'wss:') {
p.protocol = 'https:'
} else if (p.protocol === 'ws:') {
p.protocol = 'http:'
}
if (
(p.port === '80' && p.protocol === 'http:') ||
(p.port === '443' && p.protocol === 'https:')

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

@ -28,7 +28,13 @@ import { @@ -28,7 +28,13 @@ import {
import { applyRelayNip42AckTimeout } from '@/lib/relay-nip42-tuning'
import { isIndexRelayTransportFailure, queryIndexRelay } from '@/lib/index-relay-http'
import logger from '@/lib/logger'
import { canonicalRelaySessionKey, isHttpRelayUrl, normalizeHttpRelayUrl, normalizeUrl } from '@/lib/url'
import {
canonicalRelaySessionKey,
isHttpRelayUrl,
normalizeAnyRelayUrl,
normalizeHttpRelayUrl,
normalizeUrl
} from '@/lib/url'
import { RelaySubscribeOpBatch, type RelayOpTerminalRow } from '@/services/relay-operation-log.service'
import { patchRelayNoticeForFetchFailures } from '@/services/relay-notice-fetch-failure'
import type { Filter, Event as NEvent } from 'nostr-tools'
@ -92,8 +98,8 @@ function logQueryReqConsolidatedEnd( @@ -92,8 +98,8 @@ function logQueryReqConsolidatedEnd(
}
const relayTotal = new Set([
...inputRelays.map((u) => normalizeUrl(u) || u),
...httpBases.map((u) => normalizeUrl(u) || u)
...inputRelays.map((u) => normalizeAnyRelayUrl(u) || u),
...httpBases.map((u) => normalizeHttpRelayUrl(u) || u)
]).size
let relaysWithHits = 0
@ -101,7 +107,7 @@ function logQueryReqConsolidatedEnd( @@ -101,7 +107,7 @@ function logQueryReqConsolidatedEnd(
const hitUrls = new Set<string>()
for (const e of events) {
for (const u of getSeenForEvent(e.id)) {
hitUrls.add(normalizeUrl(u) || u)
hitUrls.add(normalizeAnyRelayUrl(u) || u)
}
}
relaysWithHits = hitUrls.size
@ -482,7 +488,9 @@ export class QueryService { @@ -482,7 +488,9 @@ export class QueryService {
const reqId = ++queryReqSeq
const source = options?.relayOpSource ?? 'QueryService.query'
const inputRelaysOrdered = Array.from(new Set(urls.map((u) => normalizeUrl(u) || u).filter(Boolean)))
const inputRelaysOrdered = Array.from(
new Set(urls.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean))
)
const foreground = options?.foreground === true

3
src/services/client.service.ts

@ -427,6 +427,9 @@ class ClientService extends EventTarget { @@ -427,6 +427,9 @@ 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`)
}
const n = normalizeUrl(url) || url
const base = params?.connectionTimeout ?? RELAY_POOL_CONNECTION_TIMEOUT_MS
const connectionTimeout = READ_ONLY_RELAY_CONNECT_BOOST_URLS.has(n)

Loading…
Cancel
Save