Files
dchain/client-app/hooks/useGlobalInbox.ts
vsecoder ce11a13874 feat: desktop messaging + pairing + cross-client master-pub attribution (v2.2.0-alpha5)
Two coordinated changes:

1. Desktop client gets a functional Messages section and working pairing
flow, putting it at feature parity with mobile for the v2.2.0 line.

2. Server + both clients teach each other to use the sender's master
Ed25519 (not just their X25519) to address conversations, so a peer
writing from a different linked device still rolls into the same chat.
This is the "new API logic" the desktop scaffold was waiting on.

Server (node/api_relay.go, cmd/node/main.go):
  * /relay/inbox items now carry `sender_ed25519_pub` alongside the
    per-device `sender_pub`. Empty string for pre-v2.2.0 senders.
  * WS `inbox` push summary also includes `sender_ed25519_pub`, so the
    client can skip the refetch when the envelope plainly isn't for
    the chat they're watching.
  * Both existing tests pass.

Mobile client:
  * lib/types.ts Envelope grew `sender_ed25519_pub`; fetchInbox normalises
    it (default '') for older nodes.
  * hooks/useGlobalInbox matches contacts by (master Ed25519 OR legacy
    X25519) so an incoming message from a peer's desktop reuses the
    existing chat instead of creating a duplicate placeholder.
  * hooks/useMessages now takes an optional `contactMasterEd25519` and
    exposes a matchesChat() predicate; WS inbox handler uses it too to
    avoid spurious refetches.
  * chats/[id].tsx passes `contact.address` (master) along with x25519.

Desktop client — all new:
  * src/lib/crypto.ts — tweetnacl hex/base64 helpers, generateKeyFile,
    encryptMessage/decryptMessage, signBase64, shortAddr. Same signatures
    as the mobile lib; uses Chromium's window.crypto, no expo-crypto dep.
  * src/lib/tx.ts — buildTransferTx / buildLinkDeviceTx / buildUnlinkDeviceTx
    + submitTx + humanizeTxError, canonical-bytes identical to mobile.
  * src/lib/relay.ts — fetchInbox, sendEnvelope, resolveRecipientKeys
    (multi-device fan-out with legacy identity.x25519 fallback).
  * src/lib/store.ts — zustand state gets messages{}, unread{},
    activeChat.
  * src/lib/storage.ts — per-chat cache via localStorage (500-msg cap).
  * src/hooks/useInboxPoll — 4s polling loop, addresses conversations
    by master Ed25519, bumps unread unless chat is active.
  * src/sections/messages/* — ChatList (sorted tiles, unread badges),
    Conversation (auto-scroll messages + composer + fan-out send,
    Enter-to-send / Shift+Enter for newline), EmptyConversation.
  * src/auth/Pair.tsx — 6-digit code + device key screen, polls inbox
    for a handshake envelope, assembles the KeyFile on arrival.
  * Welcome.tsx: Pair button now actually routes to <Pair>; imports
    generateKeyFile from lib/crypto (was inlined).

docs/ROADMAP.md delta: alpha5 row flipped to done inline. Alpha6
(feed + wallet) and rc1 (contacts + devices UI + profile) still
pending.
2026-04-22 17:43:18 +03:00

128 lines
5.1 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.

/**
* useGlobalInbox — app-wide inbox listener.
*
* Подписан на WS-топик `inbox:<my_x25519>` при любом экране внутри
* (app)-группы. Когда приходит push с envelope, мы:
* 1. Декриптуем — если это наш контакт, добавляем в store.
* 2. Инкрементим unreadByContact[address].
* 3. Показываем local notification (от кого + счётчик).
*
* НЕ дублирует chat-detail'овский `useMessages` — тот делает initial
* HTTP-pull при открытии чата и слушает тот же топик (двойная подписка
* с фильтром по sender_pub). Оба держат в store консистентное состояние
* через `appendMessage` (который идемпотентен по id).
*
* Фильтрация "app backgrounded" не нужна: Expo notifications'handler
* показывает banner и в foreground, но при активном чате с этим
* контактом нотификация dismiss'ится автоматически через
* clearContactNotifications (вызывается при mount'е chats/[id]).
*/
import { useEffect, useRef } from 'react';
import { AppState } from 'react-native';
import { usePathname } from 'expo-router';
import { useStore } from '@/lib/store';
import { getWSClient } from '@/lib/ws';
import { decryptMessage } from '@/lib/crypto';
import { tryParsePostRef } from '@/lib/forwardPost';
import { fetchInbox } from '@/lib/api';
import { appendMessage } from '@/lib/storage';
import { randomId } from '@/lib/utils';
import { notifyIncoming } from './useNotifications';
export function useGlobalInbox() {
const keyFile = useStore(s => s.keyFile);
const contacts = useStore(s => s.contacts);
const appendMsg = useStore(s => s.appendMessage);
const incrementUnread = useStore(s => s.incrementUnread);
const pathname = usePathname();
const contactsRef = useRef(contacts);
const pathnameRef = useRef(pathname);
useEffect(() => { contactsRef.current = contacts; }, [contacts]);
useEffect(() => { pathnameRef.current = pathname; }, [pathname]);
useEffect(() => {
if (!keyFile?.x25519_pub) return;
const ws = getWSClient();
const handleEnvelopePull = async () => {
try {
const envelopes = await fetchInbox(keyFile.x25519_pub);
for (const env of envelopes) {
// Attribution (v2.2.0+): prefer the envelope's master Ed25519
// so messages from any of the sender's linked devices roll
// into a single chat. Fall back to legacy X25519-based lookup
// for pre-v2.2.0 senders that left the field empty.
const c = contactsRef.current.find(x =>
(env.sender_ed25519_pub && x.address === env.sender_ed25519_pub) ||
x.x25519Pub === env.sender_pub,
);
if (!c) continue;
let text = '';
try {
text = decryptMessage(
env.ciphertext,
env.nonce,
env.sender_pub,
keyFile.x25519_priv,
) ?? '';
} catch {
continue;
}
if (!text) continue;
// Стабильный id от сервера (sha256(nonce||ct)[:16]); fallback
// на nonce-префикс если вдруг env.id пустой.
const msgId = env.id || `${env.sender_pub}_${env.nonce.slice(0, 16)}`;
const postRef = tryParsePostRef(text);
const msg = {
id: msgId,
from: env.sender_pub,
text: postRef ? '' : text,
timestamp: env.timestamp,
mine: false,
...(postRef && {
postRef: {
postID: postRef.post_id,
author: postRef.author,
excerpt: postRef.excerpt,
hasImage: postRef.has_image,
},
}),
};
appendMsg(c.address, msg);
await appendMessage(c.address, msg);
// Если пользователь прямо сейчас в этом чате — unread не инкрементим,
// notification не показываем.
const inThisChat =
pathnameRef.current === `/chats/${c.address}` ||
pathnameRef.current.startsWith(`/chats/${c.address}/`);
if (inThisChat && AppState.currentState === 'active') continue;
incrementUnread(c.address);
const unread = useStore.getState().unreadByContact[c.address] ?? 1;
notifyIncoming({
contactAddress: c.address,
senderName: c.username ? `@${c.username}` : (c.alias ?? 'New message'),
unreadCount: unread,
});
}
} catch {
/* silent — ошибки pull'а обрабатывает useMessages */
}
};
const off = ws.subscribe('inbox:' + keyFile.x25519_pub, (frame) => {
if (frame.event !== 'inbox') return;
handleEnvelopePull();
});
return off;
}, [keyFile, appendMsg, incrementUnread]);
}