refactor(settings): remove the Crew Network (nostr-vpn) screen

Poor fit for a skate app: it only funneled users to install a third-party VPN
app (QR join request + download links + platform detection) with zero in-app
function. Removed the sub-screen, its Settings menu row, the section enum entry,
the render switch, the nostr-vpn constants, and the now-unused imports.

Gate: 101 tests, 0 errors/warnings, boot smoke green.
This commit is contained in:
SkateSpots 2026-09-26 23:45:28 +07:00
parent 4d00d0991b
commit 9e8f8b87de

View file

@ -16,7 +16,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react'; import { useNostr } from '@nostrify/react';
import { NSchema as n } from '@nostrify/nostrify'; import { NSchema as n } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState } from 'react';
import { LoginArea } from '@/components/auth/LoginArea'; import { LoginArea } from '@/components/auth/LoginArea';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
@ -25,8 +25,7 @@ import { useCitrine } from '@/hooks/useCitrine';
import { toast } from '@/hooks/useToast'; import { toast } from '@/hooks/useToast';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { NIP05_PROVIDERS } from '@/lib/nip05Providers'; import { NIP05_PROVIDERS } from '@/lib/nip05Providers';
import QRCode from 'qrcode'; import { User, Radio, Wallet, Wrench, RotateCcw, Shield, ExternalLink, Languages, RefreshCw, AlertCircle, Database, Package, ChevronRight, ChevronLeft, Smartphone, Download, Zap, WifiOff } from 'lucide-react';
import { User, Radio, Wallet, Wrench, RotateCcw, Shield, ExternalLink, Languages, RefreshCw, AlertCircle, Database, Package, ChevronRight, ChevronLeft, Network, Copy, Check, Smartphone, Download, Globe, Zap, WifiOff } from 'lucide-react';
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@ -40,7 +39,6 @@ type Section =
| 'language' | 'language'
| 'appearance' | 'appearance'
| 'relays' | 'relays'
| 'crewnetwork'
| 'wallet' | 'wallet'
| 'stash' | 'stash'
| 'advanced'; | 'advanced';
@ -659,358 +657,6 @@ function WalletScreen({ onBack }: { onBack: () => void }) {
); );
} }
// ─── Crew Network sub-screen ──────────────────────────────────────────────────
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<HTMLCanvasElement>(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=<hex>&name=<displayName>&relay=<relay>
const joinUri = user
? `nvpn://join-request?pubkey=${user.pubkey}&name=${encodeURIComponent(metadata?.name ?? 'Skater')}&relay=${encodeURIComponent('wss://goskatespots.nostr1.com')}`
: '';
const renderQR = useCallback(async () => {
if (!canvasRef.current || !joinUri) return;
try {
setQrError(false);
await QRCode.toCanvas(canvasRef.current, joinUri, {
width: 220,
margin: 2,
color: { dark: '#000000', light: '#ffffff' },
errorCorrectionLevel: 'M',
});
setQrReady(true);
} catch {
setQrError(true);
}
}, [joinUri]);
useEffect(() => {
renderQR();
}, [renderQR]);
const handleCopy = async () => {
if (!joinUri) return;
try {
await navigator.clipboard.writeText(joinUri);
setCopied(true);
setTimeout(() => setCopied(false), 2500);
toast({ title: 'Copied!', description: 'Join request link copied to clipboard.' });
} catch {
toast({ title: 'Copy failed', description: 'Could not access clipboard.', variant: 'destructive' });
}
};
// Save the QR canvas as a PNG the user can share.
const handleDownloadQR = () => {
if (!canvasRef.current || !qrReady) return;
const link = document.createElement('a');
link.download = 'nostr-vpn-join-request.png';
link.href = canvasRef.current.toDataURL('image/png');
link.click();
};
// 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 (
<div>
<BackHeader title="Crew Network" onBack={onBack} />
<div className="space-y-5">
{/* Hero explainer */}
<div className="relative rounded-2xl overflow-hidden border border-[#ccff00]/20 bg-gradient-to-br from-[#ccff00]/5 to-transparent p-5">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-2xl bg-[#ccff00]/15 flex items-center justify-center flex-shrink-0 mt-0.5">
<Shield className="h-5 w-5 text-[#ccff00]" aria-hidden="true" />
</div>
<div>
<p className="text-sm font-black text-white uppercase tracking-wide leading-tight">Private Encrypted Mesh</p>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
<strong className="text-white">nostr-vpn</strong> 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.
</p>
</div>
</div>
</div>
{/* QR + join request */}
{user ? (
<div className="space-y-3">
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 px-1">
Your Join Request
</p>
<div className="rounded-2xl border border-white/8 bg-white/2 p-5 flex flex-col items-center gap-4">
{/* QR canvas — fixed container prevents layout shift */}
<div className="relative w-[236px] h-[236px] flex-shrink-0">
<div className="absolute inset-0 rounded-xl overflow-hidden bg-white shadow-lg shadow-black/40 flex items-center justify-center">
{/* Loading spinner — shown until QR renders */}
{!qrReady && !qrError && (
<div
role="status"
aria-label="Generating QR code"
className="flex flex-col items-center gap-2"
>
<div className="w-8 h-8 rounded-full border-2 border-black/15 border-t-black animate-spin" />
<p className="text-[10px] text-black/40 font-medium">Generating…</p>
</div>
)}
{/* Error state */}
{qrError && (
<div className="flex flex-col items-center gap-2 px-4 text-center">
<AlertCircle className="h-6 w-6 text-red-400" />
<p className="text-[11px] text-black/60">QR failed to render</p>
<button
onClick={renderQR}
className="text-[11px] font-bold text-blue-600 underline"
>
Retry
</button>
</div>
)}
<canvas
ref={canvasRef}
role="img"
aria-label="nostr-vpn join request QR code — scan with the nostr-vpn app to join your crew network"
className={cn(
'block p-2 transition-opacity duration-300',
qrReady ? 'opacity-100' : 'opacity-0 absolute'
)}
/>
</div>
</div>
<div className="w-full space-y-2.5 text-center">
<p className="text-xs text-muted-foreground leading-relaxed">
Show this QR to an admin in the nostr-vpn app.
They scan → approve → you're on the encrypted mesh.
</p>
{/* URI display — improved contrast, selectable */}
<div className="rounded-xl bg-black/40 border border-white/10 px-3 py-2.5 text-left">
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 mb-1">Join URI</p>
<p
className="text-[10px] font-mono text-white/70 break-all leading-relaxed select-all cursor-text"
title="Tap to select all, then copy"
>
{joinUri.length > 130 ? joinUri.slice(0, 130) + '…' : joinUri}
</p>
</div>
{/* Actions row */}
<div className="flex gap-2 pt-0.5">
<button
onClick={handleCopy}
aria-label={copied ? 'Link copied to clipboard' : 'Copy join request link'}
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-bold text-white transition-all active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40"
>
{copied
? <Check className="h-3.5 w-3.5 text-[#ccff00]" aria-hidden="true" />
: <Copy className="h-3.5 w-3.5" aria-hidden="true" />}
{copied ? 'Copied!' : 'Copy link'}
</button>
<button
onClick={handleDownloadQR}
disabled={!qrReady}
aria-label="Download QR code as PNG image"
className="flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-bold text-white transition-all active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Download className="h-3.5 w-3.5" aria-hidden="true" />
Save QR
</button>
<button
onClick={handleOpenApp}
disabled={openingApp}
aria-label="Open nostr-vpn app with this join request"
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl bg-[#ccff00]/15 hover:bg-[#ccff00]/25 border border-[#ccff00]/30 text-xs font-bold text-[#ccff00] transition-all active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#ccff00]/50 disabled:opacity-60"
>
<Network className="h-3.5 w-3.5" aria-hidden="true" />
{openingApp ? 'Opening…' : 'Open app'}
</button>
</div>
{/* Deep link hint */}
<p className="text-[10px] text-muted-foreground/40 leading-tight">
"Open app" requires nostr-vpn installed. If not installed, you'll be redirected to the download page.
</p>
</div>
</div>
</div>
) : (
<div className="rounded-2xl border border-white/8 bg-white/2 p-6 text-center">
<Shield className="h-8 w-8 mx-auto mb-3 text-muted-foreground/30" aria-hidden="true" />
<p className="text-sm font-bold text-white mb-1">Login required</p>
<p className="text-xs text-muted-foreground mb-4 leading-relaxed">
Log in with your Nostr keys to generate a personal join request QR for your crew's encrypted mesh.
</p>
<LoginArea className="justify-center" />
</div>
)}
{/* How it works — steps adapt based on login state */}
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 px-1 mb-2">
How It Works
</p>
<ol className="rounded-2xl border border-white/8 bg-white/2 divide-y divide-white/5 overflow-hidden" aria-label="Steps to join a crew network">
{steps.map(({ n, text }) => (
<li key={n} className="flex items-center gap-3 px-4 py-3">
<span
aria-hidden="true"
className="w-6 h-6 rounded-full bg-[#ccff00]/15 text-[#ccff00] text-[11px] font-black flex items-center justify-center flex-shrink-0"
>
{n}
</span>
<p className="text-xs text-muted-foreground leading-snug">{text}</p>
</li>
))}
</ol>
</div>
{/* Download links — platform-sorted */}
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 px-1 mb-2">
Get nostr-vpn
</p>
<div className="rounded-2xl border border-white/8 overflow-hidden divide-y divide-white/5 bg-white/2" role="list" aria-label="Download options">
{platforms.map(({ id, label, sublabel, icon: Icon, url, color, bg, badge }) => (
<a
key={id}
href={url}
target="_blank"
rel="noopener noreferrer"
role="listitem"
aria-label={`${label} — ${badge}`}
className="flex items-center gap-3 px-4 py-3 hover:bg-white/5 active:scale-[0.99] transition-all focus-visible:outline-none focus-visible:ring-inset focus-visible:ring-2 focus-visible:ring-white/30"
>
<div className={cn('w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0', bg)} aria-hidden="true">
<Icon className={cn('h-4 w-4', color)} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-bold text-white leading-tight">{label}</p>
<p className="text-[11px] text-muted-foreground leading-tight mt-0.5">{sublabel}</p>
</div>
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-white/5 text-muted-foreground border border-white/10 flex-shrink-0">
{badge}
</span>
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground/40 flex-shrink-0" aria-hidden="true" />
</a>
))}
</div>
</div>
{/* Use cases */}
<div className="rounded-2xl border border-white/8 bg-white/2 p-4 space-y-3">
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50">Why Skaters Use It</p>
<ul className="grid grid-cols-2 gap-2" aria-label="Use cases">
{[
{ emoji: '🔒', label: 'Private crew comms' },
{ emoji: '📍', label: 'Secret spot sharing' },
{ emoji: '🌍', label: 'Censorship bypass' },
{ emoji: '📶', label: 'Offline session mesh' },
].map(({ emoji, label }) => (
<li key={label} className="flex items-center gap-2 p-2.5 rounded-xl bg-white/[0.03] border border-white/[0.06]">
<span className="text-base leading-none" aria-hidden="true">{emoji}</span>
<p className="text-xs text-white/80 font-medium leading-tight">{label}</p>
</li>
))}
</ul>
</div>
{/* Learn more */}
<a
href="https://github.com/mmalmi/nostr-vpn"
target="_blank"
rel="noopener noreferrer"
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"
>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-white/8 flex items-center justify-center flex-shrink-0" aria-hidden="true">
<Globe className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-bold text-white">nostr-vpn on GitHub</p>
<p className="text-xs text-muted-foreground">Source code, docs, protocol spec</p>
</div>
</div>
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground/40 flex-shrink-0" aria-hidden="true" />
</a>
</div>
</div>
);
}
// ─── Main settings menu ─────────────────────────────────────────────────────── // ─── Main settings menu ───────────────────────────────────────────────────────
function SettingsMenu({ onNavigate }: { onNavigate: (s: Section) => void }) { function SettingsMenu({ onNavigate }: { onNavigate: (s: Section) => void }) {
@ -1072,15 +718,6 @@ function SettingsMenu({ onNavigate }: { onNavigate: (s: Section) => void }) {
iconBg="bg-cyan-500/15" iconBg="bg-cyan-500/15"
onClick={() => onNavigate('relays')} onClick={() => onNavigate('relays')}
/> />
<MenuRow
icon={Network}
label="Crew Network"
sublabel="Private mesh VPN via nostr-vpn"
iconColor="text-[#ccff00]"
iconBg="bg-[#ccff00]/10"
badge="NEW"
onClick={() => onNavigate('crewnetwork')}
/>
<MenuRow <MenuRow
icon={Languages} icon={Languages}
label="Appearance" label="Appearance"
@ -1136,7 +773,7 @@ export default function SettingsPage() {
// stale bookmark to ?section=language) falls back to the menu rather than a // stale bookmark to ?section=language) falls back to the menu rather than a
// blank screen with no way back. // blank screen with no way back.
const RENDERABLE_SECTIONS: Section[] = [ const RENDERABLE_SECTIONS: Section[] = [
'profile', 'security', 'nip05', 'mydata', 'relays', 'crewnetwork', 'appearance', 'wallet', 'advanced', 'profile', 'security', 'nip05', 'mydata', 'relays', 'appearance', 'wallet', 'advanced',
]; ];
const requestedSection = searchParams.get('section') as Section; const requestedSection = searchParams.get('section') as Section;
const initialSection = const initialSection =
@ -1175,7 +812,6 @@ export default function SettingsPage() {
</div> </div>
)} )}
{section === 'relays' && <RelaysScreen onBack={handleBack} />} {section === 'relays' && <RelaysScreen onBack={handleBack} />}
{section === 'crewnetwork' && <CrewNetworkScreen onBack={handleBack} />}
{section === 'appearance' && <AppearanceScreen onBack={handleBack} />} {section === 'appearance' && <AppearanceScreen onBack={handleBack} />}
{section === 'wallet' && <WalletScreen onBack={handleBack} />} {section === 'wallet' && <WalletScreen onBack={handleBack} />}
{section === 'advanced' && <AdvancedScreen onBack={handleBack} />} {section === 'advanced' && <AdvancedScreen onBack={handleBack} />}