feat: added QR code scanner for bunker login (#586)

Co-authored-by: blackcoffeexbt <87530449+blackcoffeexbt@users.noreply.github.com>
This commit is contained in:
Cody Tseng
2025-09-21 23:05:04 +08:00
committed by GitHub
parent c4643b2d8b
commit 75a760fadb
4 changed files with 144 additions and 14 deletions

2
.gitignore vendored
View File

@@ -24,3 +24,5 @@ dev-dist
*.njsproj
*.sln
*.sw?
.vercel

16
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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<string | null>(null)
const qrContainerRef = useRef<HTMLDivElement>(null)
const [qrCodeSize, setQrCodeSize] = useState(100)
const [isScanning, setIsScanning] = useState(false)
const videoRef = useRef<HTMLVideoElement>(null)
const qrScannerRef = useRef<QrScanner | null>(null)
const qrScannerCheckTimerRef = useRef<NodeJS.Timeout | null>(null)
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<>
<div className="relative flex flex-col gap-4">
<div ref={qrContainerRef} className="flex flex-col items-center w-full space-y-3 mb-3">
<a href={loginDetails.connectionString} aria-label="Open with Nostr signer app">
<QrCode size={qrCodeSize} value={loginDetails.connectionString} />
@@ -138,22 +219,52 @@ export default function NostrConnectLogin({
<div className="w-full space-y-1">
<div className="flex items-start space-x-2">
<Input
placeholder="bunker://..."
value={bunkerInput}
onChange={handleInputChange}
className={errMsg ? 'border-destructive' : ''}
/>
<Button onClick={handleLogin} disabled={pending}>
<div className="flex-1 relative">
<Input
placeholder="bunker://..."
value={bunkerInput}
onChange={handleInputChange}
className={errMsg ? 'border-destructive pr-10' : 'pr-10'}
/>
<Button
size="sm"
variant="ghost"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 p-0"
onClick={startQrScan}
disabled={pending}
>
<ScanQrCode />
</Button>
</div>
<Button onClick={() => handleLogin()} disabled={pending}>
<Loader className={pending ? 'animate-spin mr-2' : 'hidden'} />
{t('Login')}
</Button>
</div>
{errMsg && <div className="text-xs text-destructive pl-3 pt-1">{errMsg}</div>}
</div>
<Button variant="secondary" onClick={back} className="w-full">
{t('Back')}
</Button>
</>
<div className={cn('w-full h-full flex justify-center', isScanning ? '' : 'hidden')}>
<video
ref={videoRef}
className="absolute inset-0 w-full h-full bg-background"
autoPlay
playsInline
muted
/>
<Button
variant="secondary"
size="sm"
className="absolute top-2 right-2"
onClick={stopQrScan}
>
Cancel
</Button>
</div>
</div>
)
}