/** * 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, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useStore } from '@/lib/store'; import { fetchDevices, buildUnlinkDeviceTx, submitTx, humanizeTxError, type DeviceInfo, } from '@/lib/api'; 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 ?? ''; 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 )} )} ); })} )} {/* Pair new device — stub for v2.2.0-alpha3 pairing flow. Disabled until the QR protocol lands; left visible so operators know where the entry point will live. */} Link new device Pairing flow — coming in v2.2.0-alpha3 ); }