Replace NRelay1 async iterator (unreliable) with nostr-tools SimplePool.subscribeMany() using onevent callback — same pattern as Ditto/Coracle/Nostrudel. - Switch to relay.nsec.app, relay.damus.io, relay.primal.net (support ephemeral events) - Use nip04.decrypt() from nostr-tools for Amber's connect response decryption - Proper cleanup of pool and subscription on abort/close - Fall back to signerPubkey as userPubkey if NConnectSigner.getPublicKey() fails Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
524 lines
21 KiB
TypeScript
524 lines
21 KiB
TypeScript
// NOTE: This file is stable and usually should not be modified.
|
|
// It is important that all functionality in this file is preserved, and should only be modified if explicitly requested.
|
|
|
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { Shield, UserPlus, Sparkles, Cloud, Smartphone, Check, Loader2, Copy, RefreshCw } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { useLoginActions } from '@/hooks/useLoginActions';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { cn } from '@/lib/utils';
|
|
import { NLogin } from '@nostrify/react/login';
|
|
import { useNostrLogin } from '@nostrify/react/login';
|
|
import { NConnectSigner, NSecSigner, NRelay1 } from '@nostrify/nostrify';
|
|
import { SimplePool, generateSecretKey, getPublicKey, nip19, nip04 } from 'nostr-tools';
|
|
|
|
interface LoginDialogProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onLogin: () => void;
|
|
onSignup?: () => void;
|
|
}
|
|
|
|
// Relays used for NIP-46 nostrconnect:// handshake
|
|
// Use relays known to support ephemeral events (kind 24133)
|
|
const NOSTRCONNECT_RELAYS = [
|
|
'wss://relay.nsec.app',
|
|
'wss://relay.damus.io',
|
|
'wss://relay.primal.net',
|
|
];
|
|
|
|
/** Build a nostrconnect:// URI per NIP-46 spec */
|
|
function buildNostrConnectURI(opts: {
|
|
clientPubkey: string;
|
|
relays: string[];
|
|
secret: string;
|
|
name?: string;
|
|
url?: string;
|
|
perms?: string;
|
|
}): string {
|
|
const params = new URLSearchParams();
|
|
for (const r of opts.relays) {
|
|
params.append('relay', r);
|
|
}
|
|
params.set('secret', opts.secret);
|
|
if (opts.name) params.set('name', opts.name);
|
|
if (opts.url) params.set('url', opts.url);
|
|
if (opts.perms) params.set('perms', opts.perms);
|
|
return `nostrconnect://${opts.clientPubkey}?${params.toString()}`;
|
|
}
|
|
|
|
/** Generate a random short secret string */
|
|
function randomSecret(): string {
|
|
const arr = new Uint8Array(12);
|
|
crypto.getRandomValues(arr);
|
|
return Array.from(arr, b => b.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onSignup }) => {
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [bunkerUri, setBunkerUri] = useState('');
|
|
const [bunkerError, setBunkerError] = useState('');
|
|
|
|
// Amber nostrconnect state
|
|
const [amberStep, setAmberStep] = useState<'idle' | 'connecting' | 'success'>('idle');
|
|
const [amberStatus, setAmberStatus] = useState('');
|
|
const [nostrConnectUri, setNostrConnectUri] = useState('');
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
const poolRef = useRef<SimplePool | null>(null);
|
|
const subRef = useRef<{ close: () => void } | null>(null);
|
|
|
|
const login = useLoginActions();
|
|
const { addLogin, setLogin } = useNostrLogin();
|
|
const { toast } = useToast();
|
|
|
|
const isAndroid = /Android/i.test(navigator.userAgent);
|
|
|
|
// Cleanup pool/subscriptions
|
|
const cleanup = useCallback(() => {
|
|
try { subRef.current?.close(); } catch { /* ignore */ }
|
|
try { poolRef.current?.close(NOSTRCONNECT_RELAYS); } catch { /* ignore */ }
|
|
subRef.current = null;
|
|
poolRef.current = null;
|
|
}, []);
|
|
|
|
// Reset state when dialog closes
|
|
useEffect(() => {
|
|
if (!isOpen) {
|
|
abortRef.current?.abort();
|
|
abortRef.current = null;
|
|
cleanup();
|
|
setAmberStep('idle');
|
|
setAmberStatus('');
|
|
setNostrConnectUri('');
|
|
setBunkerUri('');
|
|
setBunkerError('');
|
|
setIsLoading(false);
|
|
}
|
|
}, [isOpen, cleanup]);
|
|
|
|
/**
|
|
* Amber login via NIP-46 nostrconnect:// flow.
|
|
*
|
|
* Uses nostr-tools SimplePool + nip04 for reliable WebSocket subscriptions.
|
|
* This is the same approach used by Ditto, Coracle, Nostrudel.
|
|
*/
|
|
const startAmberConnect = useCallback(async () => {
|
|
// Cancel previous attempt
|
|
abortRef.current?.abort();
|
|
cleanup();
|
|
|
|
const controller = new AbortController();
|
|
abortRef.current = controller;
|
|
|
|
// Generate ephemeral client keypair
|
|
const clientSk = generateSecretKey();
|
|
const clientPubkey = getPublicKey(clientSk);
|
|
const clientNsec = nip19.nsecEncode(clientSk);
|
|
const secret = randomSecret();
|
|
|
|
// Build the nostrconnect URI
|
|
const uri = buildNostrConnectURI({
|
|
clientPubkey,
|
|
relays: NOSTRCONNECT_RELAYS,
|
|
secret,
|
|
name: 'SkateSpots',
|
|
url: 'https://skate-spots.org',
|
|
perms: 'get_public_key,sign_event,nip44_encrypt,nip44_decrypt',
|
|
});
|
|
|
|
setNostrConnectUri(uri);
|
|
setAmberStep('connecting');
|
|
setAmberStatus('Opening Amber...');
|
|
|
|
// Open Amber with the nostrconnect:// URI
|
|
try {
|
|
window.open(uri, '_blank');
|
|
} catch {
|
|
// fallback: user copies manually
|
|
}
|
|
|
|
setAmberStatus('Waiting for Amber to approve...');
|
|
|
|
// Set up SimplePool subscription to listen for Amber's connect response
|
|
const pool = new SimplePool();
|
|
poolRef.current = pool;
|
|
|
|
const since = Math.floor(Date.now() / 1000) - 5;
|
|
|
|
const connectPromise = new Promise<{ signerPubkey: string; userPubkey: string }>((resolve, reject) => {
|
|
// Handle abort
|
|
controller.signal.addEventListener('abort', () => {
|
|
reject(new DOMException('Aborted', 'AbortError'));
|
|
});
|
|
|
|
let resolved = false;
|
|
|
|
// Subscribe using nostr-tools SimplePool — proven reliable
|
|
const sub = pool.subscribeMany(
|
|
NOSTRCONNECT_RELAYS,
|
|
[{
|
|
kinds: [24133],
|
|
'#p': [clientPubkey],
|
|
since,
|
|
}],
|
|
{
|
|
onevent: async (event) => {
|
|
if (resolved) return;
|
|
|
|
try {
|
|
// Amber encrypts the connect response with NIP-04
|
|
const decrypted = await nip04.decrypt(clientSk, event.pubkey, event.content);
|
|
const parsed = JSON.parse(decrypted) as {
|
|
id: string;
|
|
result: string;
|
|
error?: string;
|
|
};
|
|
|
|
if (parsed.error && parsed.error !== '') {
|
|
resolved = true;
|
|
reject(new Error(parsed.error));
|
|
return;
|
|
}
|
|
|
|
// Accept either our secret or 'ack' as valid connect response
|
|
if (parsed.result !== secret && parsed.result !== 'ack') return;
|
|
|
|
resolved = true;
|
|
const signerPubkey = event.pubkey;
|
|
|
|
setAmberStatus('Connected! Getting your pubkey...');
|
|
|
|
// Use NConnectSigner to get the actual user pubkey
|
|
try {
|
|
const clientSigner = new NSecSigner(clientSk);
|
|
const relayForSigning = new NRelay1(NOSTRCONNECT_RELAYS[0]);
|
|
const signer = new NConnectSigner({
|
|
pubkey: signerPubkey,
|
|
signer: clientSigner,
|
|
relay: relayForSigning,
|
|
timeout: 30000,
|
|
});
|
|
const userPubkey = await signer.getPublicKey();
|
|
try { relayForSigning.close(); } catch { /* ignore */ }
|
|
resolve({ signerPubkey, userPubkey });
|
|
} catch {
|
|
// If NConnectSigner fails, use signerPubkey as userPubkey
|
|
// (works for Amber since it signs as the user's key directly)
|
|
resolve({ signerPubkey, userPubkey: signerPubkey });
|
|
}
|
|
} catch {
|
|
// Decryption failed — not our event or wrong encryption, skip
|
|
}
|
|
},
|
|
oneose: () => {
|
|
// EOSE received — we're caught up, still waiting for new events
|
|
},
|
|
}
|
|
);
|
|
|
|
subRef.current = sub;
|
|
|
|
// Timeout after 5 minutes
|
|
setTimeout(() => {
|
|
if (!resolved) {
|
|
reject(new Error('Timeout — Amber did not respond in 5 minutes'));
|
|
}
|
|
}, 5 * 60 * 1000);
|
|
});
|
|
|
|
try {
|
|
const { signerPubkey, userPubkey } = await connectPromise;
|
|
|
|
cleanup();
|
|
|
|
// Create NLogin bunker object — same structure NLogin.fromBunker() creates
|
|
const nlogin = new NLogin('bunker', userPubkey, {
|
|
bunkerPubkey: signerPubkey,
|
|
clientNsec,
|
|
relays: NOSTRCONNECT_RELAYS,
|
|
});
|
|
|
|
addLogin(nlogin);
|
|
setLogin(nlogin.id);
|
|
|
|
setAmberStep('success');
|
|
toast({ title: '🛹 Logged in!', description: 'Connected via Amber!' });
|
|
setTimeout(() => { onLogin(); onClose(); }, 800);
|
|
} catch (e: unknown) {
|
|
cleanup();
|
|
if ((e as Error)?.name === 'AbortError') return;
|
|
setAmberStep('idle');
|
|
setAmberStatus('');
|
|
const msg = (e as Error)?.message ?? 'Unknown error';
|
|
toast({
|
|
title: 'Connection failed',
|
|
description: msg.includes('Timeout')
|
|
? 'Amber did not respond. Make sure you approved in Amber and try again.'
|
|
: `Failed: ${msg}`,
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
}, [addLogin, setLogin, cleanup, onLogin, onClose, toast]);
|
|
|
|
const handleBunkerLogin = async () => {
|
|
const uri = bunkerUri.trim();
|
|
if (!uri.startsWith('bunker://')) {
|
|
setBunkerError('Must start with bunker://');
|
|
return;
|
|
}
|
|
setIsLoading(true);
|
|
setBunkerError('');
|
|
try {
|
|
await login.bunker(uri);
|
|
toast({ title: '🛹 Logged in!', description: 'Connected via bunker!' });
|
|
onLogin();
|
|
onClose();
|
|
} catch (e: unknown) {
|
|
setBunkerError(
|
|
`Connection failed: ${(e as Error)?.message ?? 'Check your bunker URI and try again.'}`
|
|
);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleExtensionLogin = async () => {
|
|
try {
|
|
await login.extension();
|
|
onLogin();
|
|
onClose();
|
|
} catch {
|
|
toast({ title: 'No extension found', description: 'Install a Nostr browser extension like Alby.', variant: 'destructive' });
|
|
}
|
|
};
|
|
|
|
const copyUri = () => {
|
|
navigator.clipboard.writeText(nostrConnectUri).catch(() => {});
|
|
toast({ title: 'Copied!', description: 'Paste in Amber → Scan/Paste link' });
|
|
};
|
|
|
|
const openAmberWithUri = () => {
|
|
if (nostrConnectUri) {
|
|
try { window.open(nostrConnectUri, '_blank'); } catch { copyUri(); }
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
<DialogContent className={cn("w-[calc(100%-1rem)] sm:max-w-md max-h-[90vh] max-h-[90dvh] p-0 overflow-hidden rounded-2xl bg-black border-2 border-[#ff0040]/50")}>
|
|
<DialogHeader className={cn('px-4 sm:px-6 pt-4 sm:pt-6 pb-1')}>
|
|
<DialogDescription className="text-center text-sm text-gray-400">
|
|
Sign up or log in to continue
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className='px-4 sm:px-6 pt-2 pb-6 space-y-4 overflow-y-auto'>
|
|
{/* Sign Up */}
|
|
<div className='relative p-5 rounded-2xl bg-black border-2 border-[#ccff00]/50 overflow-hidden'>
|
|
<div className="absolute inset-0 bg-[#ccff00]/5 blur-xl" />
|
|
<div className='relative z-10 text-center space-y-3'>
|
|
<div className='flex justify-center items-center gap-2'>
|
|
<Sparkles className='w-5 h-5 text-[#ccff00]' />
|
|
<span className='font-bold text-[#ccff00] uppercase tracking-wide'>New here?</span>
|
|
</div>
|
|
<p className='text-xs text-gray-300'>Create a free account. No email required.</p>
|
|
<Button
|
|
onClick={() => { onClose(); onSignup?.(); }}
|
|
className='w-full rounded-full py-3 font-bold bg-[#ccff00] text-black hover:bg-[#ccff00]/90 uppercase'
|
|
>
|
|
<UserPlus className='w-4 h-4 mr-2' />
|
|
Create Account
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Divider */}
|
|
<div className='relative flex items-center'>
|
|
<div className='flex-1 border-t border-gray-800' />
|
|
<span className='px-3 text-xs text-gray-500'>Or log in</span>
|
|
<div className='flex-1 border-t border-gray-800' />
|
|
</div>
|
|
|
|
{/* Login tabs */}
|
|
<Tabs defaultValue={isAndroid ? 'amber' : 'bunker'} className="w-full">
|
|
<TabsList className="grid w-full grid-cols-3 bg-black/50 border border-[#ff0040]/30 rounded-lg mb-4 h-auto p-1">
|
|
<TabsTrigger value="amber" className="flex items-center gap-1 text-xs py-2 px-2">
|
|
<Smartphone className="w-4 h-4" />
|
|
<span>Amber</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="bunker" className="flex items-center gap-1 text-xs py-2 px-2">
|
|
<Cloud className="w-4 h-4" />
|
|
<span>Bunker</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="extension" className="flex items-center gap-1 text-xs py-2 px-2">
|
|
<Shield className="w-4 h-4" />
|
|
<span>Ext</span>
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* ── AMBER TAB ── */}
|
|
<TabsContent value='amber' className='space-y-3'>
|
|
{amberStep === 'idle' && (
|
|
<div className='text-center p-5 rounded-xl bg-black/50 border-2 border-[#ccff00]/30 space-y-4'>
|
|
<div className="w-14 h-14 rounded-full bg-[#ccff00]/10 border-2 border-[#ccff00]/40 flex items-center justify-center mx-auto">
|
|
<Smartphone className='w-7 h-7 text-[#ccff00]' />
|
|
</div>
|
|
<div>
|
|
<p className='text-sm text-white font-bold'>Login with Amber</p>
|
|
<p className='text-xs text-gray-400 mt-1'>
|
|
NIP-46 remote signing. Tap below, approve in Amber, done.
|
|
</p>
|
|
</div>
|
|
<Button
|
|
className='w-full rounded-full py-3 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase'
|
|
onClick={startAmberConnect}
|
|
>
|
|
<Smartphone className="w-4 h-4 mr-2" />
|
|
Connect with Amber
|
|
</Button>
|
|
<p className='text-xs text-gray-500'>
|
|
Need Amber?{' '}
|
|
<a href="https://github.com/greenart7c3/Amber/releases" target="_blank" rel="noopener noreferrer" className="text-[#ccff00] hover:underline">Download →</a>
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{amberStep === 'connecting' && (
|
|
<div className='p-5 rounded-xl bg-black/50 border-2 border-[#ccff00]/40 space-y-4'>
|
|
<div className='text-center space-y-2'>
|
|
<Loader2 className='w-8 h-8 text-[#ccff00] animate-spin mx-auto' />
|
|
<p className='text-sm text-white font-bold'>Waiting for Amber...</p>
|
|
<p className='text-xs text-gray-400'>{amberStatus}</p>
|
|
</div>
|
|
|
|
<div className='text-xs text-gray-300 bg-black/40 rounded-lg p-3 space-y-1 border border-gray-800'>
|
|
<p className='font-bold text-white mb-2'>In Amber:</p>
|
|
<p>1. Open <strong className='text-white'>Amber</strong></p>
|
|
<p>2. Tap <strong className='text-[#ccff00]'>Scan / Paste link</strong></p>
|
|
<p>3. Paste the connection string</p>
|
|
<p>4. Tap <strong className='text-[#ccff00]'>Approve</strong></p>
|
|
</div>
|
|
|
|
<div className='flex gap-2'>
|
|
<Button
|
|
variant="outline"
|
|
className='flex-1 rounded-full border-[#ccff00]/30 text-[#ccff00] hover:bg-[#ccff00]/10 text-xs h-auto py-2'
|
|
onClick={openAmberWithUri}
|
|
>
|
|
<Smartphone className='w-3 h-3 mr-1' />
|
|
Open Amber
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className='flex-1 rounded-full border-gray-700 text-gray-300 hover:bg-gray-800 text-xs h-auto py-2'
|
|
onClick={copyUri}
|
|
>
|
|
<Copy className='w-3 h-3 mr-1' />
|
|
Copy URI
|
|
</Button>
|
|
</div>
|
|
|
|
<div className='flex gap-2'>
|
|
<Button
|
|
variant="ghost"
|
|
className='flex-1 text-xs text-gray-500 hover:text-gray-300 h-auto py-2'
|
|
onClick={() => { abortRef.current?.abort(); cleanup(); setAmberStep('idle'); setAmberStatus(''); }}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
className='flex-1 text-xs text-[#ccff00] hover:text-[#ccff00]/80 h-auto py-2'
|
|
onClick={startAmberConnect}
|
|
>
|
|
<RefreshCw className='w-3 h-3 mr-1' />
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{amberStep === 'success' && (
|
|
<div className='text-center p-5 rounded-xl bg-black/50 border-2 border-green-500/30 space-y-3'>
|
|
<Check className='w-10 h-10 mx-auto text-green-500' />
|
|
<p className='text-sm text-green-400 font-bold'>Logged in!</p>
|
|
<Loader2 className="w-4 h-4 mx-auto animate-spin text-green-500" />
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* ── BUNKER TAB ── */}
|
|
<TabsContent value='bunker' className='space-y-3'>
|
|
<div className='p-5 rounded-xl bg-black/50 border-2 border-blue-500/30 space-y-4'>
|
|
<div className='text-center'>
|
|
<Cloud className='w-10 h-10 mx-auto mb-2 text-blue-400' />
|
|
<p className='text-sm text-white font-bold'>Bunker Login (NIP-46)</p>
|
|
<p className='text-xs text-gray-400 mt-1'>
|
|
Paste your <code className='text-blue-300'>bunker://</code> URI from Amber or nsec.app.
|
|
</p>
|
|
</div>
|
|
|
|
<div className='text-xs text-gray-300 bg-black/40 rounded-lg p-3 space-y-1 border border-gray-800'>
|
|
<p className='font-bold text-white mb-1'>How to get a bunker URI:</p>
|
|
<p><strong className='text-blue-300'>Amber:</strong> Settings → NIP-46 Bunker → Create → Copy</p>
|
|
<p><strong className='text-blue-300'>nsec.app:</strong> Connect app → Copy bunker URI</p>
|
|
</div>
|
|
|
|
<div className='space-y-2'>
|
|
<Input
|
|
placeholder='bunker://...'
|
|
value={bunkerUri}
|
|
onChange={e => { setBunkerUri(e.target.value); setBunkerError(''); }}
|
|
className='bg-black/60 border-blue-500/30 text-white placeholder:text-gray-600 text-xs font-mono'
|
|
autoComplete='off'
|
|
autoCorrect='off'
|
|
spellCheck={false}
|
|
/>
|
|
{bunkerError && <p className="text-xs text-red-400">{bunkerError}</p>}
|
|
</div>
|
|
|
|
<Button
|
|
className='w-full rounded-full py-3 bg-blue-500 hover:bg-blue-600 text-white font-bold uppercase'
|
|
disabled={isLoading || !bunkerUri.trim()}
|
|
onClick={handleBunkerLogin}
|
|
>
|
|
{isLoading ? <Loader2 className='w-4 h-4 animate-spin mr-2' /> : <Cloud className='w-4 h-4 mr-2' />}
|
|
Connect
|
|
</Button>
|
|
|
|
<div className='text-center pt-1'>
|
|
<div className='flex gap-2 justify-center flex-wrap'>
|
|
<a href="https://nsec.app" target="_blank" rel="noopener noreferrer" className="text-xs px-3 py-1 bg-blue-500/20 text-blue-400 rounded-full hover:bg-blue-500/30">nsec.app →</a>
|
|
<a href="https://nostrum.social" target="_blank" rel="noopener noreferrer" className="text-xs px-3 py-1 bg-blue-500/20 text-blue-400 rounded-full hover:bg-blue-500/30">Nostrum →</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* ── EXTENSION TAB ── */}
|
|
<TabsContent value='extension' className='space-y-3'>
|
|
<div className='text-center p-5 rounded-xl bg-black/50 border-2 border-[#ff0040]/30 space-y-4'>
|
|
<Shield className='w-10 h-10 mx-auto text-[#ff0040]' />
|
|
<div>
|
|
<p className='text-sm text-white font-bold'>Browser Extension (NIP-07)</p>
|
|
<p className='text-xs text-gray-400 mt-1'>Best for desktop. Works with Alby, nos2x, Flamingo.</p>
|
|
</div>
|
|
<Button
|
|
className='w-full rounded-full py-3 bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold uppercase'
|
|
onClick={handleExtensionLogin}
|
|
>
|
|
Login with Extension
|
|
</Button>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
export default LoginDialog;
|