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 && (