From 75a760fadb884189ac335eb0504651ef5de80bce Mon Sep 17 00:00:00 2001 From: Cody Tseng Date: Sun, 21 Sep 2025 23:05:04 +0800 Subject: [PATCH] feat: added QR code scanner for bunker login (#586) Co-authored-by: blackcoffeexbt <87530449+blackcoffeexbt@users.noreply.github.com> --- .gitignore | 2 + package-lock.json | 16 ++ package.json | 1 + .../AccountManager/NostrConnectionLogin.tsx | 139 ++++++++++++++++-- 4 files changed, 144 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index 692c42aa..e89fec0f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ dev-dist *.njsproj *.sln *.sw? + +.vercel diff --git a/package-lock.json b/package-lock.json index f315617c..98a37c1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,7 @@ "nstart-modal": "^2.0.0", "path-to-regexp": "^8.2.0", "qr-code-styling": "^1.9.2", + "qr-scanner": "^1.4.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^15.2.0", @@ -5367,6 +5368,12 @@ "undici-types": "~6.20.0" } }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.14", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", @@ -10563,6 +10570,15 @@ "node": ">=18.18.0" } }, + "node_modules/qr-scanner": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/qr-scanner/-/qr-scanner-1.4.2.tgz", + "integrity": "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw==", + "license": "MIT", + "dependencies": { + "@types/offscreencanvas": "^2019.6.4" + } + }, "node_modules/qrcode-generator": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.4.4.tgz", diff --git a/package.json b/package.json index 84293f3e..fc4a956f 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "nstart-modal": "^2.0.0", "path-to-regexp": "^8.2.0", "qr-code-styling": "^1.9.2", + "qr-scanner": "^1.4.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^15.2.0", diff --git a/src/components/AccountManager/NostrConnectionLogin.tsx b/src/components/AccountManager/NostrConnectionLogin.tsx index c81efb0a..37fde9d0 100644 --- a/src/components/AccountManager/NostrConnectionLogin.tsx +++ b/src/components/AccountManager/NostrConnectionLogin.tsx @@ -1,10 +1,12 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { DEFAULT_NOSTRCONNECT_RELAY } from '@/constants' +import { cn } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' -import { createNostrConnectURI, NostrConnectParams } from 'nostr-tools/nip46' -import { Check, Copy, Loader } from 'lucide-react' +import { Check, Copy, Loader, ScanQrCode } from 'lucide-react' import { generateSecretKey, getPublicKey } from 'nostr-tools' +import { createNostrConnectURI, NostrConnectParams } from 'nostr-tools/nip46' +import QrScanner from 'qr-scanner' import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import QrCode from '../QrCode' @@ -25,17 +27,22 @@ export default function NostrConnectLogin({ const [nostrConnectionErrMsg, setNostrConnectionErrMsg] = useState(null) const qrContainerRef = useRef(null) const [qrCodeSize, setQrCodeSize] = useState(100) + const [isScanning, setIsScanning] = useState(false) + const videoRef = useRef(null) + const qrScannerRef = useRef(null) + const qrScannerCheckTimerRef = useRef(null) const handleInputChange = (e: React.ChangeEvent) => { setBunkerInput(e.target.value) if (errMsg) setErrMsg(null) } - const handleLogin = () => { - if (bunkerInput === '') return + const handleLogin = (bunker: string = bunkerInput) => { + const _bunker = bunker.trim() + if (_bunker.trim() === '') return setPending(true) - bunkerLogin(bunkerInput) + bunkerLogin(_bunker) .then(() => onLoginSuccess()) .catch((err) => setErrMsg(err.message || 'Login failed')) .finally(() => setPending(false)) @@ -103,8 +110,82 @@ export default function NostrConnectLogin({ setTimeout(() => setCopied(false), 2000) } + const startQrScan = async () => { + try { + setIsScanning(true) + setErrMsg(null) + + // Wait for next render cycle to ensure video element is in DOM + await new Promise((resolve) => setTimeout(resolve, 100)) + + if (!videoRef.current) { + throw new Error('Video element not found') + } + + const hasCamera = await QrScanner.hasCamera() + if (!hasCamera) { + throw new Error('No camera found') + } + + const qrScanner = new QrScanner( + videoRef.current, + (result) => { + setBunkerInput(result.data) + stopQrScan() + handleLogin(result.data) + }, + { + highlightScanRegion: true, + highlightCodeOutline: true, + preferredCamera: 'environment' + } + ) + + qrScannerRef.current = qrScanner + await qrScanner.start() + + // Check video feed after a delay + qrScannerCheckTimerRef.current = setTimeout(() => { + if ( + videoRef.current && + (videoRef.current.videoWidth === 0 || videoRef.current.videoHeight === 0) + ) { + setErrMsg('Camera feed not available') + } + }, 1000) + } catch (error) { + setErrMsg( + `Failed to start camera: ${error instanceof Error ? error.message : 'Unknown error'}. Please check permissions.` + ) + setIsScanning(false) + if (qrScannerCheckTimerRef.current) { + clearTimeout(qrScannerCheckTimerRef.current) + qrScannerCheckTimerRef.current = null + } + } + } + + const stopQrScan = () => { + if (qrScannerRef.current) { + qrScannerRef.current.stop() + qrScannerRef.current.destroy() + qrScannerRef.current = null + } + setIsScanning(false) + if (qrScannerCheckTimerRef.current) { + clearTimeout(qrScannerCheckTimerRef.current) + qrScannerCheckTimerRef.current = null + } + } + + useEffect(() => { + return () => { + stopQrScan() + } + }, []) + return ( - <> + ) }