feat: add Crew Network section to Settings with nostr-vpn deep link integration
- New "Crew Network" menu item in Settings > App, positioned below Relays
- Full CrewNetworkScreen sub-screen with:
- Hero explainer card (nostr-vpn private mesh VPN concept)
- Live QR code generated via `qrcode` canvas for the join-request URI
(nvpn://join-request?pubkey=<hex>&name=<displayName>&relay=...)
- Copy link button + "Open in app" deep link button
- Step-by-step "How It Works" list
- Download links for iOS App Store, TestFlight beta, and GitHub releases
- 2x2 use-case grid (private crew comms, secret spots, censorship bypass, offline mesh)
- GitHub "Learn more" row
- Logged-out state shows a LoginArea prompt
- TypeScript Section type extended with 'crewnetwork'
- Cleaned up unused Palette/Settings lucide imports
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
ceff6962ec
commit
fb6c1bca36
1 changed files with 261 additions and 3 deletions
|
|
@ -13,19 +13,18 @@ import { useTranslation } from '@/contexts/LanguageContext';
|
|||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { NSchema as n } from '@nostrify/nostrify';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { cn } from '@/lib/utils';
|
||||
import QRCode from 'qrcode';
|
||||
import {
|
||||
User,
|
||||
Palette,
|
||||
Radio,
|
||||
Wallet,
|
||||
Settings,
|
||||
Wrench,
|
||||
RotateCcw,
|
||||
Shield,
|
||||
|
|
@ -37,6 +36,12 @@ import {
|
|||
Package,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Network,
|
||||
Copy,
|
||||
Check,
|
||||
Smartphone,
|
||||
Download,
|
||||
Globe,
|
||||
} from 'lucide-react';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -51,6 +56,7 @@ type Section =
|
|||
| 'language'
|
||||
| 'appearance'
|
||||
| 'relays'
|
||||
| 'crewnetwork'
|
||||
| 'wallet'
|
||||
| 'stash'
|
||||
| 'advanced';
|
||||
|
|
@ -393,6 +399,247 @@ 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';
|
||||
|
||||
function CrewNetworkScreen({ onBack }: { onBack: () => void }) {
|
||||
const { user, metadata } = useCurrentUser();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [qrReady, setQrReady] = useState(false);
|
||||
|
||||
// Build the join-request deep link using the user's npub as device identity.
|
||||
// Format: nvpn://join-request?pubkey=<hex>&name=<displayName>
|
||||
// The nostr-vpn app uses the Nostr pubkey as stable device identity.
|
||||
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 {
|
||||
await QRCode.toCanvas(canvasRef.current, joinUri, {
|
||||
width: 220,
|
||||
margin: 2,
|
||||
color: { dark: '#000000', light: '#ffffff' },
|
||||
errorCorrectionLevel: 'M',
|
||||
});
|
||||
setQrReady(true);
|
||||
} catch {
|
||||
// silently ignore QR render errors
|
||||
}
|
||||
}, [joinUri]);
|
||||
|
||||
useEffect(() => {
|
||||
renderQR();
|
||||
}, [renderQR]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!joinUri) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(joinUri);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
toast({ title: 'Copied!', description: 'Join request link copied to clipboard.' });
|
||||
} catch {
|
||||
toast({ title: 'Copy failed', description: 'Could not copy to clipboard.', variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenApp = () => {
|
||||
// Try to open the native app via deep link; if not installed, fall through to App Store
|
||||
const link = document.createElement('a');
|
||||
link.href = joinUri;
|
||||
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' },
|
||||
];
|
||||
|
||||
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">
|
||||
<Network className="h-5 w-5 text-[#ccff00]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-black text-white uppercase tracking-wide leading-tight">Private Mesh VPN</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 mesh network for your crew.
|
||||
No accounts, no servers — your key is your identity. Encrypted peer-to-peer tunnels between skaters.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QR + join request — only if logged in */}
|
||||
{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 code canvas */}
|
||||
<div className="rounded-xl overflow-hidden bg-white p-2 shadow-lg shadow-black/40">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={cn('block transition-opacity duration-300', qrReady ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
{!qrReady && (
|
||||
<div className="w-[220px] h-[220px] flex items-center justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-black/20 border-t-black animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-2 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Show this QR to an admin in the nostr-vpn app.
|
||||
They scan it, approve you, and you're on the mesh.
|
||||
</p>
|
||||
|
||||
{/* URI preview */}
|
||||
<div className="rounded-xl bg-black/30 border border-white/8 px-3 py-2 text-left">
|
||||
<p className="text-[10px] font-mono text-muted-foreground/60 break-all leading-relaxed">
|
||||
{joinUri.length > 120 ? joinUri.slice(0, 120) + '…' : joinUri}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
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]"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-[#ccff00]" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copied ? 'Copied!' : 'Copy link'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOpenApp}
|
||||
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]"
|
||||
>
|
||||
<Network className="h-3.5 w-3.5" />
|
||||
Open in app
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/2 p-6 text-center">
|
||||
<Network className="h-8 w-8 mx-auto mb-3 text-muted-foreground/30" />
|
||||
<p className="text-sm font-bold text-white mb-1">Login required</p>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Log in with your Nostr keys to generate a join request for your crew's mesh network.
|
||||
</p>
|
||||
<LoginArea className="justify-center" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* How it works */}
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 px-1 mb-2">
|
||||
How It Works
|
||||
</p>
|
||||
<div className="rounded-2xl border border-white/8 bg-white/2 divide-y divide-white/5 overflow-hidden">
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<div key={n} className="flex items-center gap-3 px-4 py-3">
|
||||
<span 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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Download links */}
|
||||
<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">
|
||||
{platforms.map(({ label, icon: Icon, url, color, bg, badge }) => (
|
||||
<a
|
||||
key={label}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-white/5 active:scale-[0.99] transition-all"
|
||||
>
|
||||
<div className={cn('w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0', bg)}>
|
||||
<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>
|
||||
</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" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What skaters use this for */}
|
||||
<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">Use Cases</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
{ emoji: '🎬', label: 'Private crew comms' },
|
||||
{ emoji: '📍', label: 'Secret spot sharing' },
|
||||
{ emoji: '🌍', label: 'Censorship bypass' },
|
||||
{ emoji: '📶', label: 'Offline session mesh' },
|
||||
].map(({ emoji, label }) => (
|
||||
<div key={label} className="flex items-center gap-2 p-2.5 rounded-xl bg-white/3 border border-white/6">
|
||||
<span className="text-base leading-none">{emoji}</span>
|
||||
<p className="text-xs text-white/80 font-medium leading-tight">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Learn more */}
|
||||
<a
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<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" />
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main settings menu ───────────────────────────────────────────────────────
|
||||
|
||||
function SettingsMenu({ onNavigate }: { onNavigate: (s: Section) => void }) {
|
||||
|
|
@ -454,6 +701,15 @@ function SettingsMenu({ onNavigate }: { onNavigate: (s: Section) => void }) {
|
|||
iconBg="bg-cyan-500/15"
|
||||
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
|
||||
icon={Languages}
|
||||
label="Appearance"
|
||||
|
|
@ -520,6 +776,7 @@ export default function SettingsPage() {
|
|||
language: 'Language',
|
||||
appearance: 'Appearance',
|
||||
relays: 'Relays',
|
||||
crewnetwork: 'Crew Network',
|
||||
wallet: 'Wallet',
|
||||
stash: 'My Stash',
|
||||
advanced: 'Advanced',
|
||||
|
|
@ -554,6 +811,7 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
)}
|
||||
{section === 'relays' && <RelaysScreen onBack={handleBack} />}
|
||||
{section === 'crewnetwork' && <CrewNetworkScreen onBack={handleBack} />}
|
||||
{section === 'appearance' && <AppearanceScreen onBack={handleBack} />}
|
||||
{section === 'wallet' && <WalletScreen onBack={handleBack} />}
|
||||
{section === 'advanced' && <AdvancedScreen onBack={handleBack} />}
|
||||
|
|
|
|||
Loading…
Reference in a new issue