Part of PR #3. Pairing flow still to come. Devices screen — app/(app)/devices.tsx: * Lists every active device from /api/devices/{self}. * "THIS DEVICE" badge on our own row, Unlink button on every other. * Unlink confirms + submits UNLINK_DEVICE tx, optimistic local removal. * Pull-to-refresh; empty state when balance is too low for auto-link. * Placeholder row for "Link new device" — wired in next commit. Settings → Devices entry row: added under a new "Devices" section. Self-wipe on revoke — lib/storage.ts + app/(app)/_layout.tsx: * New AsyncStorage marker `dchain_device_registered` tracks whether this install ever made it into the on-chain registry. * wipeAllLocalState() zeroes secure-store key + contacts + settings + chats cache + marker. Safe-idempotent. * Bootstrap effect in app layout splits three branches by (our_pub in chain's active list × marker_set): - in list → mark registered, done. - not in list + was registered → REVOKED → wipe + redirect to auth. - not in list + never registered → first boot, LINK_DEVICE. * Network errors never trigger wipe — only an explicit "pub missing from chain response" decides it. Belt-and-suspenders against a misbehaving node spuriously dropping records. Next: pairing flow so a second device (desktop, tablet, new phone) can come online, show a 6-digit code, receive master priv via a one-shot relay envelope encrypted to its fresh device X25519 pub, then self-link.
179 lines
6.3 KiB
TypeScript
179 lines
6.3 KiB
TypeScript
/**
|
||
* Main app layout — кастомный `<AnimatedSlot>` + `<NavBar>`.
|
||
*
|
||
* AnimatedSlot — обёртка над Slot'ом, анимирующая переход при смене
|
||
* pathname'а. Направление анимации вычисляется по TAB_ORDER: если
|
||
* целевой tab "справа" — слайд из правой стороны, "слева" — из левой.
|
||
*
|
||
* Intra-tab навигация (chats/index → chats/[id]) обслуживается вложенным
|
||
* Stack'ом в chats/_layout.tsx — там остаётся нативная slide-from-right
|
||
* анимация, чтобы chat detail "выезжал" поверх списка.
|
||
*
|
||
* Side-effects (balance, contacts, WS auth, dev seed) — монтируются здесь
|
||
* один раз; переходы между tab'ами их не перезапускают.
|
||
*/
|
||
import React, { useEffect } from 'react';
|
||
import { View, Platform } from 'react-native';
|
||
import { router, usePathname } from 'expo-router';
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
import { useStore } from '@/lib/store';
|
||
import { useBalance } from '@/hooks/useBalance';
|
||
import { useContacts } from '@/hooks/useContacts';
|
||
import { useWellKnownContracts } from '@/hooks/useWellKnownContracts';
|
||
import { useNotifications } from '@/hooks/useNotifications';
|
||
import { useGlobalInbox } from '@/hooks/useGlobalInbox';
|
||
import { getWSClient } from '@/lib/ws';
|
||
import { NavBar } from '@/components/NavBar';
|
||
import { AnimatedSlot } from '@/components/AnimatedSlot';
|
||
import {
|
||
saveContact,
|
||
isDeviceRegistered, markDeviceRegistered, wipeAllLocalState,
|
||
} from '@/lib/storage';
|
||
import {
|
||
fetchDevices, buildLinkDeviceTx, submitTx,
|
||
} from '@/lib/api';
|
||
|
||
export default function AppLayout() {
|
||
const keyFile = useStore(s => s.keyFile);
|
||
const requests = useStore(s => s.requests);
|
||
const insets = useSafeAreaInsets();
|
||
const pathname = usePathname();
|
||
|
||
// NavBar прячется на full-screen экранах:
|
||
// - chat detail
|
||
// - compose (new post modal)
|
||
// - feed sub-routes (post detail, hashtag search)
|
||
// - tx detail
|
||
const hideNav =
|
||
/^\/chats\/[^/]+/.test(pathname) ||
|
||
pathname === '/compose' ||
|
||
/^\/feed\/.+/.test(pathname) ||
|
||
/^\/tx\/.+/.test(pathname);
|
||
|
||
useBalance();
|
||
useContacts();
|
||
useWellKnownContracts();
|
||
useNotifications(); // permission + tap-handler
|
||
useGlobalInbox(); // global inbox listener → notifications on new peer msg
|
||
|
||
// Ensure the Saved Messages (self-chat) contact exists as soon as the user
|
||
// is signed in, so it shows up in the chat list without any prior action.
|
||
const contacts = useStore(s => s.contacts);
|
||
const upsertContact = useStore(s => s.upsertContact);
|
||
useEffect(() => {
|
||
if (!keyFile) return;
|
||
if (contacts.some(c => c.address === keyFile.pub_key)) return;
|
||
const saved = {
|
||
address: keyFile.pub_key,
|
||
x25519Pub: keyFile.x25519_pub,
|
||
alias: 'Saved Messages',
|
||
addedAt: Date.now(),
|
||
};
|
||
upsertContact(saved);
|
||
saveContact(saved).catch(() => { /* best-effort — re-added next boot anyway */ });
|
||
}, [keyFile, contacts, upsertContact]);
|
||
|
||
useEffect(() => {
|
||
const ws = getWSClient();
|
||
if (keyFile) ws.setAuthCreds({ pubKey: keyFile.pub_key, privKey: keyFile.priv_key });
|
||
else ws.setAuthCreds(null);
|
||
}, [keyFile]);
|
||
|
||
// Multi-device registry bootstrap + revoke-detection (v2.2.0).
|
||
//
|
||
// Three branches, by (chain list × local "was registered" flag):
|
||
//
|
||
// 1. Our pub is in the chain's active list →
|
||
// mark us registered locally (idempotent), done.
|
||
//
|
||
// 2. Our pub is NOT in the active list, AND we've registered before →
|
||
// another device issued UNLINK_DEVICE against us. Wipe ALL local
|
||
// state (master priv, contacts, chats, marker) and redirect to
|
||
// the auth screen. This is the security-critical path: without
|
||
// the wipe, a stolen phone after revoke would still decrypt
|
||
// historical messages.
|
||
//
|
||
// 3. Our pub is NOT in the active list, AND we've NEVER registered →
|
||
// first boot on this chain; submit LINK_DEVICE so senders can
|
||
// target us. Failures (fee, offline) are swallowed; next launch
|
||
// retries.
|
||
useEffect(() => {
|
||
if (!keyFile) return;
|
||
let cancelled = false;
|
||
(async () => {
|
||
let chainList;
|
||
try {
|
||
chainList = await fetchDevices(keyFile.pub_key);
|
||
} catch {
|
||
// Network unavailable — leave state unchanged; we'll resync on
|
||
// the next launch. Do NOT wipe on network error.
|
||
return;
|
||
}
|
||
if (cancelled) return;
|
||
|
||
const inActive = chainList.some(d => d.x25519_pub_key === keyFile.x25519_pub);
|
||
const previouslyRegistered = await isDeviceRegistered();
|
||
if (cancelled) return;
|
||
|
||
if (inActive) {
|
||
// Branch #1 — ensure the local marker is set.
|
||
if (!previouslyRegistered) await markDeviceRegistered();
|
||
return;
|
||
}
|
||
|
||
if (previouslyRegistered) {
|
||
// Branch #2 — REVOKED. Self-wipe.
|
||
await wipeAllLocalState();
|
||
useStore.getState().setKeyFile(null);
|
||
// The redirect-on-null-keyFile effect below will push the user
|
||
// back to the welcome screen automatically.
|
||
return;
|
||
}
|
||
|
||
// Branch #3 — first-boot link. Best-effort.
|
||
try {
|
||
const deviceName = Platform.select({
|
||
ios: 'iPhone',
|
||
android: 'Android phone',
|
||
default: 'Device',
|
||
}) ?? 'Device';
|
||
const tx = buildLinkDeviceTx({
|
||
from: keyFile.pub_key,
|
||
x25519Pub: keyFile.x25519_pub,
|
||
deviceName,
|
||
privKey: keyFile.priv_key,
|
||
});
|
||
await submitTx(tx);
|
||
await markDeviceRegistered();
|
||
} catch {
|
||
/* next launch retries */
|
||
}
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, [keyFile]);
|
||
|
||
useEffect(() => {
|
||
if (keyFile === null) {
|
||
const t = setTimeout(() => {
|
||
if (!useStore.getState().keyFile) router.replace('/');
|
||
}, 300);
|
||
return () => clearTimeout(t);
|
||
}
|
||
}, [keyFile]);
|
||
|
||
return (
|
||
<View style={{ flex: 1, backgroundColor: '#000000' }}>
|
||
<View style={{ flex: 1 }}>
|
||
<AnimatedSlot />
|
||
</View>
|
||
{!hideNav && (
|
||
<NavBar
|
||
bottomInset={insets.bottom}
|
||
requestCount={requests.length}
|
||
notifCount={0}
|
||
/>
|
||
)}
|
||
</View>
|
||
);
|
||
}
|