feat: added QR code scanner for bunker login (#586)
Co-authored-by: blackcoffeexbt <87530449+blackcoffeexbt@users.noreply.github.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -24,3 +24,5 @@ dev-dist
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.vercel
|
||||
|
||||
16
package-lock.json
generated
16
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user