/** * Devices screen — Settings → Linked devices. * * Multi-device registry (v2.2.0). Lists every X25519 device published * on-chain under this identity's master Ed25519 key. Operators can: * - see added-at timestamps * - rename this device (local alias for now; rename via LINK_DEVICE * with same pub + new name is a v2.3 polish) * - revoke a remote device via UNLINK_DEVICE (requires fee) * - pair a new device (Phase 3 — separate modal, stub for now) * * This device is NEVER listed with an Unlink button — revoking yourself * is a footgun (you'd wipe your own state on next launch). Export/import * your key first, then revoke from the new device. */ import React, { useCallback, useEffect, useState } from 'react'; import { View, Text, ScrollView, Pressable, ActivityIndicator, Alert, RefreshControl, TextInput, KeyboardAvoidingView, Platform, Modal, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useStore } from '@/lib/store'; import { fetchDevices, buildLinkDeviceTx, buildUnlinkDeviceTx, submitTx, sendEnvelope, humanizeTxError, type DeviceInfo, } from '@/lib/api'; import { encryptMessage } from '@/lib/crypto'; import { Header } from '@/components/Header'; import { IconButton } from '@/components/IconButton'; import { safeBack } from '@/lib/utils'; function shortPub(p: string, n = 8): string { if (!p) return '—'; return p.length <= n * 2 + 1 ? p : `${p.slice(0, n)}…${p.slice(-n)}`; } function formatDate(unixSec: number): string { return new Date(unixSec * 1000).toLocaleString(); } export default function DevicesScreen() { const insets = useSafeAreaInsets(); const keyFile = useStore(s => s.keyFile); const [devices, setDevices] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [unlinking, setUnlinking] = useState(null); // pub being revoked const load = useCallback(async (isRefresh = false) => { if (!keyFile) return; if (isRefresh) setRefreshing(true); else setLoading(true); try { const list = await fetchDevices(keyFile.pub_key); setDevices(list); } finally { setLoading(false); setRefreshing(false); } }, [keyFile]); useEffect(() => { load(false); }, [load]); const onUnlink = useCallback((dev: DeviceInfo) => { if (!keyFile) return; Alert.alert( 'Unlink device?', `"${dev.device_name}" will stop receiving messages sent to you. ` + `This costs a small network fee. The revoked device wipes its ` + `local state the next time it checks in.`, [ { text: 'Cancel', style: 'cancel' }, { text: 'Unlink', style: 'destructive', onPress: async () => { setUnlinking(dev.x25519_pub_key); try { const tx = buildUnlinkDeviceTx({ from: keyFile.pub_key, x25519Pub: dev.x25519_pub_key, privKey: keyFile.priv_key, }); await submitTx(tx); // Optimistic — drop from local list immediately; next load // reconciles. Chain tx takes ~1 block to commit. setDevices(prev => prev.filter(d => d.x25519_pub_key !== dev.x25519_pub_key)); } catch (e: any) { Alert.alert('Unlink failed', humanizeTxError(e)); } finally { setUnlinking(null); } }, }, ], ); }, [keyFile]); const meX25519 = keyFile?.x25519_pub ?? ''; // Pairing modal state — filled by the operator reading values off the // new device's /auth/pair screen. const [pairOpen, setPairOpen] = useState(false); const [pairCode, setPairCode] = useState(''); const [pairKey, setPairKey] = useState(''); const [pairName, setPairName] = useState(''); const [pairBusy, setPairBusy] = useState(false); const submitPair = useCallback(async () => { if (!keyFile) return; const code = pairCode.replace(/\s+/g, '').trim(); const key = pairKey.replace(/\s+/g, '').trim().toLowerCase(); if (!/^\d{6}$/.test(code)) { Alert.alert('Invalid code', 'The pairing code is 6 digits.'); return; } if (!/^[0-9a-f]{64}$/.test(key)) { Alert.alert('Invalid key', 'The device key must be 64 hex characters.'); return; } const name = pairName.trim() || 'New device'; setPairBusy(true); try { // 1. LINK_DEVICE on-chain so senders learn the new pub. const tx = buildLinkDeviceTx({ from: keyFile.pub_key, x25519Pub: key, deviceName: name, privKey: keyFile.priv_key, }); await submitTx(tx); // 2. Ship the handshake payload to the new device. Encrypted for // its x25519 pub so only it can read — master priv in plaintext // would be catastrophic, E2E is the whole point. const payload = JSON.stringify({ v: 1, type: 'pair-handshake', code, master_pub: keyFile.pub_key, master_priv: keyFile.priv_key, master_x25519_pub: keyFile.x25519_pub, }); const { nonce, ciphertext } = encryptMessage( payload, keyFile.x25519_priv, key, ); await sendEnvelope({ senderPub: keyFile.x25519_pub, recipientPub: key, senderEd25519Pub: keyFile.pub_key, nonce, ciphertext, }); // 3. Optimistic local insert so the row shows up without waiting // for the next pull/refresh round-trip. setDevices(prev => { if (prev.some(d => d.x25519_pub_key === key)) return prev; return [...prev, { x25519_pub_key: key, device_name: name, added_at: Math.floor(Date.now() / 1000), }]; }); setPairOpen(false); setPairCode(''); setPairKey(''); setPairName(''); Alert.alert( 'Pairing sent', 'The new device should finish pairing in a few seconds.', ); } catch (e: any) { Alert.alert('Pairing failed', humanizeTxError(e)); } finally { setPairBusy(false); } }, [keyFile, pairCode, pairKey, pairName]); return (
safeBack()} />} /> load(true)} tintColor="#1d9bf0" /> } > Every linked device has its own encryption key. Messages sent to you are delivered to all active devices. {loading ? ( ) : devices.length === 0 ? ( No devices registered yet This device auto-registers when the next network-fee is available. Top up your balance and pull to refresh. ) : ( {devices.map((d, i) => { const isMe = d.x25519_pub_key === meX25519; const busy = unlinking === d.x25519_pub_key; return ( {i > 0 && } {d.device_name || 'Unnamed device'} {isMe && ( THIS DEVICE )} {shortPub(d.x25519_pub_key)} Linked {formatDate(d.added_at)} {!isMe && ( onUnlink(d)} disabled={busy} style={({ pressed }) => ({ paddingHorizontal: 12, paddingVertical: 7, borderRadius: 999, borderWidth: 1, borderColor: '#3a2020', backgroundColor: pressed ? '#2a1414' : 'transparent', opacity: busy ? 0.5 : 1, })} > {busy ? ( ) : ( Unlink )} )} ); })} )} {/* Link new device — opens a modal with Code + DeviceKey inputs that the operator transcribes from the new device's /auth/pair screen. */} setPairOpen(true)} style={({ pressed }) => ({ paddingVertical: 13, paddingHorizontal: 16, borderRadius: 14, backgroundColor: pressed ? '#1a1a1a' : '#0a0a0a', borderWidth: 1, borderColor: '#1f1f1f', flexDirection: 'row', alignItems: 'center', gap: 10, })} > Link new device Enter the 6-digit code from the new device {/* ── Pair new device modal ───────────────────────────────────── */} !pairBusy && setPairOpen(false)} > Link new device !pairBusy && setPairOpen(false)} hitSlop={8} > Open the new device, tap Pair on the welcome screen, then transcribe the code + device key shown there. setPairOpen(false)} disabled={pairBusy} style={({ pressed }) => ({ paddingHorizontal: 16, paddingVertical: 10, borderRadius: 999, backgroundColor: pressed ? '#1a1a1a' : 'transparent', opacity: pairBusy ? 0.5 : 1, })} > Cancel ({ paddingHorizontal: 18, paddingVertical: 10, borderRadius: 999, backgroundColor: pressed ? '#1580c8' : '#1d9bf0', opacity: pairBusy ? 0.7 : 1, minWidth: 90, alignItems: 'center', })} > {pairBusy ? ( ) : ( Link )} ); } function PairInput({ label, value, onChangeText, placeholder, keyboardType, autoCapitalize, maxLength, monospace, }: { label: string; value: string; onChangeText: (v: string) => void; placeholder?: string; keyboardType?: 'default' | 'number-pad'; autoCapitalize?: 'none' | 'sentences'; maxLength?: number; monospace?: boolean; }) { return ( {label} ); }