Files
dchain/client-app/app/(app)/_layout.tsx
vsecoder 8940b97cc6 feat(client): Devices screen + revoke self-wipe (v2.2.0-alpha3 wip)
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.
2026-04-22 16:28:16 +03:00

179 lines
6.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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>
);
}