diff --git a/src/PageManager.tsx b/src/PageManager.tsx index ee4fae92..e3783d5e 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -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 }) { currentPrimaryPageRef.current = currentPrimaryPage }, [currentPrimaryPage]) const navigationCounterRef = useRef(0) + const goBackRef = useRef<() => void>(() => {}) + const [mobilePrimarySwipeRoot, setMobilePrimarySwipeRoot] = useState(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 }) { } 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 }) { 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 }) { {primaryNoteView ? ( // Show primary note view with back button on mobile -
+
@@ -2290,7 +2307,13 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { { + if (open) { + setDrawerOpen(true) + return + } + popSecondaryPage() + }} noteId={drawerNoteId} /> )} diff --git a/src/components/FavoriteRelaysSetting/AddBlockedRelay.tsx b/src/components/FavoriteRelaysSetting/AddBlockedRelay.tsx index 3dfc7409..3a5cee93 100644 --- a/src/components/FavoriteRelaysSetting/AddBlockedRelay.tsx +++ b/src/components/FavoriteRelaysSetting/AddBlockedRelay.tsx @@ -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() { const saveRelay = async () => { if (!input || isLoading) return - const normalizedUrl = normalizeUrl(input) + const normalizedUrl = normalizeAnyRelayUrl(input) if (!normalizedUrl) { setErrorMsg(t('Invalid URL')) setSuccessMsg('') diff --git a/src/components/FavoriteRelaysSetting/AddNewRelay.tsx b/src/components/FavoriteRelaysSetting/AddNewRelay.tsx index 7f224b68..e809f64b 100644 --- a/src/components/FavoriteRelaysSetting/AddNewRelay.tsx +++ b/src/components/FavoriteRelaysSetting/AddNewRelay.tsx @@ -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() { const saveRelay = async () => { if (!input || isLoading) return - const normalizedUrl = normalizeUrl(input) + const normalizedUrl = normalizeAnyRelayUrl(input) if (!normalizedUrl) { setErrorMsg(t('Invalid URL')) return diff --git a/src/components/FavoriteRelaysSetting/RelayUrl.tsx b/src/components/FavoriteRelaysSetting/RelayUrl.tsx index 5844f0c5..1740766a 100644 --- a/src/components/FavoriteRelaysSetting/RelayUrl.tsx +++ b/src/components/FavoriteRelaysSetting/RelayUrl.tsx @@ -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 }) { const saveNewRelayUrl = async () => { if (newRelayUrl === '' || isLoading) return - const normalizedUrl = normalizeUrl(newRelayUrl) + const normalizedUrl = normalizeAnyRelayUrl(newRelayUrl) if (!normalizedUrl) { return setNewRelayUrlError(t('Invalid relay URL')) } diff --git a/src/components/HelpAndAccountMenu.tsx b/src/components/HelpAndAccountMenu.tsx index 4b1b7bfb..c8284704 100644 --- a/src/components/HelpAndAccountMenu.tsx +++ b/src/components/HelpAndAccountMenu.tsx @@ -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({ 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 setLoginDialogOpen(true)} onLogoutClick={() => setLogoutDialogOpen(true)} - onBrowseCache={openBrowseCache} + onBrowseCache={onBrowseCache} /> ) : ( setLoginDialogOpen(true)} onLogoutClick={() => setLogoutDialogOpen(true)} - onBrowseCache={openBrowseCache} + onBrowseCache={onBrowseCache} /> ) } else if (variant === 'sidebar') { diff --git a/src/components/MailboxSetting/DiscoveredRelays.tsx b/src/components/MailboxSetting/DiscoveredRelays.tsx index dd09ce57..ddad9fdb 100644 --- a/src/components/MailboxSetting/DiscoveredRelays.tsx +++ b/src/components/MailboxSetting/DiscoveredRelays.tsx @@ -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: 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: 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, diff --git a/src/components/MailboxSetting/index.tsx b/src/components/MailboxSetting/index.tsx index c5051ecd..abfa3742 100644 --- a/src/components/MailboxSetting/index.tsx +++ b/src/components/MailboxSetting/index.tsx @@ -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() { const saveNewMailboxRelay = (url: string) => { if (url === '') return null - const normalizedUrl = normalizeUrl(url) + const normalizedUrl = normalizeHttpRelayUrl(url) if (!normalizedUrl) { return t('Invalid relay URL') } diff --git a/src/components/NoteDrawer/index.tsx b/src/components/NoteDrawer/index.tsx index 91a5339b..a5649a3d 100644 --- a/src/components/NoteDrawer/index.tsx +++ b/src/components/NoteDrawer/index.tsx @@ -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 { } export default function NoteDrawer({ open, onOpenChange, noteId, initialEvent }: NoteDrawerProps) { - const { currentIndex } = useSecondaryPage() + const { currentIndex, pop } = useSecondaryPage() const [displayNoteId, setDisplayNoteId] = useState(noteId) + const [swipeRoot, setSwipeRoot] = useState(null) const timeoutRef = useRef(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 }: onPointerDownOutside={(e) => preventRadixSheetCloseForPortaledOverlay(e)} onInteractOutside={(e) => preventRadixSheetCloseForPortaledOverlay(e)} > -
+
void @@ -7,11 +15,30 @@ type CacheBrowserContextValue = { const CacheBrowserContext = createContext(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 ( {children} diff --git a/src/features/feed/relay-policy.ts b/src/features/feed/relay-policy.ts index c0a3d398..f7890c6a 100644 --- a/src/features/feed/relay-policy.ts +++ b/src/features/feed/relay-policy.ts @@ -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( 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( addDrop(dropped, normalized, layer.source, 'duplicate') continue } - if (blocked.has(key)) { + if (isRelayBlockedByUser(normalized, context.blockedRelays)) { addDrop(dropped, normalized, layer.source, 'user-blocked') continue } diff --git a/src/layouts/SecondaryPageLayout/index.tsx b/src/layouts/SecondaryPageLayout/index.tsx index 8561d3d1..e01ff3fc 100644 --- a/src/layouts/SecondaryPageLayout/index.tsx +++ b/src/layouts/SecondaryPageLayout/index.tsx @@ -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( ) => { const scrollAreaRef = useRef(null) const { isSmallScreen } = useScreenSize() - const { currentIndex } = useSecondaryPage() + const { currentIndex, pop } = useSecondaryPage() + const [mobileSwipeRoot, setMobileSwipeRoot] = useState(null) + const mobileSwipeActive = isSmallScreen && currentIndex === index + useMobileSwipeBackOnElement(mobileSwipeActive ? mobileSwipeRoot : null, pop, { + enabled: mobileSwipeActive + }) useImperativeHandle( ref, @@ -87,6 +93,7 @@ const SecondaryPageLayout = forwardRef( return (
- // 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( 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( 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( 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?: 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?: 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 { 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) { diff --git a/src/lib/favorites-feed-relays.ts b/src/lib/favorites-feed-relays.ts index 5c80fa48..ff6bc6e7 100644 --- a/src/lib/favorites-feed-relays.ts +++ b/src/lib/favorites-feed-relays.ts @@ -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 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 user’s favorite relays (kind 10012 `relay` tags via {@link useFavoriteRelays}, plus bootstrap defaults @@ -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( layers: readonly (readonly string[])[], blockedRelays: string[] ): string[] { - const blocked = blockedSet(blockedRelays) const seen = new Set() 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) } diff --git a/src/lib/mobile-swipe-back.ts b/src/lib/mobile-swipe-back.ts new file mode 100644 index 00000000..d2c60a8d --- /dev/null +++ b/src/lib/mobile-swipe-back.ts @@ -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]) +} diff --git a/src/lib/relay-blocked.ts b/src/lib/relay-blocked.ts new file mode 100644 index 00000000..51713f99 --- /dev/null +++ b/src/lib/relay-blocked.ts @@ -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 +} diff --git a/src/lib/relay-list-builder.ts b/src/lib/relay-list-builder.ts index 44bff01d..149ca08c 100644 --- a/src/lib/relay-list-builder.ts +++ b/src/lib/relay-list-builder.ts @@ -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 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: { const normalizedBlocked = new Set( blockedRelays - .map((url) => (normalizeUrl(url) || url).toLowerCase()) + .map((url) => (normalizeAnyRelayUrl(url) || url).toLowerCase()) .filter(Boolean) ) diff --git a/src/lib/relay-url-normalize.test.ts b/src/lib/relay-url-normalize.test.ts new file mode 100644 index 00000000..af0661e3 --- /dev/null +++ b/src/lib/relay-url-normalize.test.ts @@ -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) + }) +}) diff --git a/src/lib/url.ts b/src/lib/url.ts index 985a1e72..6d576b85 100644 --- a/src/lib/url.ts +++ b/src/lib/url.ts @@ -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 host’s - * `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('#') - - // Block URLs with hash fragments (these are not valid for relays) + if (p.protocol !== 'ws:' && p.protocol !== 'wss:') { + logger.warn('normalizeUrl expects ws: or wss: (use normalizeHttpRelayUrl for http(s))', { url: trimmed }) + return '' + } + + 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 { 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:') diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index 1be77363..ca87b8ac 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -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( } 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( const hitUrls = new Set() 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 { 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 diff --git a/src/services/client.service.ts b/src/services/client.service.ts index e4c0e58b..f22bbbae 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -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)