From 05c7b76d6d349ff1740a4eca38f82bee8eb3a1f7 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 23 Jul 2026 22:23:20 +0700 Subject: [PATCH] fix(crew-network): comprehensive UX/accessibility overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QR rendering: - Fixed layout shift (CLS) — canvas and spinner now occupy the same fixed 236×236 container; spinner fades out, canvas fades in, no height jump - Added error state with Retry button when QRCode.toCanvas() throws - Canvas now has role="img" + descriptive aria-label for screen readers - "Save QR" button exports canvas → PNG via toDataURL (mobile share flow) Deep link / "Open in app": - Replaced document.createElement('a').click() with window.location.href (the only reliable way to fire custom schemes on iOS Safari PWA) - Added 2 s fallback timeout: if the page doesn't lose focus (app not installed), automatically redirects to App Store / GitHub releases - Button shows "Opening…" state while the timeout is pending - Added hint text explaining the fallback behaviour Platform detection: - detectPlatform() reads navigator.userAgent to identify iOS/Android/desktop - Download list is sorted so the most-relevant entry appears first - Each download row now has a sublabel (e.g. "iPhone & iPad", "arm64 devices") - Added Android APK entry (was missing) Accessibility: - All interactive buttons have focus-visible:ring-2 keyboard focus rings - Decorative icons marked aria-hidden="true" throughout - "How it works" list changed from
to
    /
  1. with aria-label - Download list uses role="list" / role="listitem" - Use-cases grid uses
      /
    • with aria-label - "Learn more" link has full descriptive aria-label inc. "(opens in new tab)" - Action buttons have aria-label for screen readers Contrast & copy: - URI preview: text-muted-foreground/60 → text-white/70 (passes WCAG AA) - URI box is now select-all / cursor-text for easy manual copy - Hero icon changed from Network → Shield (semantically: encrypted/private) - Step 2 text adapts when logged out ("Log in to generate your QR") - Use-case emoji changed 🎬→🔒 for "Private crew comms" (more on-message) Co-authored-by: shakespeare.diy --- src/pages/SettingsPage.tsx | 263 ++++++++++++++++++++++++++----------- 1 file changed, 187 insertions(+), 76 deletions(-) diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 2eb39a4..15ea972 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -404,16 +404,42 @@ function WalletScreen({ onBack }: { onBack: () => void }) { const NOSTR_VPN_APP_STORE = 'https://apps.apple.com/app/nostr-vpn/id6785410348'; const NOSTR_VPN_GITHUB = 'https://github.com/mmalmi/nostr-vpn/releases/latest'; const NOSTR_VPN_TESTFLIGHT = 'https://testflight.apple.com/join/58sg4agv'; +const NOSTR_VPN_ANDROID = 'https://github.com/mmalmi/nostr-vpn/releases/latest'; + +/** Detect the user's OS/platform to surface the most relevant download first. */ +function detectPlatform(): 'ios' | 'android' | 'desktop' { + const ua = navigator.userAgent; + if (/android/i.test(ua)) return 'android'; + if (/iphone|ipad|ipod/i.test(ua)) return 'ios'; + return 'desktop'; +} + +const ALL_PLATFORMS = [ + { id: 'ios', label: 'iOS App Store', sublabel: 'iPhone & iPad', icon: Smartphone, url: NOSTR_VPN_APP_STORE, color: 'text-blue-400', bg: 'bg-blue-500/15', badge: 'Free' }, + { id: 'testflight',label: 'iOS TestFlight', sublabel: 'Beta builds', icon: Smartphone, url: NOSTR_VPN_TESTFLIGHT, color: 'text-indigo-400', bg: 'bg-indigo-500/15', badge: 'Beta' }, + { id: 'android', label: 'Android APK', sublabel: 'arm64 devices', icon: Smartphone, url: NOSTR_VPN_ANDROID, color: 'text-green-400', bg: 'bg-green-500/15', badge: 'APK' }, + { id: 'desktop', label: 'macOS / Linux / Windows',sublabel: 'Native desktop apps', icon: Download, url: NOSTR_VPN_GITHUB, color: 'text-amber-400', bg: 'bg-amber-500/15', badge: 'Open source' }, +]; function CrewNetworkScreen({ onBack }: { onBack: () => void }) { const { user, metadata } = useCurrentUser(); const canvasRef = useRef(null); const [copied, setCopied] = useState(false); const [qrReady, setQrReady] = useState(false); + const [qrError, setQrError] = useState(false); + const [openingApp, setOpeningApp] = useState(false); + + // Detect platform once — sort most-relevant download to the top. + const platform = detectPlatform(); + const platforms = [...ALL_PLATFORMS].sort((a) => { + if (platform === 'ios' && (a.id === 'ios' || a.id === 'testflight')) return -1; + if (platform === 'android' && a.id === 'android') return -1; + if (platform === 'desktop' && a.id === 'desktop') return -1; + return 0; + }); // Build the join-request deep link using the user's npub as device identity. - // Format: nvpn://join-request?pubkey=&name= - // The nostr-vpn app uses the Nostr pubkey as stable device identity. + // Format: nvpn://join-request?pubkey=&name=&relay= const joinUri = user ? `nvpn://join-request?pubkey=${user.pubkey}&name=${encodeURIComponent(metadata?.name ?? 'Skater')}&relay=${encodeURIComponent('wss://goskatespots.nostr1.com')}` : ''; @@ -421,6 +447,7 @@ function CrewNetworkScreen({ onBack }: { onBack: () => void }) { const renderQR = useCallback(async () => { if (!canvasRef.current || !joinUri) return; try { + setQrError(false); await QRCode.toCanvas(canvasRef.current, joinUri, { width: 220, margin: 2, @@ -429,7 +456,7 @@ function CrewNetworkScreen({ onBack }: { onBack: () => void }) { }); setQrReady(true); } catch { - // silently ignore QR render errors + setQrError(true); } }, [joinUri]); @@ -442,24 +469,56 @@ function CrewNetworkScreen({ onBack }: { onBack: () => void }) { try { await navigator.clipboard.writeText(joinUri); setCopied(true); - setTimeout(() => setCopied(false), 2000); + setTimeout(() => setCopied(false), 2500); toast({ title: 'Copied!', description: 'Join request link copied to clipboard.' }); } catch { - toast({ title: 'Copy failed', description: 'Could not copy to clipboard.', variant: 'destructive' }); + toast({ title: 'Copy failed', description: 'Could not access clipboard.', variant: 'destructive' }); } }; - const handleOpenApp = () => { - // Try to open the native app via deep link; if not installed, fall through to App Store + // Save the QR canvas as a PNG the user can share. + const handleDownloadQR = () => { + if (!canvasRef.current || !qrReady) return; const link = document.createElement('a'); - link.href = joinUri; + link.download = 'nostr-vpn-join-request.png'; + link.href = canvasRef.current.toDataURL('image/png'); link.click(); }; - const platforms = [ - { label: 'iOS App Store', icon: Smartphone, url: NOSTR_VPN_APP_STORE, color: 'text-blue-400', bg: 'bg-blue-500/15', badge: 'Free' }, - { label: 'iOS TestFlight', icon: Smartphone, url: NOSTR_VPN_TESTFLIGHT, color: 'text-indigo-400', bg: 'bg-indigo-500/15', badge: 'Beta' }, - { label: 'macOS / Linux / Windows', icon: Download, url: NOSTR_VPN_GITHUB, color: 'text-green-400', bg: 'bg-green-500/15', badge: 'Open source' }, + // Open the deep link on the device. On iOS Safari, window.location.href inside + // a user-gesture handler is the only reliable way to fire custom schemes. + // After 2 s with no visibility change, assume the app isn't installed and + // redirect to the correct store page. + const handleOpenApp = () => { + if (!joinUri) return; + setOpeningApp(true); + + const fallbackUrl = platform === 'android' ? NOSTR_VPN_ANDROID : NOSTR_VPN_APP_STORE; + + const timeout = setTimeout(() => { + // Still here → app not installed → go to store/release page + window.location.href = fallbackUrl; + }, 2000); + + // Cancel the fallback if the page loses focus (app opened successfully). + const cancel = () => { + clearTimeout(timeout); + setOpeningApp(false); + }; + window.addEventListener('blur', cancel, { once: true }); + document.addEventListener('visibilitychange', () => { + if (document.hidden) cancel(); + }, { once: true }); + + window.location.href = joinUri; + }; + + // Steps adapt: if the user is logged out, step 2 says "log in" instead of "show QR". + const steps = [ + { n: '1', text: 'Install nostr-vpn on your device (free, open source)' }, + { n: '2', text: user ? 'Show your QR code above to a crew admin' : 'Log in to SkateSpots to generate your personal QR' }, + { n: '3', text: 'Admin approves — you receive a cryptographically signed roster' }, + { n: '4', text: 'WireGuard-grade encrypted mesh tunnel connects you to your crew' }, ]; return ( @@ -471,149 +530,200 @@ function CrewNetworkScreen({ onBack }: { onBack: () => void }) {
      - +
      -

      Private Mesh VPN

      +

      Private Encrypted Mesh

      - nostr-vpn turns your Nostr keys into a private mesh network for your crew. - No accounts, no servers — your key is your identity. Encrypted peer-to-peer tunnels between skaters. + nostr-vpn turns your Nostr keys into a private WireGuard mesh for your crew. + No accounts, no central server — your key is your identity. Your crew, your network.

      - {/* QR + join request — only if logged in */} + {/* QR + join request */} {user ? (

      Your Join Request

      - {/* QR code canvas */} -
      - - {!qrReady && ( -
      -
      -
      - )} + + {/* QR canvas — fixed container prevents layout shift */} +
      +
      + {/* Loading spinner — shown until QR renders */} + {!qrReady && !qrError && ( +
      +
      +

      Generating…

      +
      + )} + {/* Error state */} + {qrError && ( +
      + +

      QR failed to render

      + +
      + )} + +
      -
      -

      +

      +

      Show this QR to an admin in the nostr-vpn app. - They scan it, approve you, and you're on the mesh. + They scan → approve → you're on the encrypted mesh.

      - {/* URI preview */} -
      -

      - {joinUri.length > 120 ? joinUri.slice(0, 120) + '…' : joinUri} + {/* URI display — improved contrast, selectable */} +

      +

      Join URI

      +

      + {joinUri.length > 130 ? joinUri.slice(0, 130) + '…' : joinUri}

      - {/* Actions */} -
      + {/* Actions row */} +
      +
      + + {/* Deep link hint */} +

      + "Open app" requires nostr-vpn installed. If not installed, you'll be redirected to the download page. +

      ) : (
      - +
      )} - {/* How it works */} + {/* How it works — steps adapt based on login state */}

      How It Works

      -
      - {[ - { n: '1', text: 'Install nostr-vpn on your device (free, open source)' }, - { n: '2', text: 'Show your QR code above to a crew admin' }, - { n: '3', text: 'Admin approves — you get a signed roster back' }, - { n: '4', text: 'Encrypted mesh tunnel connects you to your crew' }, - ].map(({ n, text }) => ( -
      - +
        + {steps.map(({ n, text }) => ( +
      1. +

        {text}

        -
      +
    • ))} -
+ - {/* Download links */} + {/* Download links — platform-sorted */}

Get nostr-vpn

-
- {platforms.map(({ label, icon: Icon, url, color, bg, badge }) => ( +
+ {platforms.map(({ id, label, sublabel, icon: Icon, url, color, bg, badge }) => ( -
+

{label}

+

{sublabel}

{badge} - +
))}
- {/* What skaters use this for */} + {/* Use cases */}
-

Use Cases

-
+

Why Skaters Use It

+
    {[ - { emoji: '🎬', label: 'Private crew comms' }, + { emoji: '🔒', label: 'Private crew comms' }, { emoji: '📍', label: 'Secret spot sharing' }, { emoji: '🌍', label: 'Censorship bypass' }, { emoji: '📶', label: 'Offline session mesh' }, ].map(({ emoji, label }) => ( -
    - {emoji} +
  • +

    {label}

    -
  • + ))} -
+
{/* Learn more */} @@ -621,10 +731,11 @@ function CrewNetworkScreen({ onBack }: { onBack: () => void }) { href="https://github.com/mmalmi/nostr-vpn" target="_blank" rel="noopener noreferrer" - className="flex items-center justify-between px-4 py-3.5 rounded-2xl border border-white/8 bg-white/2 hover:bg-white/5 transition-all" + aria-label="nostr-vpn on GitHub — source code, docs, protocol spec (opens in new tab)" + className="flex items-center justify-between px-4 py-3.5 rounded-2xl border border-white/8 bg-white/2 hover:bg-white/5 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30" >
-
+
@@ -632,7 +743,7 @@ function CrewNetworkScreen({ onBack }: { onBack: () => void }) {

Source code, docs, protocol spec

- +