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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-03 12:34:20 +07:00
parent dfb60674c5
commit 03ea569a2a

View file

@ -93,12 +93,15 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ 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<AbortController | null>(null);
const poolRef = useRef<SimplePool | null>(null);
const subRef = useRef<{ close: () => void } | null>(null);
const pendingRef = useRef<PendingNostrConnect | null>(null);
const amberStepRef = useRef<'idle' | 'connecting' | 'waiting' | 'error' | 'success'>('idle');
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const elapsedIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const login = useLoginActions();
const { addLogin, setLogin } = useNostrLogin();
@ -112,6 +115,8 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ 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<LoginDialogProps> = ({ 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<boolean> => {
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<void>((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<void>((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<LoginDialogProps> = ({ isOpen, onClose, onLog
amberStepRef.current = 'idle';
setAmberStep('idle');
setAmberStatus('');
setAmberElapsed(0);
setNostrConnectUri('');
setBunkerUri('');
setBunkerError('');
@ -277,6 +308,11 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ 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<LoginDialogProps> = ({ isOpen, onClose, onLog
{amberStep === 'error' ? 'No response from Amber' : 'Waiting for Amber...'}
</p>
<p className='text-xs text-gray-400'>{amberStatus}</p>
{amberStep === 'waiting' && (
<p className='text-xs text-gray-600 tabular-nums'>
{Math.floor(amberElapsed / 60)}:{String(amberElapsed % 60).padStart(2, '0')} elapsed
</p>
)}
</div>
<div className='text-xs text-gray-300 bg-black/40 rounded-lg p-3 space-y-1 border border-gray-800'>
@ -474,6 +515,11 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
<Copy className='w-3 h-3 mr-1' />Copy URI
</Button>
</div>
{amberStep === 'waiting' && (
<Button variant="outline" className='w-full rounded-full border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/10 text-xs h-auto py-2' onClick={pollRelay}>
<RefreshCw className='w-3 h-3 mr-1' />Check Now
</Button>
)}
{isCapacitor && (
<p className='text-xs text-center text-gray-500'>