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
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
.vercel
|
||||||
|
|||||||
16
package-lock.json
generated
16
package-lock.json
generated
@@ -63,6 +63,7 @@
|
|||||||
"nstart-modal": "^2.0.0",
|
"nstart-modal": "^2.0.0",
|
||||||
"path-to-regexp": "^8.2.0",
|
"path-to-regexp": "^8.2.0",
|
||||||
"qr-code-styling": "^1.9.2",
|
"qr-code-styling": "^1.9.2",
|
||||||
|
"qr-scanner": "^1.4.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-i18next": "^15.2.0",
|
"react-i18next": "^15.2.0",
|
||||||
@@ -5367,6 +5368,12 @@
|
|||||||
"undici-types": "~6.20.0"
|
"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": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.14",
|
"version": "15.7.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
|
||||||
@@ -10563,6 +10570,15 @@
|
|||||||
"node": ">=18.18.0"
|
"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": {
|
"node_modules/qrcode-generator": {
|
||||||
"version": "1.4.4",
|
"version": "1.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.4.4.tgz",
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
"nstart-modal": "^2.0.0",
|
"nstart-modal": "^2.0.0",
|
||||||
"path-to-regexp": "^8.2.0",
|
"path-to-regexp": "^8.2.0",
|
||||||
"qr-code-styling": "^1.9.2",
|
"qr-code-styling": "^1.9.2",
|
||||||
|
"qr-scanner": "^1.4.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-i18next": "^15.2.0",
|
"react-i18next": "^15.2.0",
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { DEFAULT_NOSTRCONNECT_RELAY } from '@/constants'
|
import { DEFAULT_NOSTRCONNECT_RELAY } from '@/constants'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { createNostrConnectURI, NostrConnectParams } from 'nostr-tools/nip46'
|
import { Check, Copy, Loader, ScanQrCode } from 'lucide-react'
|
||||||
import { Check, Copy, Loader } from 'lucide-react'
|
|
||||||
import { generateSecretKey, getPublicKey } from 'nostr-tools'
|
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 { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import QrCode from '../QrCode'
|
import QrCode from '../QrCode'
|
||||||
@@ -25,17 +27,22 @@ export default function NostrConnectLogin({
|
|||||||
const [nostrConnectionErrMsg, setNostrConnectionErrMsg] = useState<string | null>(null)
|
const [nostrConnectionErrMsg, setNostrConnectionErrMsg] = useState<string | null>(null)
|
||||||
const qrContainerRef = useRef<HTMLDivElement>(null)
|
const qrContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const [qrCodeSize, setQrCodeSize] = useState(100)
|
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>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setBunkerInput(e.target.value)
|
setBunkerInput(e.target.value)
|
||||||
if (errMsg) setErrMsg(null)
|
if (errMsg) setErrMsg(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogin = () => {
|
const handleLogin = (bunker: string = bunkerInput) => {
|
||||||
if (bunkerInput === '') return
|
const _bunker = bunker.trim()
|
||||||
|
if (_bunker.trim() === '') return
|
||||||
|
|
||||||
setPending(true)
|
setPending(true)
|
||||||
bunkerLogin(bunkerInput)
|
bunkerLogin(_bunker)
|
||||||
.then(() => onLoginSuccess())
|
.then(() => onLoginSuccess())
|
||||||
.catch((err) => setErrMsg(err.message || 'Login failed'))
|
.catch((err) => setErrMsg(err.message || 'Login failed'))
|
||||||
.finally(() => setPending(false))
|
.finally(() => setPending(false))
|
||||||
@@ -103,8 +110,82 @@ export default function NostrConnectLogin({
|
|||||||
setTimeout(() => setCopied(false), 2000)
|
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 (
|
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">
|
<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">
|
<a href={loginDetails.connectionString} aria-label="Open with Nostr signer app">
|
||||||
<QrCode size={qrCodeSize} value={loginDetails.connectionString} />
|
<QrCode size={qrCodeSize} value={loginDetails.connectionString} />
|
||||||
@@ -138,22 +219,52 @@ export default function NostrConnectLogin({
|
|||||||
|
|
||||||
<div className="w-full space-y-1">
|
<div className="w-full space-y-1">
|
||||||
<div className="flex items-start space-x-2">
|
<div className="flex items-start space-x-2">
|
||||||
<Input
|
<div className="flex-1 relative">
|
||||||
placeholder="bunker://..."
|
<Input
|
||||||
value={bunkerInput}
|
placeholder="bunker://..."
|
||||||
onChange={handleInputChange}
|
value={bunkerInput}
|
||||||
className={errMsg ? 'border-destructive' : ''}
|
onChange={handleInputChange}
|
||||||
/>
|
className={errMsg ? 'border-destructive pr-10' : 'pr-10'}
|
||||||
<Button onClick={handleLogin} disabled={pending}>
|
/>
|
||||||
|
<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'} />
|
<Loader className={pending ? 'animate-spin mr-2' : 'hidden'} />
|
||||||
{t('Login')}
|
{t('Login')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{errMsg && <div className="text-xs text-destructive pl-3 pt-1">{errMsg}</div>}
|
{errMsg && <div className="text-xs text-destructive pl-3 pt-1">{errMsg}</div>}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" onClick={back} className="w-full">
|
<Button variant="secondary" onClick={back} className="w-full">
|
||||||
{t('Back')}
|
{t('Back')}
|
||||||
</Button>
|
</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