/** * 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 } 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 (v2.2.0). On every sign-in: // 1. Fetch our own device list from chain. // 2. If our local X25519 pub isn't in the active set, submit a // LINK_DEVICE tx for it — this makes "this device" discoverable // to senders. No-ops after the first successful submission on // a given chain + master key pair. // Failures are swallowed: insufficient balance, offline node, or a // chain that hasn't upgraded to v2.2.0 all surface the same way // (our device just isn't registered yet; next sign-in tries again). useEffect(() => { if (!keyFile) return; let cancelled = false; (async () => { try { const devs = await fetchDevices(keyFile.pub_key); if (cancelled) return; if (devs.some(d => d.x25519_pub_key === keyFile.x25519_pub)) { return; // already registered } 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); } catch { /* best-effort — 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 && ( )} ); }