186 lines
5.9 KiB
TypeScript
186 lines
5.9 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import QRCode from "qrcode";
|
|
import { SimplePool, generateSecretKey, getPublicKey } from "nostr-tools";
|
|
import { BunkerSigner, createNostrConnectURI } from "nostr-tools/nip46";
|
|
import { useToast } from "../contexts/ToastContext";
|
|
import type { Nip98Signer } from "../api";
|
|
|
|
const DEFAULT_RELAYS = [
|
|
"wss://relay.damus.io",
|
|
"wss://relay.nostr.band",
|
|
];
|
|
const WAIT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
|
|
function randomHex(bytes: number): string {
|
|
const arr = new Uint8Array(bytes);
|
|
crypto.getRandomValues(arr);
|
|
return Array.from(arr)
|
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
.join("");
|
|
}
|
|
|
|
interface RemoteSignerQRProps {
|
|
onConnect: (sign: Nip98Signer) => Promise<void>;
|
|
onError: (message: string | null) => void;
|
|
disabled?: boolean;
|
|
/** When true, show "Signing in…" but keep mounted so the signer pool stays alive. */
|
|
signingIn?: boolean;
|
|
}
|
|
|
|
export function RemoteSignerQR({ onConnect, onError, disabled, signingIn }: RemoteSignerQRProps) {
|
|
const { showToast } = useToast();
|
|
const [connectionUri, setConnectionUri] = useState<string | null>(null);
|
|
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
|
const [copyLabel, setCopyLabel] = useState("Copy");
|
|
const [waiting, setWaiting] = useState(false);
|
|
const poolRef = useRef<SimplePool | null>(null);
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const clearTimeoutRef = useCallback(() => {
|
|
if (timeoutRef.current) {
|
|
clearTimeout(timeoutRef.current);
|
|
timeoutRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const generateUri = useCallback(() => {
|
|
const clientSecret = generateSecretKey();
|
|
const clientPubkey = getPublicKey(clientSecret);
|
|
const secret = randomHex(32);
|
|
const uri = createNostrConnectURI({
|
|
clientPubkey,
|
|
relays: DEFAULT_RELAYS,
|
|
secret,
|
|
name: "Sats Faucet",
|
|
url: typeof window !== "undefined" ? window.location.origin : "",
|
|
});
|
|
return { uri, clientSecret };
|
|
}, []);
|
|
|
|
const startWaiting = useCallback(
|
|
(uri: string, clientSecret: Uint8Array) => {
|
|
clearTimeoutRef();
|
|
const pool = new SimplePool();
|
|
poolRef.current = pool;
|
|
const abort = new AbortController();
|
|
abortRef.current = abort;
|
|
setWaiting(true);
|
|
|
|
timeoutRef.current = setTimeout(() => {
|
|
timeoutRef.current = null;
|
|
if (abortRef.current?.signal.aborted) return;
|
|
abortRef.current?.abort();
|
|
onError("Connection timed out. Try regenerating the QR code.");
|
|
setWaiting(false);
|
|
}, WAIT_TIMEOUT_MS);
|
|
|
|
BunkerSigner.fromURI(clientSecret, uri, { pool }, abort.signal)
|
|
.then(async (signer) => {
|
|
if (abortRef.current?.signal.aborted) return;
|
|
clearTimeoutRef();
|
|
setWaiting(false);
|
|
const sign: Nip98Signer = (e) => signer.signEvent(e);
|
|
try {
|
|
await onConnect(sign);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
onError(msg);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
if (abortRef.current?.signal.aborted) return;
|
|
clearTimeoutRef();
|
|
setWaiting(false);
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
onError(msg);
|
|
});
|
|
},
|
|
[onConnect, onError, clearTimeoutRef]
|
|
);
|
|
|
|
const regenerate = useCallback(() => {
|
|
clearTimeoutRef();
|
|
abortRef.current?.abort();
|
|
abortRef.current = null;
|
|
if (poolRef.current) {
|
|
poolRef.current.destroy();
|
|
poolRef.current = null;
|
|
}
|
|
onError(null);
|
|
const { uri, clientSecret } = generateUri();
|
|
setConnectionUri(uri);
|
|
QRCode.toDataURL(uri, { width: 220, margin: 1 }).then(setQrDataUrl).catch(() => setQrDataUrl(null));
|
|
startWaiting(uri, clientSecret);
|
|
}, [generateUri, startWaiting, onError, clearTimeoutRef]);
|
|
|
|
useEffect(() => {
|
|
if (disabled) return;
|
|
const { uri, clientSecret } = generateUri();
|
|
setConnectionUri(uri);
|
|
setQrDataUrl(null);
|
|
QRCode.toDataURL(uri, { width: 220, margin: 1 }).then(setQrDataUrl).catch(() => setQrDataUrl(null));
|
|
startWaiting(uri, clientSecret);
|
|
return () => {
|
|
clearTimeoutRef();
|
|
abortRef.current?.abort();
|
|
if (poolRef.current) {
|
|
poolRef.current.destroy();
|
|
poolRef.current = null;
|
|
}
|
|
};
|
|
}, [disabled, generateUri, startWaiting, clearTimeoutRef]);
|
|
|
|
const handleCopy = useCallback(() => {
|
|
if (!connectionUri) return;
|
|
navigator.clipboard.writeText(connectionUri).then(() => {
|
|
setCopyLabel("Copied");
|
|
showToast("Copied");
|
|
setTimeout(() => setCopyLabel("Copy"), 1500);
|
|
});
|
|
}, [connectionUri, showToast]);
|
|
|
|
if (disabled) return null;
|
|
|
|
return (
|
|
<div className="remote-signer-qr-content">
|
|
<div className="remote-signer-qr-card">
|
|
{qrDataUrl ? (
|
|
<img src={qrDataUrl} alt="Scan to connect with your signer" />
|
|
) : (
|
|
<span className="remote-signer-qr-placeholder">Generating QR…</span>
|
|
)}
|
|
</div>
|
|
{signingIn ? (
|
|
<p className="remote-signer-waiting" role="status">
|
|
Signing in…
|
|
</p>
|
|
) : waiting ? (
|
|
<p className="remote-signer-waiting" role="status">
|
|
Waiting for signer approval…
|
|
</p>
|
|
) : null}
|
|
<div className="remote-signer-connection-row">
|
|
<input
|
|
type="text"
|
|
readOnly
|
|
value={connectionUri ?? ""}
|
|
aria-label="Connection string"
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="remote-signer-copy-btn"
|
|
onClick={handleCopy}
|
|
disabled={!connectionUri}
|
|
aria-label="Copy connection string"
|
|
>
|
|
{copyLabel}
|
|
</button>
|
|
</div>
|
|
<button type="button" className="remote-signer-regenerate-btn" onClick={regenerate}>
|
|
Regenerate QR
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|