20 changed files with 315 additions and 110 deletions
@ -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]) |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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) |
||||
}) |
||||
}) |
||||
Loading…
Reference in new issue