20 changed files with 315 additions and 110 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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