import React from 'react'; import { Icon } from '@/components/icon/Icon'; import { Button } from '@/components/ui/button'; import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; import { useI18n } from '@/lib/i18n'; import { cn } from '@/lib/utils'; import { connectionDisplayUrl, useMobileConnection } from './mobileConnections'; import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan'; import { mobileConnectionInputClass, mobileInputKeyboardProps } from './mobileConnectionUi'; import { MobileQrConnectionLoading, MobileQrScannerOverlay } from './MobileQrScannerOverlay'; export type MobileConnectionNotice = { kind: 'unreachable' | 'auth-expired'; label: string; }; export const MobileConnectionWelcome: React.FC<{ onConnected: () => void; /** Why the user landed here (failed cold-launch auto-connect) — shown as a banner. */ notice?: MobileConnectionNotice | null; }> = ({ onConnected, notice = null }) => { const { t } = useI18n(); const conn = useMobileConnection(onConnected); const { connections, isBusy, isPasswordBusy, error, pendingConnection } = conn; const [serverUrl, setServerUrl] = React.useState(''); const [connectionName, setConnectionName] = React.useState(''); const [clientToken, setClientToken] = React.useState(''); const [isScanning, setIsScanning] = React.useState(false); const [isCompletingScan, setIsCompletingScan] = React.useState(false); const scanAbortRef = React.useRef(null); const qrScanSupported = React.useMemo(() => isQrScanSupported(), []); // QR pairing is the primary flow; the manual URL form stays collapsed unless // scanning is unavailable (web build) or the user asks for it. const [manualOpen, setManualOpen] = React.useState(() => !isQrScanSupported()); // Which saved connection is being connected to, for the per-row spinner. const [connectingId, setConnectingId] = React.useState(null); const [password, setPassword] = React.useState(''); const handleSubmit = React.useCallback((event: React.FormEvent) => { event.preventDefault(); void conn.connect({ url: serverUrl, clientToken, label: connectionName }); }, [clientToken, conn, connectionName, serverUrl]); // Accept a pasted pairing link (openchamber://connect?...) in the URL field and // split it back into the server URL + token. const handleUrlChange = React.useCallback((value: string) => { if (/^openchamber:\/\//i.test(value.trim())) { const payload = parseConnectionPayload(value); if (payload) { if ('pairing' in payload) { void conn.redeemPairingConnection(payload.pairing); return; } setServerUrl(payload.url); if (payload.label) setConnectionName(payload.label); if (payload.clientToken) setClientToken(payload.clientToken); return; } } setServerUrl(value); }, [conn]); const handleScanQr = React.useCallback(async () => { if (scanAbortRef.current || isBusy) return; conn.setError(null); setIsScanning(true); const controller = new AbortController(); scanAbortRef.current = controller; try { const result = await scanConnectionQr({ signal: controller.signal }); if (scanAbortRef.current === controller) { scanAbortRef.current = null; setIsScanning(false); } switch (result.status) { case 'ok': setIsCompletingScan(true); setServerUrl(result.url); if (result.label) setConnectionName(result.label); if (result.clientToken) setClientToken(result.clientToken); await conn.connect({ url: result.url, clientToken: result.clientToken, label: result.label }); break; case 'pairing': setIsCompletingScan(true); await conn.redeemPairingConnection(result.pairing); break; case 'permission-denied': conn.setError(t('mobile.connect.scan.permissionDenied')); break; case 'invalid': conn.setError(t('mobile.connect.scan.invalid')); break; case 'unsupported': conn.setError(t('mobile.connect.scan.unsupported')); break; case 'failed': conn.setError(t('mobile.connect.scan.failed')); break; case 'cancelled': default: break; } } finally { setIsCompletingScan(false); if (scanAbortRef.current === controller) { scanAbortRef.current = null; setIsScanning(false); } } }, [conn, isBusy, t]); React.useEffect(() => () => scanAbortRef.current?.abort(), []); const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => { event.preventDefault(); void conn.submitPassword(password); }, [conn, password]); const cancelPassword = React.useCallback(() => { setPassword(''); conn.cancelPassword(); }, [conn]); return ( <> {isScanning ? scanAbortRef.current?.abort()} /> : null} {isCompletingScan ? : null}

{t('mobile.connect.welcome.title')}

{notice ? (

{notice.kind === 'auth-expired' ? t('mobile.connect.notice.authExpired', { label: notice.label }) : t('mobile.connect.notice.unreachable', { label: notice.label })}

) : null} {pendingConnection ? (

{pendingConnection.label}

{pendingConnection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(pendingConnection) : t('mobile.connect.relay.badge')}

setPassword(event.target.value)} placeholder={t('mobile.connect.password.placeholder')} aria-label={t('mobile.connect.password.label')} type="password" autoFocus className={mobileConnectionInputClass} /> {error ?

{error}

: null}
) : (
{/* Primary path: scan the pairing QR from "Add a device" on the server. */} {qrScanSupported ? (

{t('mobile.connect.welcome.scanHint')}

) : null} {error && !manualOpen ?

{error}

: null} {connections.length > 0 ? (

{t('mobile.connect.saved.title')}

{connections.map((connection) => { const isConnectingRow = connectingId === connection.id; return ( ); })}
) : null} {/* Manual URL entry, collapsed by default — most people pair by QR. */}
{qrScanSupported ? ( ) : null}
handleUrlChange(event.target.value)} placeholder={t('mobile.connect.url.placeholder')} aria-label={t('mobile.connect.url.label')} type="url" inputMode="url" autoCapitalize="none" tabIndex={manualOpen ? undefined : -1} className={cn(mobileConnectionInputClass, 'text-center')} /> setConnectionName(event.target.value)} placeholder={t('mobile.instances.label.placeholder')} aria-label={t('mobile.instances.label.label')} autoComplete="off" autoCapitalize="words" autoCorrect="off" spellCheck={false} tabIndex={manualOpen ? undefined : -1} className={cn(mobileConnectionInputClass, 'text-center')} /> setClientToken(event.target.value)} placeholder={t('mobile.connect.token.placeholder')} aria-label={t('mobile.connect.token.label')} tabIndex={manualOpen ? undefined : -1} autoCapitalize="none" className={cn(mobileConnectionInputClass, 'text-center')} />

{t('mobile.connect.token.hint')}

{error ?

{error}

: null}
)}
); };