import React from 'react'; import { Icon } from '@/components/icon/Icon'; import { Button } from '@/components/ui/button'; import { useI18n } from '@/lib/i18n'; import { isRelayModeActive } from '@/lib/relay/runtime-tunnel'; import { cn } from '@/lib/utils'; import { connectionDisplayUrl, isActiveRuntimeConnection, useMobileConnection } from './mobileConnections'; import { useDebugPanelLongPress } from './mobileConnectionDebug'; import { MobileConnectionDebugPanel } from './MobileConnectionDebugPanel'; import { isQrScanSupported, scanConnectionQr } from './mobileQrScan'; import { mobileConnectionInputClass, mobileInputKeyboardProps } from './mobileConnectionUi'; import { MobileQrConnectionLoading, MobileQrScannerOverlay } from './MobileQrScannerOverlay'; export const MobileInstancesSurface: React.FC<{ onConnect: () => void; onActiveConnectionDeleted: () => void; }> = ({ onActiveConnectionDeleted, onConnect }) => { const { t } = useI18n(); const conn = useMobileConnection(onConnect); const { connections, isBusy, isPasswordBusy, error, pendingConnection, connect, submitPassword, cancelPassword, saveConnection, removeConnection, setError, } = conn; const [editingId, setEditingId] = React.useState(null); const editingConnection = editingId ? connections.find((connection) => connection.id === editingId) ?? null : null; const [confirmingDeleteId, setConfirmingDeleteId] = React.useState(null); const [url, setUrl] = React.useState(''); const [label, setLabel] = React.useState(''); const [clientToken, setClientToken] = React.useState(''); const [password, setPassword] = React.useState(''); const [isScanning, setIsScanning] = React.useState(false); const [isCompletingScan, setIsCompletingScan] = React.useState(false); const scanAbortRef = React.useRef(null); const qrScanSupported = React.useMemo(() => isQrScanSupported(), []); // The manual add/edit form is hidden until asked for — the sheet leads with // the list of instances (with live status), not a wall of inputs. const [formOpen, setFormOpen] = React.useState(false); // Which row is being connected to, for the per-row spinner. const [connectingId, setConnectingId] = React.useState(null); // Hidden diagnostics: long-press a connection row to open the connection // event log (the long-press swallows the row's normal connect tap). const [debugOpen, setDebugOpen] = React.useState(false); const debugLongPress = useDebugPanelLongPress(React.useCallback(() => setDebugOpen(true), [])); // Populate/clear the form imperatively (on edit tap / cancel / save) rather than via // an effect keyed on the derived connection object. With an effect, any churn of the // connections list re-fires it and overwrites what the user is typing — the keyboard // "resets" mid-edit. Imperative population is immune to that. const resetForm = React.useCallback(() => { setEditingId(null); setUrl(''); setLabel(''); setClientToken(''); setError(null); setFormOpen(false); }, [setError]); const saveInstance = React.useCallback((event: React.FormEvent) => { event.preventDefault(); // The id is what makes this an EDIT: saveConnection uses it to preserve the // existing relay/https candidates (and the Keychain token they key) instead // of rebuilding the instance from the single URL field. void saveConnection({ id: editingId ?? undefined, url, label, clientToken }).then((saved) => { if (saved) resetForm(); }); }, [clientToken, editingId, label, resetForm, saveConnection, url]); // Scan a pairing QR into the add/edit form fields (does not change edit mode, so // the form-reset effect doesn't wipe the scanned values). The user reviews + saves. const handleScanInstance = React.useCallback(async () => { if (scanAbortRef.current) return; 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': // Legacy token QR: prefill the manual form for review before saving. setUrl(result.url); if (result.label) setLabel(result.label); if (result.clientToken) setClientToken(result.clientToken); setFormOpen(true); break; case 'pairing': setIsCompletingScan(true); await conn.redeemPairingConnection(result.pairing); break; case 'permission-denied': setError(t('mobile.connect.scan.permissionDenied')); break; case 'invalid': setError(t('mobile.connect.scan.invalid')); break; case 'unsupported': setError(t('mobile.connect.scan.unsupported')); break; case 'failed': setError(t('mobile.connect.scan.failed')); break; case 'cancelled': default: break; } } finally { setIsCompletingScan(false); if (scanAbortRef.current === controller) { scanAbortRef.current = null; setIsScanning(false); } } }, [conn, setError, t]); React.useEffect(() => () => scanAbortRef.current?.abort(), []); const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => { event.preventDefault(); void submitPassword(password); }, [password, submitPassword]); const cancelPasswordPrompt = React.useCallback(() => { setPassword(''); cancelPassword(); }, [cancelPassword]); // Two-step delete (mirrors the session sheet): the trash icon arms the row, a // second tap on the destructive button confirms, the X disarms. No hover relied on. const toggleConfirmDelete = React.useCallback((id: string) => { setConfirmingDeleteId((current) => (current === id ? null : id)); }, []); const confirmDelete = React.useCallback((id: string) => { setConfirmingDeleteId(null); if (editingId === id) resetForm(); // Removing the ACTIVE instance — or the LAST one — must drop the user back // to the connect screen instead of leaving them in a stale, unbacked UI. const wasLast = connections.length === 1; void removeConnection(id).then((removed) => { if (!removed) return; if (wasLast || isActiveRuntimeConnection(removed)) { onActiveConnectionDeleted(); } }); }, [connections.length, editingId, onActiveConnectionDeleted, removeConnection, resetForm]); const inputClass = mobileConnectionInputClass; if (pendingConnection) { return (

{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={inputClass} /> {error ?

{error}

: null}
); } return ( <> {isScanning ? scanAbortRef.current?.abort()} /> : null} {isCompletingScan ? : null} {debugOpen ? setDebugOpen(false)} /> : null}
{connections.length > 0 ? (
{connections.map((connection) => { const confirming = confirmingDeleteId === connection.id; const isActive = isActiveRuntimeConnection(connection); const isConnectingRow = connectingId === connection.id; // Status line: the active instance says HOW it is connected right // now (direct vs relay); others show their address. const statusText = isConnectingRow ? t('mobile.connect.connecting') : isActive ? (isRelayModeActive() ? t('mobile.instances.status.connectedRelay') : t('mobile.instances.status.connectedDirect')) : connection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(connection) : t('mobile.connect.relay.badge'); return (
{confirming ? ( ) : !connection.candidates.some((c) => c.kind === 'direct') ? null : ( )}
); })}
) : (

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

)} {/* Add actions: QR pairing is the primary path; the manual form stays hidden until asked for (or until a row's edit button opens it). */} {!formOpen && !editingConnection ? (
{qrScanSupported ? ( ) : null} {error ?

{error}

: null}
) : (

{editingConnection ? t('mobile.instances.editTitle') : t('mobile.instances.addTitle')}

{error ?

{error}

: null}
)}
); };