PR #2 of the multi-device roadmap — wires the messenger pipeline against the on-chain registry landed in v2.2.0-alpha1. lib/api.ts: - DeviceInfo type mirroring blockchain.DeviceInfo. - IdentityInfo.device_count (optional; populated from /api/identity). - fetchDevices(masterPub) → /api/devices/{master_pub}, returns []. Errors swallowed so a downed endpoint doesn't block messaging. - resolveRecipientKeys(masterPub) — the routing primitive. Returns devices[] if registered, else falls back to IdentityInfo.x25519_pub (pre-v2.2.0 path). Empty only when recipient has published nothing. - buildLinkDeviceTx / buildUnlinkDeviceTx — signed by master Ed25519, min-fee cost, canonical JSON payload matching the chain-side LinkDevicePayload / UnlinkDevicePayload. app/(app)/chats/[id].tsx: - sendCore now fans out: encrypts once per recipient device pub (Promise.all, any failure rejects the batch), falls back to the cached contact.x25519Pub if the registry lookup returns nothing. - Saved Messages short-circuit preserved; no devices lookup for self. app/(app)/_layout.tsx: - On every sign-in, auto-submit LINK_DEVICE for this device if its X25519 pub isn't already in the master's registry. Device name picks "iPhone" / "Android phone" / "Device" by Platform. Errors (insufficient balance / legacy chain without LINK_DEVICE support) are silent — next launch retries. Backward compatibility: senders fall back to identity.x25519_pub when the recipient has no registry entries, so pre-v2.2.0 clients still receive messages. Chain-side already gates new validation on the event types existing; old clients simply never emit LINK_DEVICE and keep working with a single X25519. Next — PR #3 (Settings → Devices screen + QR pairing flow + receive-side self-wipe on revoke detection).
141 lines
5.2 KiB
TypeScript
141 lines
5.2 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 } 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 (
|
||
<View style={{ flex: 1, backgroundColor: '#000000' }}>
|
||
<View style={{ flex: 1 }}>
|
||
<AnimatedSlot />
|
||
</View>
|
||
{!hideNav && (
|
||
<NavBar
|
||
bottomInset={insets.bottom}
|
||
requestCount={requests.length}
|
||
notifCount={0}
|
||
/>
|
||
)}
|
||
</View>
|
||
);
|
||
}
|