/** * Main app layout — кастомный `` + ``. * * 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 ( {!hideNav && ( )} ); }