Files
dchain/client-app/app/(app)/_layout.tsx
vsecoder 423d307125 feat(client): multi-device fan-out + auto-link (v2.2.0-alpha2)
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).
2026-04-22 16:24:36 +03:00

141 lines
5.2 KiB
TypeScript
Raw 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 } 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>
);
}