From 2f5dbcdde9e3e5734bde5b390852caaf1dd90535 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:41:12 +0800 Subject: [PATCH 1/2] feat(ui): add QR code scanner to ConnectPrompt Add browser-based QR scanning as an alternative to paste mode in the ConnectPrompt component. Mobile users can scan the Desktop QR code directly instead of manually copying connection codes. - Add qr-scanner dependency (WebWorker-based decoding, BarcodeDetector support) - Create use-qr-scanner hook wrapping camera lifecycle and cleanup - Create QrScannerView component with viewfinder overlay - ConnectPrompt auto-detects mobile (touch + narrow viewport) and defaults to scan mode - Lazy-load scanner component for zero initial bundle impact - Graceful fallback to paste mode on permission denial or no camera Co-Authored-By: Claude Opus 4.5 --- packages/ui/package.json | 1 + packages/ui/src/components/connect-prompt.tsx | 177 ++++++++++++++---- .../ui/src/components/qr-scanner-view.tsx | 82 ++++++++ packages/ui/src/hooks/use-qr-scanner.ts | 118 ++++++++++++ pnpm-lock.yaml | 23 ++- 5 files changed, 361 insertions(+), 40 deletions(-) create mode 100644 packages/ui/src/components/qr-scanner-view.tsx create mode 100644 packages/ui/src/hooks/use-qr-scanner.ts diff --git a/packages/ui/package.json b/packages/ui/package.json index 49f3133c..a1299e4d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -22,6 +22,7 @@ "clsx": "^2.1.1", "linkify-it": "^5.0.0", "next-themes": "^0.4.6", + "qr-scanner": "^1.4.2", "react": "catalog:", "react-dom": "catalog:", "react-markdown": "^10.1.0", diff --git a/packages/ui/src/components/connect-prompt.tsx b/packages/ui/src/components/connect-prompt.tsx index 8d3fbb0b..decfbb81 100644 --- a/packages/ui/src/components/connect-prompt.tsx +++ b/packages/ui/src/components/connect-prompt.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState, useEffect, useCallback, lazy, Suspense, useRef } from "react"; import { Button } from "@multica/ui/components/ui/button"; import { Textarea } from "@multica/ui/components/ui/textarea"; import { toast } from "@multica/ui/components/ui/sonner"; @@ -9,54 +9,157 @@ import { parseConnectionCode, saveConnection, } from "@multica/store"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Camera01Icon, TextIcon } from "@hugeicons/core-free-icons"; + +const LazyQrScannerView = lazy(() => + import("@multica/ui/components/qr-scanner-view").then((m) => ({ + default: m.QrScannerView, + })), +); + +type Mode = "scan" | "paste"; export function ConnectPrompt() { - const gwState = useConnectionStore((s) => s.connectionState) - const [codeInput, setCodeInput] = useState("") + const gwState = useConnectionStore((s) => s.connectionState); + const [codeInput, setCodeInput] = useState(""); + const [mode, setMode] = useState("paste"); // SSR-safe default + const [canScan, setCanScan] = useState(false); + const scannedRef = useRef(false); - const handleConnect = useCallback(() => { - const trimmed = codeInput.trim() - if (!trimmed) return - try { - const info = parseConnectionCode(trimmed) - saveConnection(info) - useConnectionStore.getState().connect(info) - setCodeInput("") - } catch (e) { - toast.error((e as Error).message) + // Detect mobile + camera capability, auto-switch to scan mode + useEffect(() => { + const isTouchDevice = + "ontouchstart" in window || navigator.maxTouchPoints > 0; + const isNarrow = window.innerWidth < 768; + const hasGetUserMedia = !!navigator.mediaDevices?.getUserMedia; + + if (hasGetUserMedia) { + setCanScan(true); + if (isTouchDevice && isNarrow) { + setMode("scan"); + } } - }, [codeInput]) + }, []); + + // Handle paste-mode connect + const handleConnect = useCallback(() => { + const trimmed = codeInput.trim(); + if (!trimmed) return; + try { + const info = parseConnectionCode(trimmed); + saveConnection(info); + useConnectionStore.getState().connect(info); + setCodeInput(""); + } catch (e) { + toast.error((e as Error).message); + } + }, [codeInput]); + + // Handle QR scan result — auto-connect, no button needed + const handleQrScan = useCallback((data: string) => { + // Prevent duplicate connects from rapid successive scans + if (scannedRef.current) return; + scannedRef.current = true; + + try { + const info = parseConnectionCode(data); + saveConnection(info); + useConnectionStore.getState().connect(info); + } catch (e) { + toast.error((e as Error).message); + // Allow re-scan on error (invalid/expired code) + scannedRef.current = false; + } + }, []); + + const handleScanError = useCallback((msg: string) => { + toast.error(msg); + setMode("paste"); + }, []); + + const isConnecting = gwState === "connecting" || gwState === "connected"; return (
-

Paste a connection code to start

- {(gwState === "connecting" || gwState === "connected") && ( -

Connecting...

+

+ {mode === "scan" + ? "Scan QR code to connect" + : "Paste a connection code to start"} +

+ {isConnecting && ( +

+ Connecting... +

)}
+ + {/* Mode toggle — only show if camera is available */} + {canScan && ( +
+ + +
+ )} + + {/* Content */}
-