first commit

Made-with: Cursor
This commit is contained in:
Michaël
2026-02-26 18:33:00 -03:00
commit 3734365463
76 changed files with 14133 additions and 0 deletions

View 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>
);
}