Add QR scanner to Send dialog
The recipient input on /wallet's Send dialog now has a camera button that opens a QR scanner. Bitcoin BIP-21 URIs are parsed and the silent-payment fallback (?sp=) is preferred when present, falling back to the on-chain address otherwise. Plain addresses, sp1… codes, npub, and nprofile values are dropped into the input verbatim and resolved by the existing recipient logic. QrScannerDialog is a standalone component (ported from Ditto) that owns the camera lifecycle via getUserMedia and the qr-scanner npm package. It surfaces failure modes (insecure context, denied permission, no camera, busy camera, overconstrained, ready timeout) instead of a silent black screen, and offers a flash toggle when the device supports it. Android needed an explicit CAMERA permission in the manifest; iOS's existing NSCameraUsageDescription string was extended to mention QR scanning. No Capacitor camera plugin is required — the standard web APIs work inside WKWebView and Android's WebView.
This commit is contained in:
@@ -60,4 +60,6 @@
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
</manifest>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Agora needs access to your photo library to upload images to your posts and profile.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Agora needs camera access to take photos and videos for your posts.</string>
|
||||
<string>Agora needs camera access to take photos and videos for your posts, and to scan QR codes when sending Bitcoin.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Agora needs access to your microphone to record voice messages.</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
||||
Generated
+16
@@ -105,6 +105,7 @@
|
||||
"iso-3166": "^4.4.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"nostr-tools": "^2.13.0",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
"react-blurhash": "^0.3.0",
|
||||
@@ -6226,6 +6227,12 @@
|
||||
"undici-types": "~6.21.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/qrcode": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz",
|
||||
@@ -11997,6 +12004,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
"iso-3166": "^4.4.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"nostr-tools": "^2.13.0",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
"react-blurhash": "^0.3.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Loader2, Search } from 'lucide-react';
|
||||
import { Loader2, QrCode, Search } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
@@ -10,12 +10,21 @@ import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface BitcoinRecipientInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
resolvedPubkey?: string;
|
||||
/**
|
||||
* When provided, a camera button is rendered inside the input that opens
|
||||
* a QR scanner. The parent owns the scanner lifecycle and interprets the
|
||||
* scan result (BIP-21 parsing, silent-payment priority, etc.).
|
||||
*/
|
||||
onScanClick?: () => void;
|
||||
/** Localized aria-label for the scan button (only used when `onScanClick` is set). */
|
||||
scanLabel?: string;
|
||||
}
|
||||
|
||||
function shouldSkipProfileSearch(value: string): boolean {
|
||||
@@ -33,7 +42,7 @@ function shouldSkipProfileSearch(value: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function BitcoinRecipientInput({ value, onChange, placeholder, resolvedPubkey }: BitcoinRecipientInputProps) {
|
||||
export function BitcoinRecipientInput({ value, onChange, placeholder, resolvedPubkey, onScanClick, scanLabel }: BitcoinRecipientInputProps) {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const searchQuery = shouldSkipProfileSearch(value) ? '' : value;
|
||||
@@ -67,7 +76,12 @@ export function BitcoinRecipientInput({ value, onChange, placeholder, resolvedPu
|
||||
<div className="relative flex items-center">
|
||||
<Search className="absolute left-3 size-4 text-muted-foreground pointer-events-none" />
|
||||
{isFetching && shouldShowSearch && (
|
||||
<Loader2 className="absolute right-3 size-4 text-muted-foreground animate-spin" />
|
||||
<Loader2
|
||||
className={cn(
|
||||
'absolute size-4 text-muted-foreground animate-spin',
|
||||
onScanClick ? 'right-11' : 'right-3',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
ref={inputRef}
|
||||
@@ -80,8 +94,21 @@ export function BitcoinRecipientInput({ value, onChange, placeholder, resolvedPu
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="pl-10 pr-10 font-mono text-base md:text-sm"
|
||||
className={cn(
|
||||
'pl-10 font-mono text-base md:text-sm',
|
||||
onScanClick ? 'pr-11' : 'pr-10',
|
||||
)}
|
||||
/>
|
||||
{onScanClick && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onScanClick}
|
||||
aria-label={scanLabel ?? 'Scan QR code'}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 size-8 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 flex items-center justify-center motion-safe:transition-colors"
|
||||
>
|
||||
<QrCode className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { BitcoinAmountPicker } from '@/components/BitcoinAmountPicker';
|
||||
import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
|
||||
import { BitcoinRecipientInput } from '@/components/BitcoinRecipientInput';
|
||||
import { QrScannerDialog } from '@/components/QrScannerDialog';
|
||||
import { HelpTip } from '@/components/HelpTip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
import {
|
||||
isLargeAmount,
|
||||
nostrPubkeyToBitcoinAddress,
|
||||
parseBitcoinUri,
|
||||
satsToUSD,
|
||||
} from '@/lib/bitcoin';
|
||||
import {
|
||||
@@ -228,11 +230,39 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
const [error, setError] = useState('');
|
||||
const [feePopoverOpen, setFeePopoverOpen] = useState(false);
|
||||
const [success, setSuccess] = useState<SendResult | null>(null);
|
||||
const [scannerOpen, setScannerOpen] = useState(false);
|
||||
|
||||
const feeSpeedUserChanged = useRef(false);
|
||||
|
||||
const recipient = useMemo(() => resolveRecipient(recipientInput), [recipientInput]);
|
||||
|
||||
/**
|
||||
* Interpret a freshly-scanned QR code and stuff it into the recipient
|
||||
* input. A `bitcoin:bc1q…?sp=sp1q…` BIP-21 URI means "send via silent
|
||||
* payment if you can; otherwise fall back to the on-chain address" — we
|
||||
* prefer the `sp` parameter when it parses, and the swap toggle under the
|
||||
* input lets the user fall back to the on-chain address if they want to.
|
||||
* Anything else (bare address, `sp1…`, npub, nprofile) is dropped in
|
||||
* verbatim and `resolveRecipient` does the rest.
|
||||
*/
|
||||
const handleScan = useCallback((scanned: string) => {
|
||||
setScannerOpen(false);
|
||||
setError('');
|
||||
const trimmed = scanned.trim();
|
||||
const bip21 = parseBitcoinUri(trimmed);
|
||||
if (bip21) {
|
||||
if (bip21.sp && resolveRecipient(bip21.sp)) {
|
||||
setRecipientInput(bip21.sp);
|
||||
return;
|
||||
}
|
||||
if (bip21.address) {
|
||||
setRecipientInput(bip21.address);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setRecipientInput(trimmed);
|
||||
}, []);
|
||||
|
||||
// ── Fee rates ────────────────────────────────────────────────
|
||||
const { data: feeRates } = useQuery({
|
||||
queryKey: ['blockbook-fee-rates', blockbookBaseUrl],
|
||||
@@ -533,6 +563,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={(v) => { if (!v) handleClose(); }}>
|
||||
<DialogContent className="max-w-[425px] rounded-2xl p-0 gap-0 border-border overflow-hidden max-h-[95vh] [&>button]:hidden">
|
||||
<DialogTitle className="sr-only">{t('walletSend.title')}</DialogTitle>
|
||||
@@ -583,6 +614,8 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
onChange={setRecipientInput}
|
||||
placeholder={t('walletSend.recipient.placeholder')}
|
||||
resolvedPubkey={recipient?.pubkey}
|
||||
onScanClick={() => setScannerOpen(true)}
|
||||
scanLabel={t('walletSend.recipient.scan')}
|
||||
/>
|
||||
{recipient && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -703,6 +736,13 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<QrScannerDialog
|
||||
isOpen={scannerOpen}
|
||||
onClose={() => setScannerOpen(false)}
|
||||
onScan={handleScan}
|
||||
title={t('walletSend.recipient.scan')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Camera, Loader2, X, ZapOff, Zap } from 'lucide-react';
|
||||
import QrScanner from 'qr-scanner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface QrScannerDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** Called with the decoded QR text the first time a code is read. */
|
||||
onScan: (value: string) => void;
|
||||
/** Override the dialog title (defaults to the localized "Scan QR code"). */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/** How long to wait after `start()` resolves before declaring the camera dead. */
|
||||
const VIDEO_READY_TIMEOUT_MS = 6000;
|
||||
|
||||
/**
|
||||
* Camera-based QR scanner dialog. Works in browsers, Capacitor's WKWebView
|
||||
* (iOS), and Android's WebView, all via `getUserMedia` + the `qr-scanner`
|
||||
* library (ZXing / BarcodeDetector under the hood).
|
||||
*
|
||||
* The dialog owns the camera lifecycle: it spins up the scanner when opened
|
||||
* and tears it down on close, so callers only need to manage `isOpen` and
|
||||
* react to `onScan`.
|
||||
*
|
||||
* Failure modes we explicitly surface (instead of a silent black screen):
|
||||
* - Insecure context (HTTP) — getUserMedia is unavailable.
|
||||
* - Camera permission denied.
|
||||
* - No camera on the device.
|
||||
* - `facingMode: 'environment'` not satisfiable (some laptops, some
|
||||
* locked-down WebViews). We retry with the front camera.
|
||||
* - `start()` resolves but the video never emits `loadedmetadata` within
|
||||
* `VIDEO_READY_TIMEOUT_MS` — usually means the worker engine failed to
|
||||
* initialize or another app is holding the camera.
|
||||
*/
|
||||
export function QrScannerDialog({ isOpen, onClose, onScan, title }: QrScannerDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
// Callback ref so we know the moment the element is attached. Radix
|
||||
// Dialog mounts content lazily inside a Portal, so a plain `useRef` is
|
||||
// still null on the first effect tick after `isOpen` flips to true.
|
||||
// A state-backed ref re-runs the effect once the <video> actually
|
||||
// exists in the DOM.
|
||||
const [video, setVideo] = useState<HTMLVideoElement | null>(null);
|
||||
const scannerRef = useRef<QrScanner | null>(null);
|
||||
// Keep the latest onScan in a ref so the start effect doesn't tear down
|
||||
// the camera every time the parent passes a new callback identity.
|
||||
const onScanRef = useRef(onScan);
|
||||
onScanRef.current = onScan;
|
||||
|
||||
const [status, setStatus] = useState<'idle' | 'starting' | 'running' | 'error'>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasFlash, setHasFlash] = useState(false);
|
||||
const [flashOn, setFlashOn] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (!video) return;
|
||||
|
||||
// Secure-context guard — getUserMedia is only available on https / localhost.
|
||||
if (typeof window !== 'undefined' && window.isSecureContext === false) {
|
||||
setStatus('error');
|
||||
setError(t('qrScanner.errors.insecure'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
setStatus('error');
|
||||
setError(t('qrScanner.errors.unsupported'));
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let readyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
setStatus('starting');
|
||||
setError(null);
|
||||
|
||||
const handleDecode = (result: QrScanner.ScanResult) => {
|
||||
if (cancelled) return;
|
||||
cancelled = true;
|
||||
scanner.stop();
|
||||
onScanRef.current(result.data);
|
||||
};
|
||||
|
||||
let scanner: QrScanner;
|
||||
try {
|
||||
scanner = new QrScanner(
|
||||
video,
|
||||
handleDecode,
|
||||
{
|
||||
returnDetailedScanResult: true,
|
||||
highlightScanRegion: true,
|
||||
highlightCodeOutline: true,
|
||||
preferredCamera: 'environment',
|
||||
maxScansPerSecond: 5,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
setError(humanizeCameraError(err, t));
|
||||
return;
|
||||
}
|
||||
|
||||
scannerRef.current = scanner;
|
||||
|
||||
/**
|
||||
* Watch the video element. If it never reaches `HAVE_METADATA` within the
|
||||
* timeout, the scanner is silently broken. Surface that instead of a
|
||||
* black screen.
|
||||
*/
|
||||
const armReadyTimeout = () => {
|
||||
readyTimer = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
if (video.readyState < 1 /* HAVE_METADATA */) {
|
||||
cancelled = true;
|
||||
scanner.stop();
|
||||
setStatus('error');
|
||||
setError(t('qrScanner.errors.didntStart'));
|
||||
}
|
||||
}, VIDEO_READY_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
const clearReadyTimeout = () => {
|
||||
if (readyTimer) {
|
||||
clearTimeout(readyTimer);
|
||||
readyTimer = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const onLoadedMetadata = () => clearReadyTimeout();
|
||||
video.addEventListener('loadedmetadata', onLoadedMetadata, { once: true });
|
||||
|
||||
/**
|
||||
* Some devices reject `facingMode: 'environment'` with OverconstrainedError
|
||||
* (laptops without a rear camera, some Android WebViews). Retry without a
|
||||
* camera preference so the browser picks any available device.
|
||||
*/
|
||||
const startWithFallback = async () => {
|
||||
try {
|
||||
await scanner.start();
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
if (isOverconstrainedError(err)) {
|
||||
try {
|
||||
await scanner.setCamera('user');
|
||||
return;
|
||||
} catch {
|
||||
// Fall through and report the original error.
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
startWithFallback()
|
||||
.then(async () => {
|
||||
if (cancelled) return;
|
||||
clearReadyTimeout();
|
||||
setStatus('running');
|
||||
armReadyTimeout();
|
||||
try {
|
||||
const flashAvailable = await scanner.hasFlash();
|
||||
if (!cancelled) setHasFlash(flashAvailable);
|
||||
} catch {
|
||||
// Flash detection is best-effort; ignore.
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
clearReadyTimeout();
|
||||
setStatus('error');
|
||||
setError(humanizeCameraError(err, t));
|
||||
});
|
||||
|
||||
// Arm an initial timeout in case `start()` neither resolves nor rejects
|
||||
// (e.g. the worker engine wedges on CSP-blocked blob creation, or the
|
||||
// OS permission dialog is dismissed without a callback firing).
|
||||
armReadyTimeout();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearReadyTimeout();
|
||||
video.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
scanner.stop();
|
||||
scanner.destroy();
|
||||
scannerRef.current = null;
|
||||
setHasFlash(false);
|
||||
setFlashOn(false);
|
||||
};
|
||||
}, [isOpen, video, t]);
|
||||
|
||||
const toggleFlash = async () => {
|
||||
const scanner = scannerRef.current;
|
||||
if (!scanner) return;
|
||||
try {
|
||||
await scanner.toggleFlash();
|
||||
setFlashOn(scanner.isFlashOn());
|
||||
} catch {
|
||||
// Ignore — some devices report `hasFlash` true but error on toggle.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||
<DialogContent className="max-w-[min(380px,calc(100vw-2rem))] max-h-[calc(100svh-2rem)] rounded-2xl p-0 gap-0 border-border overflow-hidden flex flex-col [&>button]:hidden">
|
||||
<div className="flex items-center justify-between px-4 h-12 shrink-0">
|
||||
<DialogTitle className="text-base font-semibold flex items-center gap-1.5">
|
||||
{title ?? t('qrScanner.title')}
|
||||
</DialogTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 motion-safe:transition-colors"
|
||||
aria-label={t('qrScanner.close')}
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative w-full aspect-square bg-black overflow-hidden shrink-0"
|
||||
style={{ maxHeight: 'min(380px, calc(100vw - 2rem))' }}
|
||||
>
|
||||
<video
|
||||
ref={setVideo}
|
||||
className={cn(
|
||||
'absolute inset-0 w-full h-full object-cover',
|
||||
status !== 'running' && 'opacity-0',
|
||||
)}
|
||||
playsInline
|
||||
muted
|
||||
/>
|
||||
|
||||
{status === 'starting' && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-white/80">
|
||||
<Loader2 className="size-8 animate-spin" />
|
||||
<p className="text-sm">{t('qrScanner.starting')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center p-6">
|
||||
<Alert variant="destructive" className="bg-background">
|
||||
<Camera className="size-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
{error || t('qrScanner.errors.generic')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'running' && hasFlash && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleFlash}
|
||||
aria-label={flashOn ? t('qrScanner.flashOff') : t('qrScanner.flashOn')}
|
||||
className="absolute bottom-3 right-3 size-10 rounded-full bg-black/50 backdrop-blur-sm text-white flex items-center justify-center hover:bg-black/70 motion-safe:transition-colors"
|
||||
>
|
||||
{flashOn ? <ZapOff className="size-5" /> : <Zap className="size-5" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 shrink-0">
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{t('qrScanner.aim')}
|
||||
</p>
|
||||
{status === 'error' && (
|
||||
<Button onClick={onClose} variant="outline" className="w-full mt-3">
|
||||
{t('qrScanner.close')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function isOverconstrainedError(err: unknown): boolean {
|
||||
if (!err || typeof err !== 'object') return false;
|
||||
const name = (err as { name?: unknown }).name;
|
||||
if (name === 'OverconstrainedError') return true;
|
||||
const msg = err instanceof Error ? err.message : '';
|
||||
return /overconstrained|constraint/i.test(msg);
|
||||
}
|
||||
|
||||
function humanizeCameraError(err: unknown, t: (key: string) => string): string {
|
||||
const name = (err && typeof err === 'object' ? (err as { name?: unknown }).name : undefined);
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
if (name === 'NotFoundError' || /no camera/i.test(msg) || /not found/i.test(msg)) {
|
||||
return t('qrScanner.errors.notFound');
|
||||
}
|
||||
if (name === 'NotAllowedError' || /permission/i.test(msg) || /denied/i.test(msg)) {
|
||||
return t('qrScanner.errors.denied');
|
||||
}
|
||||
if (name === 'NotReadableError' || /in use|busy|readable/i.test(msg)) {
|
||||
return t('qrScanner.errors.busy');
|
||||
}
|
||||
if (name === 'SecurityError' || /secure context/i.test(msg) || /https/i.test(msg)) {
|
||||
return t('qrScanner.errors.insecure');
|
||||
}
|
||||
if (name === 'OverconstrainedError' || /overconstrained|constraint/i.test(msg)) {
|
||||
return t('qrScanner.errors.overconstrained');
|
||||
}
|
||||
return msg || t('qrScanner.errors.generic');
|
||||
}
|
||||
@@ -584,6 +584,48 @@ export function validateBitcoinAddress(address: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed shape of a `bitcoin:` BIP-21 URI.
|
||||
*
|
||||
* Only the fields the wallet actually consumes are surfaced. The amount,
|
||||
* label, and message parameters are ignored — the Send dialog lets the user
|
||||
* pick the USD amount, and we have no lightning fallback to consume.
|
||||
*/
|
||||
export interface ParsedBitcoinUri {
|
||||
/** On-chain address from the URI path. May be empty for sp-only URIs. */
|
||||
address: string;
|
||||
/** BIP-352 silent payment address from the `sp=` parameter, if present. */
|
||||
sp?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `bitcoin:` BIP-21 URI into its address + optional silent-payment
|
||||
* fallback. Returns `null` for anything that isn't `bitcoin:…` (the scheme
|
||||
* check is case-insensitive).
|
||||
*
|
||||
* Validation of the address / `sp` values is left to the caller — this
|
||||
* helper just splits the URI. A BIP-21 URI like
|
||||
* `bitcoin:bc1q…?sp=sp1q…` is interpreted by callers as "send via silent
|
||||
* payment if you can; otherwise fall back to the on-chain address".
|
||||
*/
|
||||
export function parseBitcoinUri(input: string): ParsedBitcoinUri | null {
|
||||
const trimmed = input.trim();
|
||||
if (!/^bitcoin:/i.test(trimmed)) return null;
|
||||
|
||||
const payload = trimmed.slice('bitcoin:'.length);
|
||||
const qIdx = payload.indexOf('?');
|
||||
const address = (qIdx === -1 ? payload : payload.slice(0, qIdx)).trim();
|
||||
|
||||
let sp: string | undefined;
|
||||
if (qIdx !== -1) {
|
||||
// URLSearchParams handles percent-decoding and repeated keys.
|
||||
const params = new URLSearchParams(payload.slice(qIdx + 1));
|
||||
sp = params.get('sp')?.trim() || undefined;
|
||||
}
|
||||
|
||||
return { address, sp };
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a signed transaction hex to the Bitcoin network via an
|
||||
* Esplora-compatible API. Returns the txid.
|
||||
|
||||
+20
-1
@@ -1056,7 +1056,8 @@
|
||||
"sendingNostr": "الإرسال إلى عنوان مستخدم Nostr على السلسلة.",
|
||||
"sendingRaw": "الإرسال إلى عنوان بيتكوين خام.",
|
||||
"useSilentPayment": "استخدام الدفع الصامت بدلاً من ذلك",
|
||||
"useOnchain": "استخدام عنوان على السلسلة بدلاً من ذلك"
|
||||
"useOnchain": "استخدام عنوان على السلسلة بدلاً من ذلك",
|
||||
"scan": "مسح رمز QR"
|
||||
},
|
||||
"feeSpeed": {
|
||||
"fastest": "~10 دقائق",
|
||||
@@ -1092,6 +1093,24 @@
|
||||
"done": "تم"
|
||||
}
|
||||
},
|
||||
"qrScanner": {
|
||||
"title": "مسح رمز QR",
|
||||
"close": "إغلاق",
|
||||
"starting": "جارٍ تشغيل الكاميرا…",
|
||||
"aim": "وجّه الكاميرا نحو رمز QR.",
|
||||
"flashOn": "تشغيل الفلاش",
|
||||
"flashOff": "إيقاف الفلاش",
|
||||
"errors": {
|
||||
"generic": "تعذّر الوصول إلى الكاميرا.",
|
||||
"insecure": "يتطلب الوصول إلى الكاميرا اتصالاً آمناً (HTTPS).",
|
||||
"unsupported": "هذا المتصفح لا يدعم الوصول إلى الكاميرا.",
|
||||
"notFound": "لم يُعثر على أي كاميرا في هذا الجهاز.",
|
||||
"denied": "تم رفض إذن الكاميرا. فعّله من الإعدادات لمسح رموز QR.",
|
||||
"busy": "تطبيق آخر يستخدم الكاميرا. أغلقه وحاول مجدداً.",
|
||||
"overconstrained": "كاميرا هذا الجهاز لا تدعم الإعدادات المطلوبة.",
|
||||
"didntStart": "لم تبدأ الكاميرا. ربما يستخدمها تطبيق آخر، أو ربما حظر متصفحك الماسح."
|
||||
}
|
||||
},
|
||||
"bitcoinPublic": {
|
||||
"lead": "الأموال التي ترسلها علنية ويمكن تتبّعها إليك.",
|
||||
"learnMore": "اعرف المزيد",
|
||||
|
||||
+20
-1
@@ -1495,7 +1495,8 @@
|
||||
"sendingNostr": "Sending to a Nostr user's on-chain address.",
|
||||
"sendingRaw": "Sending to a raw Bitcoin address.",
|
||||
"useSilentPayment": "Use silent payment instead",
|
||||
"useOnchain": "Use on-chain address instead"
|
||||
"useOnchain": "Use on-chain address instead",
|
||||
"scan": "Scan QR code"
|
||||
},
|
||||
"feeSpeed": {
|
||||
"fastest": "~10 min",
|
||||
@@ -1531,6 +1532,24 @@
|
||||
"done": "Done"
|
||||
}
|
||||
},
|
||||
"qrScanner": {
|
||||
"title": "Scan QR code",
|
||||
"close": "Close",
|
||||
"starting": "Starting camera…",
|
||||
"aim": "Point your camera at a QR code.",
|
||||
"flashOn": "Turn flash on",
|
||||
"flashOff": "Turn flash off",
|
||||
"errors": {
|
||||
"generic": "Could not access the camera.",
|
||||
"insecure": "Camera access requires a secure (HTTPS) connection.",
|
||||
"unsupported": "This browser doesn't support camera access.",
|
||||
"notFound": "No camera was found on this device.",
|
||||
"denied": "Camera permission was denied. Enable it in your settings to scan QR codes.",
|
||||
"busy": "Another app is using the camera. Close it and try again.",
|
||||
"overconstrained": "This device's camera doesn't support the requested settings.",
|
||||
"didntStart": "Camera didn't start. Another app may be using it, or your browser may have blocked the scanner."
|
||||
}
|
||||
},
|
||||
"bitcoinPublic": {
|
||||
"lead": "Money you send is public and can be traced back to you.",
|
||||
"learnMore": "Learn more",
|
||||
|
||||
+20
-1
@@ -1072,7 +1072,8 @@
|
||||
"sendingNostr": "Enviando a la dirección en cadena de un usuario de Nostr.",
|
||||
"sendingRaw": "Enviando a una dirección de Bitcoin sin asociar.",
|
||||
"useSilentPayment": "Usar pago silencioso en su lugar",
|
||||
"useOnchain": "Usar dirección en cadena en su lugar"
|
||||
"useOnchain": "Usar dirección en cadena en su lugar",
|
||||
"scan": "Escanear código QR"
|
||||
},
|
||||
"feeSpeed": {
|
||||
"fastest": "~10 min",
|
||||
@@ -1108,6 +1109,24 @@
|
||||
"done": "Hecho"
|
||||
}
|
||||
},
|
||||
"qrScanner": {
|
||||
"title": "Escanear código QR",
|
||||
"close": "Cerrar",
|
||||
"starting": "Iniciando la cámara…",
|
||||
"aim": "Apunta la cámara al código QR.",
|
||||
"flashOn": "Encender el flash",
|
||||
"flashOff": "Apagar el flash",
|
||||
"errors": {
|
||||
"generic": "No se pudo acceder a la cámara.",
|
||||
"insecure": "El acceso a la cámara requiere una conexión segura (HTTPS).",
|
||||
"unsupported": "Este navegador no admite el acceso a la cámara.",
|
||||
"notFound": "No se encontró ninguna cámara en este dispositivo.",
|
||||
"denied": "Se denegó el permiso de la cámara. Actívalo en los ajustes para escanear códigos QR.",
|
||||
"busy": "Otra aplicación está usando la cámara. Ciérrala e inténtalo de nuevo.",
|
||||
"overconstrained": "La cámara de este dispositivo no admite la configuración solicitada.",
|
||||
"didntStart": "La cámara no se inició. Es posible que otra aplicación la esté usando o que el navegador haya bloqueado el escáner."
|
||||
}
|
||||
},
|
||||
"bitcoinPublic": {
|
||||
"lead": "El dinero que envías es público y puede rastrearse hasta ti.",
|
||||
"learnMore": "Más información",
|
||||
|
||||
+20
-1
@@ -1072,7 +1072,8 @@
|
||||
"sendingNostr": "ارسال به نشانی رویزنجیرهٔ یک کاربر Nostr.",
|
||||
"sendingRaw": "ارسال به یک نشانی خام بیتکوین.",
|
||||
"useSilentPayment": "استفاده از پرداخت خاموش به جای آن",
|
||||
"useOnchain": "استفاده از نشانی رویزنجیره به جای آن"
|
||||
"useOnchain": "استفاده از نشانی رویزنجیره به جای آن",
|
||||
"scan": "اسکن کد QR"
|
||||
},
|
||||
"feeSpeed": {
|
||||
"fastest": "~۱۰ دقیقه",
|
||||
@@ -1108,6 +1109,24 @@
|
||||
"done": "انجام شد"
|
||||
}
|
||||
},
|
||||
"qrScanner": {
|
||||
"title": "اسکن کد QR",
|
||||
"close": "بستن",
|
||||
"starting": "در حال راهاندازی دوربین…",
|
||||
"aim": "دوربین خود را به سمت کد QR بگیرید.",
|
||||
"flashOn": "روشن کردن فلاش",
|
||||
"flashOff": "خاموش کردن فلاش",
|
||||
"errors": {
|
||||
"generic": "دسترسی به دوربین ممکن نشد.",
|
||||
"insecure": "دسترسی به دوربین به یک اتصال امن (HTTPS) نیاز دارد.",
|
||||
"unsupported": "این مرورگر از دسترسی به دوربین پشتیبانی نمیکند.",
|
||||
"notFound": "هیچ دوربینی روی این دستگاه پیدا نشد.",
|
||||
"denied": "اجازهٔ دسترسی به دوربین رد شد. برای اسکن کدهای QR آن را در تنظیمات فعال کنید.",
|
||||
"busy": "برنامهٔ دیگری از دوربین استفاده میکند. آن را ببندید و دوباره تلاش کنید.",
|
||||
"overconstrained": "دوربین این دستگاه از تنظیمات درخواستشده پشتیبانی نمیکند.",
|
||||
"didntStart": "دوربین شروع به کار نکرد. ممکن است برنامهٔ دیگری از آن استفاده کند، یا مرورگر شما اسکنر را مسدود کرده باشد."
|
||||
}
|
||||
},
|
||||
"bitcoinPublic": {
|
||||
"lead": "پولی که ارسال میکنید عمومی است و میتواند به شما بازیابی شود.",
|
||||
"learnMore": "بیشتر بدانید",
|
||||
|
||||
+20
-1
@@ -1494,7 +1494,8 @@
|
||||
"sendingNostr": "Envoi vers l'adresse on-chain d'un utilisateur Nostr.",
|
||||
"sendingRaw": "Envoi vers une adresse Bitcoin brute.",
|
||||
"useSilentPayment": "Utiliser un paiement silencieux à la place",
|
||||
"useOnchain": "Utiliser une adresse on-chain à la place"
|
||||
"useOnchain": "Utiliser une adresse on-chain à la place",
|
||||
"scan": "Scanner le QR code"
|
||||
},
|
||||
"feeSpeed": {
|
||||
"fastest": "~10 min",
|
||||
@@ -1530,6 +1531,24 @@
|
||||
"done": "Terminé"
|
||||
}
|
||||
},
|
||||
"qrScanner": {
|
||||
"title": "Scanner le QR code",
|
||||
"close": "Fermer",
|
||||
"starting": "Démarrage de la caméra…",
|
||||
"aim": "Pointez votre caméra vers un QR code.",
|
||||
"flashOn": "Activer le flash",
|
||||
"flashOff": "Désactiver le flash",
|
||||
"errors": {
|
||||
"generic": "Impossible d'accéder à la caméra.",
|
||||
"insecure": "L'accès à la caméra nécessite une connexion sécurisée (HTTPS).",
|
||||
"unsupported": "Ce navigateur ne prend pas en charge l'accès à la caméra.",
|
||||
"notFound": "Aucune caméra n'a été trouvée sur cet appareil.",
|
||||
"denied": "L'autorisation de la caméra a été refusée. Activez-la dans vos paramètres pour scanner des QR codes.",
|
||||
"busy": "Une autre application utilise la caméra. Fermez-la et réessayez.",
|
||||
"overconstrained": "La caméra de cet appareil ne prend pas en charge les paramètres demandés.",
|
||||
"didntStart": "La caméra n'a pas démarré. Une autre application l'utilise peut-être, ou votre navigateur a peut-être bloqué le scanner."
|
||||
}
|
||||
},
|
||||
"bitcoinPublic": {
|
||||
"lead": "L'argent que vous envoyez est public et peut être retracé jusqu'à vous.",
|
||||
"learnMore": "En savoir plus",
|
||||
|
||||
+20
-1
@@ -1440,7 +1440,8 @@
|
||||
"sendingNostr": "एक Nostr यूज़र के ऑन-चेन एड्रेस पर भेजा जा रहा है।",
|
||||
"sendingRaw": "एक raw Bitcoin एड्रेस पर भेजा जा रहा है।",
|
||||
"useSilentPayment": "इसके बजाय साइलेंट पेमेंट का उपयोग करें",
|
||||
"useOnchain": "इसके बजाय ऑन-चेन एड्रेस का उपयोग करें"
|
||||
"useOnchain": "इसके बजाय ऑन-चेन एड्रेस का उपयोग करें",
|
||||
"scan": "QR कोड स्कैन करें"
|
||||
},
|
||||
"feeSpeed": {
|
||||
"fastest": "~10 मिनट",
|
||||
@@ -1476,6 +1477,24 @@
|
||||
"done": "हो गया"
|
||||
}
|
||||
},
|
||||
"qrScanner": {
|
||||
"title": "QR कोड स्कैन करें",
|
||||
"close": "बंद करें",
|
||||
"starting": "कैमरा शुरू हो रहा है…",
|
||||
"aim": "अपने कैमरे को QR कोड पर ले जाएँ।",
|
||||
"flashOn": "फ़्लैश चालू करें",
|
||||
"flashOff": "फ़्लैश बंद करें",
|
||||
"errors": {
|
||||
"generic": "कैमरे तक नहीं पहुँचा जा सका।",
|
||||
"insecure": "कैमरा एक्सेस के लिए सुरक्षित (HTTPS) कनेक्शन ज़रूरी है।",
|
||||
"unsupported": "यह ब्राउज़र कैमरा एक्सेस को सपोर्ट नहीं करता।",
|
||||
"notFound": "इस डिवाइस पर कोई कैमरा नहीं मिला।",
|
||||
"denied": "कैमरे की अनुमति अस्वीकृत कर दी गई। QR कोड स्कैन करने के लिए इसे अपनी सेटिंग्स में चालू करें।",
|
||||
"busy": "कोई दूसरा ऐप कैमरे का इस्तेमाल कर रहा है। उसे बंद करके दोबारा कोशिश करें।",
|
||||
"overconstrained": "इस डिवाइस का कैमरा अनुरोधित सेटिंग्स को सपोर्ट नहीं करता।",
|
||||
"didntStart": "कैमरा शुरू नहीं हुआ। कोई दूसरा ऐप इसका उपयोग कर रहा हो सकता है, या आपके ब्राउज़र ने स्कैनर को ब्लॉक कर दिया हो।"
|
||||
}
|
||||
},
|
||||
"bitcoinPublic": {
|
||||
"lead": "आप जो पैसा भेजते हैं वह सार्वजनिक है और आप तक trace किया जा सकता है।",
|
||||
"learnMore": "और जानें",
|
||||
|
||||
+20
-1
@@ -1440,7 +1440,8 @@
|
||||
"sendingNostr": "Mengirim ke alamat on-chain pengguna Nostr.",
|
||||
"sendingRaw": "Mengirim ke alamat Bitcoin mentah.",
|
||||
"useSilentPayment": "Gunakan silent payment saja",
|
||||
"useOnchain": "Gunakan alamat on-chain saja"
|
||||
"useOnchain": "Gunakan alamat on-chain saja",
|
||||
"scan": "Pindai kode QR"
|
||||
},
|
||||
"feeSpeed": {
|
||||
"fastest": "~10 menit",
|
||||
@@ -1476,6 +1477,24 @@
|
||||
"done": "Selesai"
|
||||
}
|
||||
},
|
||||
"qrScanner": {
|
||||
"title": "Pindai kode QR",
|
||||
"close": "Tutup",
|
||||
"starting": "Menyalakan kamera…",
|
||||
"aim": "Arahkan kamera Anda ke kode QR.",
|
||||
"flashOn": "Nyalakan lampu kilat",
|
||||
"flashOff": "Matikan lampu kilat",
|
||||
"errors": {
|
||||
"generic": "Tidak bisa mengakses kamera.",
|
||||
"insecure": "Akses kamera memerlukan koneksi yang aman (HTTPS).",
|
||||
"unsupported": "Peramban ini tidak mendukung akses kamera.",
|
||||
"notFound": "Tidak ada kamera yang ditemukan di perangkat ini.",
|
||||
"denied": "Izin kamera ditolak. Aktifkan di pengaturan untuk memindai kode QR.",
|
||||
"busy": "Aplikasi lain sedang menggunakan kamera. Tutup dan coba lagi.",
|
||||
"overconstrained": "Kamera perangkat ini tidak mendukung pengaturan yang diminta.",
|
||||
"didntStart": "Kamera tidak menyala. Mungkin aplikasi lain sedang menggunakannya, atau peramban Anda memblokir pemindai."
|
||||
}
|
||||
},
|
||||
"bitcoinPublic": {
|
||||
"lead": "Uang yang Anda kirim bersifat publik dan bisa dilacak kembali ke Anda.",
|
||||
"learnMore": "Pelajari lebih lanjut",
|
||||
|
||||
+20
-1
@@ -1072,7 +1072,8 @@
|
||||
"sendingNostr": "ផ្ញើទៅអាសយដ្ឋានលើខ្សែសង្វាក់របស់អ្នកប្រើ Nostr ម្នាក់។",
|
||||
"sendingRaw": "ផ្ញើទៅអាសយដ្ឋាន Bitcoin ដើម។",
|
||||
"useSilentPayment": "ប្រើការទូទាត់ស្ងាត់ជំនួសវិញ",
|
||||
"useOnchain": "ប្រើអាសយដ្ឋានលើខ្សែសង្វាក់ជំនួសវិញ"
|
||||
"useOnchain": "ប្រើអាសយដ្ឋានលើខ្សែសង្វាក់ជំនួសវិញ",
|
||||
"scan": "ស្កេនកូដ QR"
|
||||
},
|
||||
"feeSpeed": {
|
||||
"fastest": "~១០ នាទី",
|
||||
@@ -1108,6 +1109,24 @@
|
||||
"done": "រួចរាល់"
|
||||
}
|
||||
},
|
||||
"qrScanner": {
|
||||
"title": "ស្កេនកូដ QR",
|
||||
"close": "បិទ",
|
||||
"starting": "កំពុងចាប់ផ្ដើមកាមេរ៉ា…",
|
||||
"aim": "តម្រង់កាមេរ៉ារបស់អ្នកទៅកាន់កូដ QR។",
|
||||
"flashOn": "បើកពិល",
|
||||
"flashOff": "បិទពិល",
|
||||
"errors": {
|
||||
"generic": "មិនអាចចូលប្រើកាមេរ៉ាបានទេ។",
|
||||
"insecure": "ការចូលប្រើកាមេរ៉ាទាមទារការតភ្ជាប់សុវត្ថិភាព (HTTPS)។",
|
||||
"unsupported": "កម្មវិធីរុករកនេះមិនគាំទ្រការចូលប្រើកាមេរ៉ាទេ។",
|
||||
"notFound": "រកមិនឃើញកាមេរ៉ានៅលើឧបករណ៍នេះទេ។",
|
||||
"denied": "ការអនុញ្ញាតកាមេរ៉ាត្រូវបានបដិសេធ។ បើកវានៅក្នុងការកំណត់របស់អ្នកដើម្បីស្កេនកូដ QR។",
|
||||
"busy": "កម្មវិធីផ្សេងទៀតកំពុងប្រើកាមេរ៉ា។ បិទវាហើយព្យាយាមម្តងទៀត។",
|
||||
"overconstrained": "កាមេរ៉ានៃឧបករណ៍នេះមិនគាំទ្រការកំណត់ដែលបានស្នើទេ។",
|
||||
"didntStart": "កាមេរ៉ាមិនបានចាប់ផ្ដើមទេ។ កម្មវិធីផ្សេងទៀតប្រហែលជាកំពុងប្រើវា ឬកម្មវិធីរុករករបស់អ្នកប្រហែលជាបានរារាំងម៉ាស៊ីនស្កេន។"
|
||||
}
|
||||
},
|
||||
"bitcoinPublic": {
|
||||
"lead": "ប្រាក់ដែលអ្នកផ្ញើគឺសាធារណៈ ហើយអាចត្រូវបានតាមដានទៅអ្នកវិញ។",
|
||||
"learnMore": "ស្វែងយល់បន្ថែម",
|
||||
|
||||
+20
-1
@@ -1072,7 +1072,8 @@
|
||||
"sendingNostr": "د Nostr کارن د زنځیر په پته لیږل.",
|
||||
"sendingRaw": "یوې خامه بټکوین پتې ته لیږل.",
|
||||
"useSilentPayment": "پر ځای يې د چوپې ورکړې وکاروئ",
|
||||
"useOnchain": "پر ځای يې د زنځیر په پته وکاروئ"
|
||||
"useOnchain": "پر ځای يې د زنځیر په پته وکاروئ",
|
||||
"scan": "د QR کوډ سکن کړئ"
|
||||
},
|
||||
"feeSpeed": {
|
||||
"fastest": "~۱۰ دقیقې",
|
||||
@@ -1108,6 +1109,24 @@
|
||||
"done": "بشپړ"
|
||||
}
|
||||
},
|
||||
"qrScanner": {
|
||||
"title": "د QR کوډ سکن کړئ",
|
||||
"close": "بندول",
|
||||
"starting": "کیمره پیلیږي…",
|
||||
"aim": "خپله کیمره د QR کوډ پر لور ونیسئ.",
|
||||
"flashOn": "فلش بل کړئ",
|
||||
"flashOff": "فلش مړ کړئ",
|
||||
"errors": {
|
||||
"generic": "کیمرې ته لاسرسی ممکن نه شو.",
|
||||
"insecure": "کیمرې ته لاسرسي ته خوندي (HTTPS) اړیکې اړتیا ده.",
|
||||
"unsupported": "دا براوزر کیمرې ته لاسرسی نه ملاتړي.",
|
||||
"notFound": "په دې وسیله کې هېڅ کیمره ونه موندل شوه.",
|
||||
"denied": "د کیمرې اجازه رد شوه. د QR کوډونو د سکن کولو لپاره یې په تنظیماتو کې فعاله کړئ.",
|
||||
"busy": "بل اپلیکیشن کیمره کاروي. هغه وتړئ او بیا هڅه وکړئ.",
|
||||
"overconstrained": "د دې وسیلې کیمره غوښتل شوي تنظیمات نه ملاتړي.",
|
||||
"didntStart": "کیمره پیل نه شوه. کېدای شي بل اپلیکیشن یې کاروي، یا ستاسو براوزر سکنر بند کړی وي."
|
||||
}
|
||||
},
|
||||
"bitcoinPublic": {
|
||||
"lead": "هغه پیسې چې لیږئ عامه دي او تاسو ته بیرته تعقیب کیدلی شي.",
|
||||
"learnMore": "نور زده کړئ",
|
||||
|
||||
+20
-1
@@ -1504,7 +1504,8 @@
|
||||
"sendingNostr": "Enviando para o endereço on-chain de um usuário Nostr.",
|
||||
"sendingRaw": "Enviando para um endereço Bitcoin bruto.",
|
||||
"useSilentPayment": "Usar pagamento silencioso",
|
||||
"useOnchain": "Usar endereço on-chain"
|
||||
"useOnchain": "Usar endereço on-chain",
|
||||
"scan": "Escanear código QR"
|
||||
},
|
||||
"feeSpeed": {
|
||||
"fastest": "~10 min",
|
||||
@@ -1540,6 +1541,24 @@
|
||||
"done": "Concluído"
|
||||
}
|
||||
},
|
||||
"qrScanner": {
|
||||
"title": "Escanear código QR",
|
||||
"close": "Fechar",
|
||||
"starting": "Iniciando a câmera…",
|
||||
"aim": "Aponte a câmera para um código QR.",
|
||||
"flashOn": "Ligar o flash",
|
||||
"flashOff": "Desligar o flash",
|
||||
"errors": {
|
||||
"generic": "Não foi possível acessar a câmera.",
|
||||
"insecure": "O acesso à câmera requer uma conexão segura (HTTPS).",
|
||||
"unsupported": "Este navegador não suporta acesso à câmera.",
|
||||
"notFound": "Nenhuma câmera foi encontrada neste dispositivo.",
|
||||
"denied": "A permissão da câmera foi negada. Ative-a nas configurações para escanear códigos QR.",
|
||||
"busy": "Outro aplicativo está usando a câmera. Feche-o e tente novamente.",
|
||||
"overconstrained": "A câmera deste dispositivo não suporta as configurações solicitadas.",
|
||||
"didntStart": "A câmera não iniciou. Outro aplicativo pode estar usando-a, ou seu navegador pode ter bloqueado o scanner."
|
||||
}
|
||||
},
|
||||
"bitcoinPublic": {
|
||||
"lead": "O dinheiro que você envia é público e pode ser rastreado até você.",
|
||||
"learnMore": "Saiba mais",
|
||||
|
||||
+20
-1
@@ -1504,7 +1504,8 @@
|
||||
"sendingNostr": "Отправка на адрес пользователя Nostr в блокчейне.",
|
||||
"sendingRaw": "Отправка на сырой Bitcoin-адрес.",
|
||||
"useSilentPayment": "Использовать тихий платёж",
|
||||
"useOnchain": "Использовать адрес в блокчейне"
|
||||
"useOnchain": "Использовать адрес в блокчейне",
|
||||
"scan": "Сканировать QR-код"
|
||||
},
|
||||
"feeSpeed": {
|
||||
"fastest": "~10 мин",
|
||||
@@ -1540,6 +1541,24 @@
|
||||
"done": "Готово"
|
||||
}
|
||||
},
|
||||
"qrScanner": {
|
||||
"title": "Сканировать QR-код",
|
||||
"close": "Закрыть",
|
||||
"starting": "Запуск камеры…",
|
||||
"aim": "Наведите камеру на QR-код.",
|
||||
"flashOn": "Включить вспышку",
|
||||
"flashOff": "Выключить вспышку",
|
||||
"errors": {
|
||||
"generic": "Не удалось получить доступ к камере.",
|
||||
"insecure": "Доступ к камере требует защищённого (HTTPS) соединения.",
|
||||
"unsupported": "Этот браузер не поддерживает доступ к камере.",
|
||||
"notFound": "Камера на этом устройстве не найдена.",
|
||||
"denied": "Доступ к камере запрещён. Разрешите его в настройках, чтобы сканировать QR-коды.",
|
||||
"busy": "Камеру использует другое приложение. Закройте его и попробуйте снова.",
|
||||
"overconstrained": "Камера этого устройства не поддерживает запрошенные настройки.",
|
||||
"didntStart": "Камера не запустилась. Возможно, её использует другое приложение, или браузер заблокировал сканер."
|
||||
}
|
||||
},
|
||||
"bitcoinPublic": {
|
||||
"lead": "Отправляемые вами деньги публичны и могут быть отслежены до вас.",
|
||||
"learnMore": "Узнать больше",
|
||||
|
||||
+20
-1
@@ -1072,7 +1072,8 @@
|
||||
"sendingNostr": "Kutumira kukero yepacheni yemushandisi weNostr.",
|
||||
"sendingRaw": "Kutumira kukero yeBitcoin yakangosaina.",
|
||||
"useSilentPayment": "Shandisa mubhadharo wakanyararira pachinzvimbo",
|
||||
"useOnchain": "Shandisa kero yepacheni pachinzvimbo"
|
||||
"useOnchain": "Shandisa kero yepacheni pachinzvimbo",
|
||||
"scan": "Skena kodhi yeQR"
|
||||
},
|
||||
"feeSpeed": {
|
||||
"fastest": "~10 maminitsi",
|
||||
@@ -1108,6 +1109,24 @@
|
||||
"done": "Zvapera"
|
||||
}
|
||||
},
|
||||
"qrScanner": {
|
||||
"title": "Skena kodhi yeQR",
|
||||
"close": "Vhara",
|
||||
"starting": "Kutanga kamera…",
|
||||
"aim": "Nongedza kamera yako kukodhi yeQR.",
|
||||
"flashOn": "Batidza mwenje",
|
||||
"flashOff": "Dzima mwenje",
|
||||
"errors": {
|
||||
"generic": "Hazvina kukwanisika kushandisa kamera.",
|
||||
"insecure": "Kushandisa kamera kunoda kubatana kwakachengeteka (HTTPS).",
|
||||
"unsupported": "Browser iyi haitsigire kushandisa kamera.",
|
||||
"notFound": "Hapana kamera yakawanikwa pamudziyo uyu.",
|
||||
"denied": "Mvumo yekamera yakarambwa. Ibvumire mukurongedza kwako kuti uskene makodhi eQR.",
|
||||
"busy": "Imwe app iri kushandisa kamera. Ivhare wozoedza zvakare.",
|
||||
"overconstrained": "Kamera yemudziyo uyu haitsigire zvakumbirwa.",
|
||||
"didntStart": "Kamera haina kutanga. Imwe app inogona kunge iri kuishandisa, kana browser yako yakanga yavhara skena."
|
||||
}
|
||||
},
|
||||
"bitcoinPublic": {
|
||||
"lead": "Mari yaunotumira inozivikanwa nemunhu wese uye inogona kudzokerwa kwauri.",
|
||||
"learnMore": "Dzidza zvakawanda",
|
||||
|
||||
+20
-1
@@ -1399,7 +1399,8 @@
|
||||
"sendingNostr": "Inatuma kwa anwani ya katika-mnyororo ya mtumiaji wa Nostr.",
|
||||
"sendingRaw": "Inatuma kwa anwani ghafi ya Bitcoin.",
|
||||
"useSilentPayment": "Tumia malipo ya kimya badala yake",
|
||||
"useOnchain": "Tumia anwani ya katika-mnyororo badala yake"
|
||||
"useOnchain": "Tumia anwani ya katika-mnyororo badala yake",
|
||||
"scan": "Changanua msimbo wa QR"
|
||||
},
|
||||
"feeSpeed": {
|
||||
"fastest": "~dakika 10",
|
||||
@@ -1435,6 +1436,24 @@
|
||||
"done": "Imekamilika"
|
||||
}
|
||||
},
|
||||
"qrScanner": {
|
||||
"title": "Changanua msimbo wa QR",
|
||||
"close": "Funga",
|
||||
"starting": "Inawasha kamera…",
|
||||
"aim": "Elekeza kamera yako kwa msimbo wa QR.",
|
||||
"flashOn": "Washa mweko",
|
||||
"flashOff": "Zima mweko",
|
||||
"errors": {
|
||||
"generic": "Haikuweza kufikia kamera.",
|
||||
"insecure": "Ufikiaji wa kamera unahitaji muunganisho salama (HTTPS).",
|
||||
"unsupported": "Kivinjari hiki hakitumii ufikiaji wa kamera.",
|
||||
"notFound": "Hakuna kamera iliyopatikana kwenye kifaa hiki.",
|
||||
"denied": "Idhini ya kamera imekataliwa. Iwashe katika mipangilio yako ili kuchanganua misimbo ya QR.",
|
||||
"busy": "Programu nyingine inatumia kamera. Ifunge na ujaribu tena.",
|
||||
"overconstrained": "Kamera ya kifaa hiki haitumii mipangilio iliyoombwa.",
|
||||
"didntStart": "Kamera haikuanza. Programu nyingine inaweza kuwa inaitumia, au kivinjari chako kinaweza kuwa kimezuia kichanganuzi."
|
||||
}
|
||||
},
|
||||
"bitcoinPublic": {
|
||||
"lead": "Pesa unazotuma ni za umma na zinaweza kufuatiliwa hadi kwako.",
|
||||
"learnMore": "Jifunze zaidi",
|
||||
|
||||
+20
-1
@@ -1439,7 +1439,8 @@
|
||||
"sendingNostr": "Bir Nostr kullanıcısının zincir üstü adresine gönderiliyor.",
|
||||
"sendingRaw": "Ham bir Bitcoin adresine gönderiliyor.",
|
||||
"useSilentPayment": "Bunun yerine sessiz ödeme kullan",
|
||||
"useOnchain": "Bunun yerine zincir üstü adres kullan"
|
||||
"useOnchain": "Bunun yerine zincir üstü adres kullan",
|
||||
"scan": "QR kodu tara"
|
||||
},
|
||||
"feeSpeed": {
|
||||
"fastest": "~10 dk",
|
||||
@@ -1475,6 +1476,24 @@
|
||||
"done": "Tamam"
|
||||
}
|
||||
},
|
||||
"qrScanner": {
|
||||
"title": "QR kodu tara",
|
||||
"close": "Kapat",
|
||||
"starting": "Kamera başlatılıyor…",
|
||||
"aim": "Kameranızı bir QR koduna doğrultun.",
|
||||
"flashOn": "Flaşı aç",
|
||||
"flashOff": "Flaşı kapat",
|
||||
"errors": {
|
||||
"generic": "Kameraya erişilemedi.",
|
||||
"insecure": "Kamera erişimi için güvenli (HTTPS) bir bağlantı gerekiyor.",
|
||||
"unsupported": "Bu tarayıcı kamera erişimini desteklemiyor.",
|
||||
"notFound": "Bu cihazda kamera bulunamadı.",
|
||||
"denied": "Kamera izni reddedildi. QR kodlarını taramak için ayarlarınızdan etkinleştirin.",
|
||||
"busy": "Başka bir uygulama kamerayı kullanıyor. Onu kapatıp tekrar deneyin.",
|
||||
"overconstrained": "Bu cihazın kamerası istenen ayarları desteklemiyor.",
|
||||
"didntStart": "Kamera başlamadı. Başka bir uygulama kullanıyor olabilir ya da tarayıcınız tarayıcıyı engellemiş olabilir."
|
||||
}
|
||||
},
|
||||
"bitcoinPublic": {
|
||||
"lead": "Gönderdiğiniz para herkese açıktır ve size kadar izlenebilir.",
|
||||
"learnMore": "Daha fazla bilgi",
|
||||
|
||||
@@ -1008,7 +1008,8 @@
|
||||
"sendingNostr": "傳送到某個 Nostr 使用者的鏈上地址。",
|
||||
"sendingRaw": "傳送到一個原始比特幣地址。",
|
||||
"useSilentPayment": "改用靜默支付",
|
||||
"useOnchain": "改用鏈上地址"
|
||||
"useOnchain": "改用鏈上地址",
|
||||
"scan": "掃描 QR 碼"
|
||||
},
|
||||
"feeSpeed": {
|
||||
"fastest": "~10 分鐘",
|
||||
@@ -1044,6 +1045,24 @@
|
||||
"done": "完成"
|
||||
}
|
||||
},
|
||||
"qrScanner": {
|
||||
"title": "掃描 QR 碼",
|
||||
"close": "關閉",
|
||||
"starting": "正在啟動相機…",
|
||||
"aim": "將相機對準 QR 碼。",
|
||||
"flashOn": "開啟閃光燈",
|
||||
"flashOff": "關閉閃光燈",
|
||||
"errors": {
|
||||
"generic": "無法存取相機。",
|
||||
"insecure": "存取相機需要安全(HTTPS)連線。",
|
||||
"unsupported": "此瀏覽器不支援存取相機。",
|
||||
"notFound": "此裝置上未找到相機。",
|
||||
"denied": "相機權限被拒絕。請在設定中啟用以掃描 QR 碼。",
|
||||
"busy": "另一個應用程式正在使用相機。請關閉它後再試。",
|
||||
"overconstrained": "此裝置的相機不支援所請求的設定。",
|
||||
"didntStart": "相機未啟動。可能有其他應用程式正在使用它,或者你的瀏覽器阻止了掃描器。"
|
||||
}
|
||||
},
|
||||
"bitcoinPublic": {
|
||||
"lead": "你傳送的資金是公開的,可以追溯到你。",
|
||||
"learnMore": "瞭解更多",
|
||||
|
||||
+20
-1
@@ -1072,7 +1072,8 @@
|
||||
"sendingNostr": "发送到某个 Nostr 用户的链上地址。",
|
||||
"sendingRaw": "发送到一个原始比特币地址。",
|
||||
"useSilentPayment": "改用静默支付",
|
||||
"useOnchain": "改用链上地址"
|
||||
"useOnchain": "改用链上地址",
|
||||
"scan": "扫描二维码"
|
||||
},
|
||||
"feeSpeed": {
|
||||
"fastest": "~10 分钟",
|
||||
@@ -1108,6 +1109,24 @@
|
||||
"done": "完成"
|
||||
}
|
||||
},
|
||||
"qrScanner": {
|
||||
"title": "扫描二维码",
|
||||
"close": "关闭",
|
||||
"starting": "正在启动摄像头…",
|
||||
"aim": "将摄像头对准二维码。",
|
||||
"flashOn": "打开闪光灯",
|
||||
"flashOff": "关闭闪光灯",
|
||||
"errors": {
|
||||
"generic": "无法访问摄像头。",
|
||||
"insecure": "访问摄像头需要安全(HTTPS)连接。",
|
||||
"unsupported": "此浏览器不支持访问摄像头。",
|
||||
"notFound": "此设备上未找到摄像头。",
|
||||
"denied": "摄像头权限被拒绝。请在设置中启用以扫描二维码。",
|
||||
"busy": "另一个应用正在使用摄像头。请关闭它后再试。",
|
||||
"overconstrained": "此设备的摄像头不支持所请求的设置。",
|
||||
"didntStart": "摄像头未启动。可能有其他应用正在使用它,或者你的浏览器阻止了扫描器。"
|
||||
}
|
||||
},
|
||||
"bitcoinPublic": {
|
||||
"lead": "你发送的资金是公开的,可以追溯到你。",
|
||||
"learnMore": "了解更多",
|
||||
|
||||
Reference in New Issue
Block a user