From 03ea569a2ac95134f59c6f14870eb22222ee3454 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Fri, 3 Jul 2026 12:34:20 +0700 Subject: [PATCH] Amber login: poll relay every 3s after returning from Amber, add elapsed timer + Check Now button - Replace one-shot visibility handler with pollRelay() called immediately on foreground + every 3s - Add elapsed time counter (M:SS) so user can see the app is still trying - Add 'Check Now' button for manual relay poll without generating a new URI - Poll interval auto-clears on success, timeout, or cancel Co-authored-by: shakespeare.diy --- src/components/auth/LoginDialog.tsx | 150 ++++++++++++++++++---------- 1 file changed, 98 insertions(+), 52 deletions(-) diff --git a/src/components/auth/LoginDialog.tsx b/src/components/auth/LoginDialog.tsx index d428bb6..f4532fa 100644 --- a/src/components/auth/LoginDialog.tsx +++ b/src/components/auth/LoginDialog.tsx @@ -93,12 +93,15 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog const [amberStep, setAmberStep] = useState<'idle' | 'connecting' | 'waiting' | 'error' | 'success'>('idle'); const [amberStatus, setAmberStatus] = useState(''); + const [amberElapsed, setAmberElapsed] = useState(0); const [nostrConnectUri, setNostrConnectUri] = useState(''); const abortRef = useRef(null); const poolRef = useRef(null); const subRef = useRef<{ close: () => void } | null>(null); const pendingRef = useRef(null); const amberStepRef = useRef<'idle' | 'connecting' | 'waiting' | 'error' | 'success'>('idle'); + const pollIntervalRef = useRef | null>(null); + const elapsedIntervalRef = useRef | null>(null); const login = useLoginActions(); const { addLogin, setLogin } = useNostrLogin(); @@ -112,6 +115,8 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog try { poolRef.current?.close(NOSTRCONNECT_RELAYS); } catch { /* ignore */ } subRef.current = null; poolRef.current = null; + if (pollIntervalRef.current) { clearInterval(pollIntervalRef.current); pollIntervalRef.current = null; } + if (elapsedIntervalRef.current) { clearInterval(elapsedIntervalRef.current); elapsedIntervalRef.current = null; } }, []); // Complete login once we have signerPubkey from any source @@ -151,68 +156,93 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog setTimeout(() => { onLogin(); onClose(); }, 800); }, [addLogin, setLogin, cleanup, onLogin, onClose, toast]); - // Listen for app visibility change (user returns from Amber) — then poll relay - // This is more reliable in Capacitor than keeping a WebSocket open in background + // Poll relay once — returns true if Amber's response was found and login completed + const pollRelay = useCallback(async (): Promise => { + const pending = pendingRef.current || loadPendingNC(); + if (!pending || amberStepRef.current !== 'waiting') return false; + + console.log('[Amber] Polling relay for response...'); + setAmberStatus('Checking relay for Amber response...'); + + try { + const decoded = nip19.decode(pending.clientNsec); + if (decoded.type !== 'nsec') return false; + const clientSk = decoded.data; + const since = Math.floor(pending.timestamp / 1000) - 30; + const freshPool = new SimplePool(); + let found = false; + + await new Promise((resolve) => { + const timeout = setTimeout(() => { freshPool.close(NOSTRCONNECT_RELAYS); resolve(); }, 6000); + + const sub = freshPool.subscribeMany( + NOSTRCONNECT_RELAYS, + [{ kinds: [24133], '#p': [pending.clientPubkey], since }], + { + onevent: async (event) => { + if (found) return; + console.log('[Amber] Poll received event from:', event.pubkey); + try { + 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 !== '') return; + if (parsed.result !== pending.secret && parsed.result !== 'ack') return; + found = true; + clearTimeout(timeout); + sub.close(); + freshPool.close(NOSTRCONNECT_RELAYS); + pendingRef.current = null; + abortRef.current?.abort(); + await completeLogin(event.pubkey, clientSk, pending.clientNsec, pending.relays); + resolve(); + } catch { /* wrong encryption key or malformed event */ } + }, + oneose: () => { + setTimeout(() => { sub.close(); freshPool.close(NOSTRCONNECT_RELAYS); resolve(); }, 1500); + }, + } + ); + }); + + if (!found && amberStepRef.current === 'waiting') { + setAmberStatus('Nothing yet — still waiting for Amber...'); + } + return found; + } catch { return false; } + }, [completeLogin]); + + // When app returns from Amber: immediately poll, then keep polling every 3s useEffect(() => { const handleVisibility = async () => { if (document.visibilityState !== 'visible') return; - const pending = pendingRef.current || loadPendingNC(); - if (!pending || amberStep !== 'waiting') return; + if (amberStepRef.current !== 'waiting') return; - setAmberStatus('Checking for Amber response...'); + console.log('[Amber] App foregrounded — starting poll loop'); - try { - const decoded = nip19.decode(pending.clientNsec); - if (decoded.type !== 'nsec') return; - const clientSk = decoded.data; + // Poll immediately on return + const found = await pollRelay(); + if (found) return; - const since = Math.floor(pending.timestamp / 1000) - 30; - - // Re-subscribe with a fresh pool each time app comes to foreground - const freshPool = new SimplePool(); - let found = false; - - await new Promise((resolve) => { - const timeout = setTimeout(() => { resolve(); }, 6000); - - const sub = freshPool.subscribeMany( - NOSTRCONNECT_RELAYS, - [{ kinds: [24133], '#p': [pending.clientPubkey], since }], - { - onevent: async (event) => { - if (found) return; - try { - 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 !== '') return; - if (parsed.result !== pending.secret && parsed.result !== 'ack') return; - found = true; - clearTimeout(timeout); - sub.close(); - freshPool.close(NOSTRCONNECT_RELAYS); - pendingRef.current = null; - abortRef.current?.abort(); - await completeLogin(event.pubkey, clientSk, pending.clientNsec, pending.relays); - resolve(); - } catch { /* wrong encryption */ } - }, - oneose: () => { - // All caught up — wait a bit more then give up - setTimeout(() => { sub.close(); freshPool.close(NOSTRCONNECT_RELAYS); resolve(); }, 2000); - }, - } - ); - }); - - if (!found) { - setAmberStatus('Not received yet. Did you approve in Amber? Tap retry or come back after approving.'); - } - } catch { /* ignore */ } + // Then poll every 3 seconds until success or timeout/cancel clears the interval + if (!pollIntervalRef.current) { + pollIntervalRef.current = setInterval(async () => { + if (amberStepRef.current !== 'waiting') { + clearInterval(pollIntervalRef.current!); + pollIntervalRef.current = null; + return; + } + const done = await pollRelay(); + if (done) { + clearInterval(pollIntervalRef.current!); + pollIntervalRef.current = null; + } + }, 3000); + } }; document.addEventListener('visibilitychange', handleVisibility); return () => document.removeEventListener('visibilitychange', handleVisibility); - }, [amberStep, completeLogin]); + }, [pollRelay]); // Reset when dialog closes useEffect(() => { @@ -223,6 +253,7 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog amberStepRef.current = 'idle'; setAmberStep('idle'); setAmberStatus(''); + setAmberElapsed(0); setNostrConnectUri(''); setBunkerUri(''); setBunkerError(''); @@ -277,6 +308,11 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog amberStepRef.current = 'waiting'; setAmberStep('waiting'); setAmberStatus('Approve in Amber, then come back here.'); + setAmberElapsed(0); + if (elapsedIntervalRef.current) clearInterval(elapsedIntervalRef.current); + elapsedIntervalRef.current = setInterval(() => { + setAmberElapsed(s => s + 1); + }, 1000); // Live relay subscription (works on web/PWA; may be backgrounded in Capacitor) const pool = new SimplePool(); @@ -456,6 +492,11 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog {amberStep === 'error' ? 'No response from Amber' : 'Waiting for Amber...'}

{amberStatus}

+ {amberStep === 'waiting' && ( +

+ {Math.floor(amberElapsed / 60)}:{String(amberElapsed % 60).padStart(2, '0')} elapsed +

+ )}
@@ -474,6 +515,11 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog Copy URI
+ {amberStep === 'waiting' && ( + + )} {isCapacitor && (