first commit
Made-with: Cursor
This commit is contained in:
185
frontend/src/components/RemoteSignerQR.tsx
Normal file
185
frontend/src/components/RemoteSignerQR.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user