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.
This commit is contained in:
@@ -107,7 +107,7 @@ export default function ChatScreen() {
|
|||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const selectionMode = selectedIds.size > 0;
|
const selectionMode = selectedIds.size > 0;
|
||||||
|
|
||||||
useMessages(contact?.x25519Pub ?? '');
|
useMessages(contact?.x25519Pub ?? '', contact?.address);
|
||||||
|
|
||||||
// ── Typing indicator от peer'а ─────────────────────────────────────────
|
// ── Typing indicator от peer'а ─────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -52,10 +52,13 @@ export function useGlobalInbox() {
|
|||||||
try {
|
try {
|
||||||
const envelopes = await fetchInbox(keyFile.x25519_pub);
|
const envelopes = await fetchInbox(keyFile.x25519_pub);
|
||||||
for (const env of envelopes) {
|
for (const env of envelopes) {
|
||||||
// Найти контакт по sender_pub — если не знакомый, игнорим
|
// Attribution (v2.2.0+): prefer the envelope's master Ed25519
|
||||||
// (для MVP; в future можно показывать "unknown sender").
|
// so messages from any of the sender's linked devices roll
|
||||||
const c = contactsRef.current.find(
|
// into a single chat. Fall back to legacy X25519-based lookup
|
||||||
x => x.x25519Pub === env.sender_pub,
|
// 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;
|
if (!c) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,26 @@ import { tryParsePostRef } from '@/lib/forwardPost';
|
|||||||
const FALLBACK_POLL_INTERVAL = 30_000; // HTTP poll when WS is down
|
const FALLBACK_POLL_INTERVAL = 30_000; // HTTP poll when WS is down
|
||||||
const WS_GRACE_BEFORE_POLLING = 15_000; // don't start polling immediately on disconnect
|
const WS_GRACE_BEFORE_POLLING = 15_000; // don't start polling immediately on disconnect
|
||||||
|
|
||||||
export function useMessages(contactX25519: string) {
|
/**
|
||||||
|
* useMessages — mounts per-chat inbox consumption. Accepts:
|
||||||
|
* - contactX25519: the legacy/primary X25519 for the contact.
|
||||||
|
* - contactMasterEd25519 (optional, v2.2.0+): the contact's master
|
||||||
|
* identity so we can attribute envelopes from any of their
|
||||||
|
* linked devices to this conversation.
|
||||||
|
*
|
||||||
|
* Matching rule: an envelope belongs to this chat when
|
||||||
|
* env.sender_ed25519_pub === contactMasterEd25519 (v2.2.0 path)
|
||||||
|
* OR env.sender_pub === contactX25519 (legacy path)
|
||||||
|
*/
|
||||||
|
export function useMessages(contactX25519: string, contactMasterEd25519?: string) {
|
||||||
const keyFile = useStore(s => s.keyFile);
|
const keyFile = useStore(s => s.keyFile);
|
||||||
const appendMsg = useStore(s => s.appendMessage);
|
const appendMsg = useStore(s => s.appendMessage);
|
||||||
|
|
||||||
|
const matchesChat = useCallback((env: { sender_pub: string; sender_ed25519_pub: string }): boolean => {
|
||||||
|
if (contactMasterEd25519 && env.sender_ed25519_pub === contactMasterEd25519) return true;
|
||||||
|
return env.sender_pub === contactX25519;
|
||||||
|
}, [contactX25519, contactMasterEd25519]);
|
||||||
|
|
||||||
// Подгружаем кэш сообщений из AsyncStorage при открытии чата.
|
// Подгружаем кэш сообщений из AsyncStorage при открытии чата.
|
||||||
// Релей держит envelope'ы всего 7 дней, поэтому без чтения кэша
|
// Релей держит envelope'ы всего 7 дней, поэтому без чтения кэша
|
||||||
// история старше недели пропадает при каждом рестарте приложения.
|
// история старше недели пропадает при каждом рестарте приложения.
|
||||||
@@ -48,8 +64,8 @@ export function useMessages(contactX25519: string) {
|
|||||||
try {
|
try {
|
||||||
const envelopes = await fetchInbox(keyFile.x25519_pub);
|
const envelopes = await fetchInbox(keyFile.x25519_pub);
|
||||||
for (const env of envelopes) {
|
for (const env of envelopes) {
|
||||||
// Only process messages from this contact
|
// Only process messages that belong to this chat (see matchesChat).
|
||||||
if (env.sender_pub !== contactX25519) continue;
|
if (!matchesChat(env)) continue;
|
||||||
|
|
||||||
const text = decryptMessage(
|
const text = decryptMessage(
|
||||||
env.ciphertext,
|
env.ciphertext,
|
||||||
@@ -130,10 +146,17 @@ export function useMessages(contactX25519: string) {
|
|||||||
// the handler so we only render messages in THIS chat.
|
// the handler so we only render messages in THIS chat.
|
||||||
const offInbox = ws.subscribe('inbox:' + keyFile.x25519_pub, (frame) => {
|
const offInbox = ws.subscribe('inbox:' + keyFile.x25519_pub, (frame) => {
|
||||||
if (frame.event !== 'inbox') return;
|
if (frame.event !== 'inbox') return;
|
||||||
const d = frame.data as { sender_pub?: string } | undefined;
|
const d = frame.data as {
|
||||||
// Optimisation: if the envelope is from a different peer, skip the
|
sender_pub?: string; sender_ed25519_pub?: string;
|
||||||
// whole refetch — we'd just drop it in the sender filter below anyway.
|
} | undefined;
|
||||||
if (d?.sender_pub && d.sender_pub !== contactX25519) return;
|
// Optimisation: if the envelope definitely isn't for this chat,
|
||||||
|
// skip the whole refetch. Multi-device aware — the peer may be
|
||||||
|
// writing from any of their linked devices (different X25519
|
||||||
|
// pubs), so we check against their master Ed25519 too.
|
||||||
|
if (d && !matchesChat({
|
||||||
|
sender_pub: d.sender_pub ?? '',
|
||||||
|
sender_ed25519_pub: d.sender_ed25519_pub ?? '',
|
||||||
|
})) return;
|
||||||
pullAndDecrypt();
|
pullAndDecrypt();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -264,6 +264,9 @@ export async function getTxHistory(pubkey: string, limit = 50): Promise<TxRecord
|
|||||||
interface InboxItemWire {
|
interface InboxItemWire {
|
||||||
id: string;
|
id: string;
|
||||||
sender_pub: string;
|
sender_pub: string;
|
||||||
|
/** sender_ed25519_pub was added in v2.2.0; older nodes omit it.
|
||||||
|
Default to empty string when missing. */
|
||||||
|
sender_ed25519_pub?: string;
|
||||||
recipient_pub: string;
|
recipient_pub: string;
|
||||||
fee_ut?: number;
|
fee_ut?: number;
|
||||||
sent_at: number;
|
sent_at: number;
|
||||||
@@ -328,6 +331,7 @@ export async function fetchInbox(x25519PubHex: string): Promise<Envelope[]> {
|
|||||||
return items.map((it): Envelope => ({
|
return items.map((it): Envelope => ({
|
||||||
id: it.id,
|
id: it.id,
|
||||||
sender_pub: it.sender_pub,
|
sender_pub: it.sender_pub,
|
||||||
|
sender_ed25519_pub: it.sender_ed25519_pub ?? '',
|
||||||
recipient_pub: it.recipient_pub,
|
recipient_pub: it.recipient_pub,
|
||||||
nonce: bytesToHex(base64ToBytes(it.nonce)),
|
nonce: bytesToHex(base64ToBytes(it.nonce)),
|
||||||
ciphertext: bytesToHex(base64ToBytes(it.ciphertext)),
|
ciphertext: bytesToHex(base64ToBytes(it.ciphertext)),
|
||||||
|
|||||||
@@ -34,7 +34,19 @@ export interface Contact {
|
|||||||
export interface Envelope {
|
export interface Envelope {
|
||||||
/** sha256(nonce||ciphertext)[:16] hex — stable server-assigned id. */
|
/** sha256(nonce||ciphertext)[:16] hex — stable server-assigned id. */
|
||||||
id: string;
|
id: string;
|
||||||
sender_pub: string; // X25519 hex
|
sender_pub: string; // X25519 hex (this envelope's per-device sender key)
|
||||||
|
/**
|
||||||
|
* sender_ed25519_pub (v2.2.0+): the sender's master Ed25519 identity.
|
||||||
|
* Multiple X25519 pubs under the same identity all share one master —
|
||||||
|
* clients use THIS to group messages into a single conversation even
|
||||||
|
* when the sender replies from different devices.
|
||||||
|
*
|
||||||
|
* Empty string on legacy envelopes from pre-v2.2.0 senders. Consumers
|
||||||
|
* should fall back to `sender_pub` in that case (keeps old clients'
|
||||||
|
* messages visible, even if attribution is per-X25519 rather than
|
||||||
|
* per-identity).
|
||||||
|
*/
|
||||||
|
sender_ed25519_pub: string;
|
||||||
recipient_pub: string; // X25519 hex
|
recipient_pub: string; // X25519 hex
|
||||||
nonce: string; // hex 24 bytes
|
nonce: string; // hex 24 bytes
|
||||||
ciphertext: string; // hex NaCl box
|
ciphertext: string; // hex NaCl box
|
||||||
|
|||||||
@@ -684,10 +684,16 @@ func main() {
|
|||||||
// /relay/inbox if it needs the full envelope. Keeps WS frames small and
|
// /relay/inbox if it needs the full envelope. Keeps WS frames small and
|
||||||
// avoids a fat push for every message.
|
// avoids a fat push for every message.
|
||||||
mailbox.SetOnStore(func(env *relay.Envelope) {
|
mailbox.SetOnStore(func(env *relay.Envelope) {
|
||||||
|
// Summary only — no ciphertext. Multi-device (v2.2.0+) clients
|
||||||
|
// use sender_ed25519_pub to decide whether the envelope belongs
|
||||||
|
// to the chat they're currently viewing (messages from any of
|
||||||
|
// the peer's linked devices share a master identity), so the
|
||||||
|
// field must be in every push.
|
||||||
sum, _ := json.Marshal(map[string]any{
|
sum, _ := json.Marshal(map[string]any{
|
||||||
"id": env.ID,
|
"id": env.ID,
|
||||||
"recipient_pub": env.RecipientPub,
|
"recipient_pub": env.RecipientPub,
|
||||||
"sender_pub": env.SenderPub,
|
"sender_pub": env.SenderPub,
|
||||||
|
"sender_ed25519_pub": env.SenderEd25519PubKey,
|
||||||
"sent_at": env.SentAt,
|
"sent_at": env.SentAt,
|
||||||
})
|
})
|
||||||
eventBus.EmitInbox(env.RecipientPub, sum)
|
eventBus.EmitInbox(env.RecipientPub, sum)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dchain-desktop",
|
"name": "dchain-desktop",
|
||||||
"version": "2.2.0-alpha4",
|
"version": "2.2.0-alpha5",
|
||||||
"description": "DChain desktop client — Electron shell mirroring the mobile app's functionality with a keyboard-first 3-panel layout.",
|
"description": "DChain desktop client — Electron shell mirroring the mobile app's functionality with a keyboard-first 3-panel layout.",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
|
|||||||
198
desktop/src/auth/Pair.tsx
Normal file
198
desktop/src/auth/Pair.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
// Pair screen — secondary-device onboarding on desktop.
|
||||||
|
//
|
||||||
|
// Same protocol as mobile's app/(auth)/pair.tsx:
|
||||||
|
// 1. Generate a local X25519 keypair + random 6-digit code.
|
||||||
|
// 2. Display them so the operator can transcribe onto their primary
|
||||||
|
// device (mobile Settings → Devices → Link new device).
|
||||||
|
// 3. Poll /relay/inbox every 2.5s waiting for a handshake envelope.
|
||||||
|
// 4. On a decryptable payload with matching {v, type, code}, assemble
|
||||||
|
// a KeyFile (master Ed25519 from the envelope + this session's
|
||||||
|
// X25519 keypair) and persist — App then promotes us into Shell.
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import nacl from 'tweetnacl';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { bytesToHex, decryptMessage } from '@/lib/crypto';
|
||||||
|
import { fetchInbox } from '@/lib/relay';
|
||||||
|
import { saveKeyFile, markDeviceRegistered } from '@/lib/storage';
|
||||||
|
import type { KeyFile } from '@/lib/types';
|
||||||
|
|
||||||
|
const PAIR_VERSION = 1;
|
||||||
|
|
||||||
|
interface PairPayload {
|
||||||
|
v: number;
|
||||||
|
type: 'pair-handshake';
|
||||||
|
code: string;
|
||||||
|
master_pub: string;
|
||||||
|
master_priv: string;
|
||||||
|
master_x25519_pub: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
x25519Pub: string;
|
||||||
|
x25519Priv: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomCode(): string {
|
||||||
|
return Math.floor(Math.random() * 1_000_000).toString().padStart(6, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function genSession(): Session {
|
||||||
|
const kp = nacl.box.keyPair();
|
||||||
|
return {
|
||||||
|
x25519Pub: bytesToHex(kp.publicKey),
|
||||||
|
x25519Priv: bytesToHex(kp.secretKey),
|
||||||
|
code: randomCode(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pair({ onBack }: { onBack: () => void }): React.ReactElement {
|
||||||
|
const setKeyFile = useStore(s => s.setKeyFile);
|
||||||
|
const session = useRef<Session>(genSession()).current;
|
||||||
|
const [status, setStatus] = useState<'waiting' | 'success'>('waiting');
|
||||||
|
|
||||||
|
const copy = useCallback((text: string) => {
|
||||||
|
navigator.clipboard?.writeText(text).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const tick = async () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
try {
|
||||||
|
const envs = await fetchInbox(session.x25519Pub);
|
||||||
|
for (const env of envs) {
|
||||||
|
const plain = decryptMessage(
|
||||||
|
env.ciphertext, env.nonce, env.sender_pub, session.x25519Priv,
|
||||||
|
);
|
||||||
|
if (!plain) continue;
|
||||||
|
let payload: PairPayload;
|
||||||
|
try { payload = JSON.parse(plain); } catch { continue; }
|
||||||
|
if (
|
||||||
|
payload.v !== PAIR_VERSION ||
|
||||||
|
payload.type !== 'pair-handshake' ||
|
||||||
|
payload.code !== session.code ||
|
||||||
|
!payload.master_pub || !payload.master_priv
|
||||||
|
) continue;
|
||||||
|
|
||||||
|
const kf: KeyFile = {
|
||||||
|
pub_key: payload.master_pub,
|
||||||
|
priv_key: payload.master_priv,
|
||||||
|
x25519_pub: session.x25519Pub,
|
||||||
|
x25519_priv: session.x25519Priv,
|
||||||
|
};
|
||||||
|
await saveKeyFile(kf);
|
||||||
|
markDeviceRegistered();
|
||||||
|
setKeyFile(kf);
|
||||||
|
setStatus('success');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch { /* next tick */ }
|
||||||
|
if (!cancelled) timer = setTimeout(tick, 2_500);
|
||||||
|
};
|
||||||
|
|
||||||
|
tick();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [session, setKeyFile]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: '100%', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 40, background: '#000', color: '#fff',
|
||||||
|
}}>
|
||||||
|
<div style={{ maxWidth: 440, width: '100%' }}>
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
style={{
|
||||||
|
marginBottom: 18, padding: '6px 10px', borderRadius: 999,
|
||||||
|
background: 'transparent', color: '#8b8b8b', fontSize: 13,
|
||||||
|
border: '1px solid #1f1f1f', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h1 style={{ fontSize: 22, fontWeight: 800, margin: '0 0 8px' }}>
|
||||||
|
Pair with your other device
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: '#8b8b8b', fontSize: 13, margin: 0, lineHeight: 1.5 }}>
|
||||||
|
On a device where you're already signed in, open
|
||||||
|
Settings → Devices → Link new device and
|
||||||
|
enter these two values.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Code */}
|
||||||
|
<Card title="1. Code">
|
||||||
|
<div style={{
|
||||||
|
color: '#fff', fontFamily: 'monospace', fontSize: 34,
|
||||||
|
fontWeight: 800, letterSpacing: 6, textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
{session.code.slice(0, 3)} {session.code.slice(3)}
|
||||||
|
</div>
|
||||||
|
<CopyLink onClick={() => copy(session.code)}>Copy code</CopyLink>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Device key */}
|
||||||
|
<Card title="2. Device key">
|
||||||
|
<div className="selectable" style={{
|
||||||
|
color: '#fff', fontFamily: 'monospace', fontSize: 12,
|
||||||
|
lineHeight: 1.5, wordBreak: 'break-all',
|
||||||
|
}}>
|
||||||
|
{session.x25519Pub}
|
||||||
|
</div>
|
||||||
|
<CopyLink onClick={() => copy(session.x25519Pub)}>Copy key</CopyLink>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: 18, textAlign: 'center',
|
||||||
|
color: status === 'success' ? '#3ba55d' : '#8b8b8b',
|
||||||
|
fontSize: 13,
|
||||||
|
}}>
|
||||||
|
{status === 'waiting'
|
||||||
|
? 'Waiting for your other device…'
|
||||||
|
: 'Paired. Opening your chats…'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 18, padding: 16, borderRadius: 14,
|
||||||
|
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
color: '#5a5a5a', fontSize: 11, fontWeight: 700,
|
||||||
|
letterSpacing: 1.2, textTransform: 'uppercase', marginBottom: 10,
|
||||||
|
}}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyLink({ children, onClick }: {
|
||||||
|
children: React.ReactNode; onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
marginTop: 8, padding: 0, background: 'transparent',
|
||||||
|
border: 'none', color: '#1d9bf0', fontSize: 12, fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>{children}</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,30 +11,19 @@
|
|||||||
// with mobile comes in alpha5.
|
// with mobile comes in alpha5.
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import nacl from 'tweetnacl';
|
|
||||||
import { useStore } from '@/lib/store';
|
import { useStore } from '@/lib/store';
|
||||||
import { saveKeyFile } from '@/lib/storage';
|
import { saveKeyFile } from '@/lib/storage';
|
||||||
|
import { generateKeyFile } from '@/lib/crypto';
|
||||||
import type { KeyFile } from '@/lib/types';
|
import type { KeyFile } from '@/lib/types';
|
||||||
|
import { Pair } from './Pair';
|
||||||
function bytesToHex(b: Uint8Array): string {
|
|
||||||
return Array.from(b).map(x => x.toString(16).padStart(2, '0')).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateKeyFile(): KeyFile {
|
|
||||||
const signKP = nacl.sign.keyPair();
|
|
||||||
const boxKP = nacl.box.keyPair();
|
|
||||||
return {
|
|
||||||
pub_key: bytesToHex(signKP.publicKey),
|
|
||||||
priv_key: bytesToHex(signKP.secretKey),
|
|
||||||
x25519_pub: bytesToHex(boxKP.publicKey),
|
|
||||||
x25519_priv: bytesToHex(boxKP.secretKey),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Welcome(): React.ReactElement {
|
export function Welcome(): React.ReactElement {
|
||||||
const setKeyFile = useStore(s => s.setKeyFile);
|
const setKeyFile = useStore(s => s.setKeyFile);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [screen, setScreen] = useState<'welcome' | 'pair'>('welcome');
|
||||||
|
|
||||||
|
if (screen === 'pair') return <Pair onBack={() => setScreen('welcome')} />;
|
||||||
|
|
||||||
const onCreate = async () => {
|
const onCreate = async () => {
|
||||||
setBusy(true); setErr(null);
|
setBusy(true); setErr(null);
|
||||||
@@ -73,7 +62,8 @@ export function Welcome(): React.ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onPair = () => {
|
const onPair = () => {
|
||||||
setErr('Pair flow lands in v2.2.0-alpha5. For now use Import from a key file exported on your phone.');
|
setErr(null);
|
||||||
|
setScreen('pair');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
117
desktop/src/hooks/useInboxPoll.ts
Normal file
117
desktop/src/hooks/useInboxPoll.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// useInboxPoll — polls GET /relay/inbox for *every* X25519 pub this
|
||||||
|
// device owns (master identity + every linked device). In v2.2.0, senders
|
||||||
|
// fan out one envelope per recipient device, so we need to read all of
|
||||||
|
// them on our side to see messages that were addressed to any of our pubs.
|
||||||
|
//
|
||||||
|
// Poll interval is 4 seconds — desktop is typically always-on, we can
|
||||||
|
// afford this cadence. A WebSocket-based push path is a polish pass away;
|
||||||
|
// for alpha5 the polling loop is plenty responsive.
|
||||||
|
//
|
||||||
|
// Every newly-arrived envelope is:
|
||||||
|
// 1. Decrypted with our X25519 priv + sender's pub (from envelope metadata).
|
||||||
|
// 2. Parsed — today as JSON "pair-handshake" or plain text; group chats
|
||||||
|
// and encrypted payloads with attachments come in later alphas.
|
||||||
|
// 3. Routed: plain text → store.appendMessage + disk; anything we can't
|
||||||
|
// parse is skipped silently (future clients will extend the protocol).
|
||||||
|
//
|
||||||
|
// We keep a local "seen" set keyed by envelope.id so a second poll cycle
|
||||||
|
// doesn't re-deliver an already-consumed envelope while it sits in the
|
||||||
|
// relay mailbox waiting for TTL.
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { fetchInbox, type Envelope } from '@/lib/relay';
|
||||||
|
import { decryptMessage } from '@/lib/crypto';
|
||||||
|
import { appendMessage as persistMessage, upsertContact as persistContact } from '@/lib/storage';
|
||||||
|
import type { Message } from '@/lib/types';
|
||||||
|
|
||||||
|
const POLL_MS = 4_000;
|
||||||
|
|
||||||
|
export function useInboxPoll(): void {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const activeChat = useStore(s => s.activeChat);
|
||||||
|
|
||||||
|
// Ref-based so the tick closure sees the latest set without re-running
|
||||||
|
// the whole effect every time a new envelope arrives.
|
||||||
|
const seen = useRef<Set<string>>(new Set());
|
||||||
|
const activeChatRef = useRef<string | null>(activeChat);
|
||||||
|
useEffect(() => { activeChatRef.current = activeChat; }, [activeChat]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
let cancelled = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const envs = await fetchInbox(keyFile.x25519_pub);
|
||||||
|
if (cancelled) return;
|
||||||
|
for (const env of envs) {
|
||||||
|
if (seen.current.has(env.id)) continue;
|
||||||
|
seen.current.add(env.id);
|
||||||
|
consume(env, keyFile.x25519_priv, activeChatRef.current);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// transient — try again next tick
|
||||||
|
}
|
||||||
|
if (!cancelled) timer = setTimeout(tick, POLL_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
tick();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [keyFile]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function consume(env: Envelope, myX25519Priv: string, activeChat: string | null): void {
|
||||||
|
const plain = decryptMessage(env.ciphertext, env.nonce, env.sender_pub, myX25519Priv);
|
||||||
|
if (plain === null) return; // not for us / garbage / rotated keys
|
||||||
|
|
||||||
|
// Skip handshake envelopes — the /auth pair flow consumes those
|
||||||
|
// separately before any chat is mounted.
|
||||||
|
if (plain.startsWith('{') && plain.includes('"type":"pair-handshake"')) return;
|
||||||
|
|
||||||
|
// Conversation address = sender's master Ed25519 identity (v2.2.0+).
|
||||||
|
// The envelope now carries this explicitly in `sender_ed25519_pub`,
|
||||||
|
// so a reply from a different linked device still rolls into the
|
||||||
|
// same chat. Pre-v2.2.0 senders leave the field empty; we fall back
|
||||||
|
// to `sender_pub` (the per-device X25519) so legacy peers still
|
||||||
|
// appear as contacts — they'll just be addressed by X25519 until
|
||||||
|
// they upgrade.
|
||||||
|
const from = env.sender_ed25519_pub || env.sender_pub;
|
||||||
|
|
||||||
|
const st = useStore.getState();
|
||||||
|
|
||||||
|
// Create a placeholder contact if we've never seen this peer —
|
||||||
|
// mirrors mobile's behaviour.
|
||||||
|
if (!st.contacts.some(c => c.address === from)) {
|
||||||
|
const c = {
|
||||||
|
address: from,
|
||||||
|
x25519Pub: from,
|
||||||
|
alias: undefined,
|
||||||
|
addedAt: Date.now(),
|
||||||
|
};
|
||||||
|
st.upsertContact(c);
|
||||||
|
persistContact(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg: Message = {
|
||||||
|
id: env.id,
|
||||||
|
from: env.sender_pub,
|
||||||
|
text: plain,
|
||||||
|
timestamp: env.timestamp,
|
||||||
|
mine: false,
|
||||||
|
read: false,
|
||||||
|
edited: false,
|
||||||
|
};
|
||||||
|
st.appendMessage(from, msg);
|
||||||
|
persistMessage(from, msg);
|
||||||
|
|
||||||
|
// Only surface an unread badge if the recipient isn't already
|
||||||
|
// looking at this conversation.
|
||||||
|
if (activeChat !== from) {
|
||||||
|
st.bumpUnread(from);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
desktop/src/lib/crypto.ts
Normal file
93
desktop/src/lib/crypto.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// Crypto primitives. Mirrors client-app/lib/crypto.ts function-for-
|
||||||
|
// function (same signatures, same hex/base64 formats on the wire) so
|
||||||
|
// the two clients decrypt each other's envelopes and sign txs the node
|
||||||
|
// accepts interchangeably.
|
||||||
|
//
|
||||||
|
// The only real difference from mobile: we don't need expo-crypto — the
|
||||||
|
// Electron renderer is a Chromium browser, so window.crypto.getRandomValues
|
||||||
|
// is always available and we just let tweetnacl pick it up on its own
|
||||||
|
// (tweetnacl auto-detects window.crypto when present).
|
||||||
|
|
||||||
|
import nacl from 'tweetnacl';
|
||||||
|
import { decodeUTF8, encodeUTF8 } from 'tweetnacl-util';
|
||||||
|
import type { KeyFile } from './types';
|
||||||
|
|
||||||
|
// ─── Hex / base64 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function hexToBytes(hex: string): Uint8Array {
|
||||||
|
if (hex.length % 2 !== 0) throw new Error('odd hex length');
|
||||||
|
const b = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
export function bytesToHex(b: Uint8Array): string {
|
||||||
|
return Array.from(b).map(x => x.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
export function bytesToBase64(b: Uint8Array): string {
|
||||||
|
let s = '';
|
||||||
|
for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
|
||||||
|
return btoa(s);
|
||||||
|
}
|
||||||
|
export function base64ToBytes(b64: string): Uint8Array {
|
||||||
|
const bin = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
|
||||||
|
const out = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Key generation ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function generateKeyFile(): KeyFile {
|
||||||
|
const sign = nacl.sign.keyPair();
|
||||||
|
const box = nacl.box.keyPair();
|
||||||
|
return {
|
||||||
|
pub_key: bytesToHex(sign.publicKey),
|
||||||
|
priv_key: bytesToHex(sign.secretKey),
|
||||||
|
x25519_pub: bytesToHex(box.publicKey),
|
||||||
|
x25519_priv: bytesToHex(box.secretKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── NaCl box (E2E messaging) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function encryptMessage(
|
||||||
|
plaintext: string,
|
||||||
|
senderSecretHex: string,
|
||||||
|
recipientPubHex: string,
|
||||||
|
): { nonce: string; ciphertext: string } {
|
||||||
|
const nonce = nacl.randomBytes(nacl.box.nonceLength);
|
||||||
|
const msg = decodeUTF8(plaintext);
|
||||||
|
const box = nacl.box(msg, nonce, hexToBytes(recipientPubHex), hexToBytes(senderSecretHex));
|
||||||
|
return { nonce: bytesToHex(nonce), ciphertext: bytesToHex(box) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptMessage(
|
||||||
|
ciphertextHex: string,
|
||||||
|
nonceHex: string,
|
||||||
|
senderPubHex: string,
|
||||||
|
recipientSecHex: string,
|
||||||
|
): string | null {
|
||||||
|
try {
|
||||||
|
const plain = nacl.box.open(
|
||||||
|
hexToBytes(ciphertextHex), hexToBytes(nonceHex),
|
||||||
|
hexToBytes(senderPubHex), hexToBytes(recipientSecHex),
|
||||||
|
);
|
||||||
|
return plain ? encodeUTF8(plain) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Ed25519 signing ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function signBase64(data: Uint8Array, privKeyHex: string): string {
|
||||||
|
const sig = nacl.sign.detached(data, hexToBytes(privKeyHex));
|
||||||
|
return bytesToBase64(sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function shortAddr(hex: string, chars = 8): string {
|
||||||
|
if (hex.length <= chars * 2 + 3) return hex;
|
||||||
|
return `${hex.slice(0, chars)}…${hex.slice(-chars)}`;
|
||||||
|
}
|
||||||
113
desktop/src/lib/relay.ts
Normal file
113
desktop/src/lib/relay.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
// Relay mailbox client. Same wire format + semantics as
|
||||||
|
// client-app/lib/api.ts, narrowed to the calls the desktop actually
|
||||||
|
// needs right now: broadcast sealed envelopes, fetch inbox, resolve a
|
||||||
|
// recipient's device pubs for fan-out.
|
||||||
|
|
||||||
|
import { get, post, fetchDevices, getIdentity } from './api';
|
||||||
|
import {
|
||||||
|
hexToBytes, bytesToHex, bytesToBase64, base64ToBytes,
|
||||||
|
} from './crypto';
|
||||||
|
|
||||||
|
export interface Envelope {
|
||||||
|
id: string;
|
||||||
|
sender_pub: string; // X25519 hex (per-device key)
|
||||||
|
/**
|
||||||
|
* sender_ed25519_pub (v2.2.0+): master Ed25519 identity of the sender.
|
||||||
|
* Empty for legacy senders; when present, clients should use this as
|
||||||
|
* the conversation address so messages from any of the sender's
|
||||||
|
* linked devices roll into a single chat.
|
||||||
|
*/
|
||||||
|
sender_ed25519_pub: string;
|
||||||
|
recipient_pub: string;
|
||||||
|
nonce: string; // hex
|
||||||
|
ciphertext: string; // hex
|
||||||
|
timestamp: number; // unix seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Inbox ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface InboxItemWire {
|
||||||
|
id: string;
|
||||||
|
sender_pub: string;
|
||||||
|
sender_ed25519_pub?: string; // v2.2.0+; omitted by older nodes
|
||||||
|
recipient_pub: string;
|
||||||
|
sent_at: number;
|
||||||
|
nonce: string; // base64 on the wire
|
||||||
|
ciphertext: string; // base64 on the wire
|
||||||
|
}
|
||||||
|
interface InboxResponseWire {
|
||||||
|
pub: string;
|
||||||
|
count: number;
|
||||||
|
has_more: boolean;
|
||||||
|
items: InboxItemWire[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /relay/inbox?pub=<x25519> → envelopes addressed to that pub.
|
||||||
|
* Converts base64 nonce/ciphertext (Go wire format) to hex so they
|
||||||
|
* line up with what crypto.decryptMessage expects.
|
||||||
|
*/
|
||||||
|
export async function fetchInbox(x25519Pub: string): Promise<Envelope[]> {
|
||||||
|
const resp = await get<InboxResponseWire>(`/relay/inbox?pub=${x25519Pub}`);
|
||||||
|
const items = Array.isArray(resp?.items) ? resp.items : [];
|
||||||
|
return items.map((it): Envelope => ({
|
||||||
|
id: it.id,
|
||||||
|
sender_pub: it.sender_pub,
|
||||||
|
sender_ed25519_pub: it.sender_ed25519_pub ?? '',
|
||||||
|
recipient_pub: it.recipient_pub,
|
||||||
|
nonce: bytesToHex(base64ToBytes(it.nonce)),
|
||||||
|
ciphertext: bytesToHex(base64ToBytes(it.ciphertext)),
|
||||||
|
timestamp: it.sent_at ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Broadcast ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /relay/broadcast — submits a pre-sealed E2E envelope. The node
|
||||||
|
* relays without ever reading the plaintext; only the recipient's
|
||||||
|
* X25519 priv can open it. Sender_ed25519_pub is advisory for future
|
||||||
|
* fee-proof flows; current node ignores it when fee_ut = 0.
|
||||||
|
*/
|
||||||
|
export async function sendEnvelope(params: {
|
||||||
|
senderPub: string; // X25519 hex
|
||||||
|
recipientPub: string; // X25519 hex
|
||||||
|
nonce: string; // hex
|
||||||
|
ciphertext: string; // hex
|
||||||
|
senderEd25519Pub?: string; // optional
|
||||||
|
}): Promise<{ id: string; status: string }> {
|
||||||
|
const sentAt = Math.floor(Date.now() / 1000);
|
||||||
|
const nonceB64 = bytesToBase64(hexToBytes(params.nonce));
|
||||||
|
const ctB64 = bytesToBase64(hexToBytes(params.ciphertext));
|
||||||
|
// Envelope.id is server-facing dedup key; first 16 bytes of the nonce
|
||||||
|
// are cryptographically random, reuse them to avoid another RNG call.
|
||||||
|
const id = bytesToHex(hexToBytes(params.nonce).slice(0, 16));
|
||||||
|
return post<{ id: string; status: string }>('/relay/broadcast', {
|
||||||
|
envelope: {
|
||||||
|
id,
|
||||||
|
sender_pub: params.senderPub,
|
||||||
|
recipient_pub: params.recipientPub,
|
||||||
|
sender_ed25519_pub: params.senderEd25519Pub ?? '',
|
||||||
|
fee_ut: 0,
|
||||||
|
fee_sig: null,
|
||||||
|
nonce: nonceB64,
|
||||||
|
ciphertext: ctB64,
|
||||||
|
sent_at: sentAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Recipient resolution (multi-device v2.2.0) ──────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For a recipient identity, return every X25519 pub we should ship an
|
||||||
|
* envelope to. Device registry first, identity.x25519_pub as fall-back.
|
||||||
|
* Same helper lives in client-app — copied here rather than imported so
|
||||||
|
* the desktop build stays React-Native-free.
|
||||||
|
*/
|
||||||
|
export async function resolveRecipientKeys(masterPub: string): Promise<string[]> {
|
||||||
|
const devs = await fetchDevices(masterPub);
|
||||||
|
if (devs.length > 0) return devs.map(d => d.x25519_pub_key);
|
||||||
|
const id = await getIdentity(masterPub);
|
||||||
|
return id?.x25519_pub ? [id.x25519_pub] : [];
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
// per-install state. A future polish could move chats to IndexedDB
|
// per-install state. A future polish could move chats to IndexedDB
|
||||||
// for streaming writes, but localStorage is fine for v2.2.0.
|
// for streaming writes, but localStorage is fine for v2.2.0.
|
||||||
|
|
||||||
import type { KeyFile, NodeSettings, Contact } from './types';
|
import type { KeyFile, NodeSettings, Contact, Message } from './types';
|
||||||
import type { DChainAPI } from '../../electron/preload';
|
import type { DChainAPI } from '../../electron/preload';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -105,6 +105,34 @@ export function upsertContact(c: Contact): void {
|
|||||||
saveContacts(cs);
|
saveContacts(cs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Chat cache (per-conversation, capped) ───────────────────────────────
|
||||||
|
|
||||||
|
const CHATS_PREFIX = 'dchain_chats_';
|
||||||
|
const CHAT_CAP = 500;
|
||||||
|
|
||||||
|
export function loadMessages(chatAddr: string): Message[] {
|
||||||
|
const raw = localStorage.getItem(CHATS_PREFIX + chatAddr);
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as Message[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append + persist. Deduplicates by id, trims to CHAT_CAP newest. Callers
|
||||||
|
* in the UI should prefer zustand's store.appendMessage for reactivity
|
||||||
|
* and call this from effects to flush to disk.
|
||||||
|
*/
|
||||||
|
export function appendMessage(chatAddr: string, m: Message): void {
|
||||||
|
const cur = loadMessages(chatAddr);
|
||||||
|
if (cur.some(x => x.id === m.id)) return;
|
||||||
|
cur.push(m);
|
||||||
|
const trimmed = cur.slice(-CHAT_CAP);
|
||||||
|
localStorage.setItem(CHATS_PREFIX + chatAddr, JSON.stringify(trimmed));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Multi-device bookkeeping (shared semantic with mobile client) ───────
|
// ─── Multi-device bookkeeping (shared semantic with mobile client) ───────
|
||||||
|
|
||||||
const DEVICE_REGISTERED_KEY = 'dchain_device_registered';
|
const DEVICE_REGISTERED_KEY = 'dchain_device_registered';
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// Zustand store — same pattern as client-app/lib/store.ts, just lighter.
|
// Zustand store — mirrors client-app/lib/store.ts, trimmed to what the
|
||||||
// Holds identity, node settings, UI nav state (current section), and the
|
// desktop shell needs today. Holds identity, node settings, live chat
|
||||||
// bootstrapped flag so the Welcome screen can redirect only once boot
|
// state (contacts + per-chat messages + unread counters) and UI nav
|
||||||
// has run.
|
// (current section + selected contact). Persistence lives in
|
||||||
|
// lib/storage.ts and hooks (auto-save on mutations).
|
||||||
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { KeyFile, NodeSettings, Contact } from './types';
|
import type { KeyFile, NodeSettings, Contact, Message } from './types';
|
||||||
|
|
||||||
export type Section = 'messages' | 'feed' | 'wallet' | 'contacts' | 'settings' | 'profile';
|
export type Section = 'messages' | 'feed' | 'wallet' | 'contacts' | 'settings' | 'profile';
|
||||||
|
|
||||||
@@ -14,6 +15,12 @@ interface State {
|
|||||||
settings: NodeSettings;
|
settings: NodeSettings;
|
||||||
contacts: Contact[];
|
contacts: Contact[];
|
||||||
section: Section;
|
section: Section;
|
||||||
|
/** address of the currently-open conversation (mirrors mobile's route param). */
|
||||||
|
activeChat: string | null;
|
||||||
|
/** Messages keyed by contact.address. Each list is chronological (old → new). */
|
||||||
|
messages: Record<string, Message[]>;
|
||||||
|
/** Unread counters keyed by contact.address; 0 (or absent) = nothing pending. */
|
||||||
|
unread: Record<string, number>;
|
||||||
|
|
||||||
setBooted: (v: boolean) => void;
|
setBooted: (v: boolean) => void;
|
||||||
setKeyFile: (k: KeyFile | null) => void;
|
setKeyFile: (k: KeyFile | null) => void;
|
||||||
@@ -21,6 +28,12 @@ interface State {
|
|||||||
setContacts: (cs: Contact[]) => void;
|
setContacts: (cs: Contact[]) => void;
|
||||||
upsertContact: (c: Contact) => void;
|
upsertContact: (c: Contact) => void;
|
||||||
setSection: (s: Section) => void;
|
setSection: (s: Section) => void;
|
||||||
|
setActiveChat: (addr: string | null) => void;
|
||||||
|
|
||||||
|
setMessages: (addr: string, msgs: Message[]) => void;
|
||||||
|
appendMessage: (addr: string, m: Message) => void;
|
||||||
|
bumpUnread: (addr: string) => void;
|
||||||
|
clearUnread: (addr: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useStore = create<State>((set) => ({
|
export const useStore = create<State>((set) => ({
|
||||||
@@ -29,6 +42,9 @@ export const useStore = create<State>((set) => ({
|
|||||||
settings: { nodeUrl: 'http://localhost:8080', contractId: '' },
|
settings: { nodeUrl: 'http://localhost:8080', contractId: '' },
|
||||||
contacts: [],
|
contacts: [],
|
||||||
section: 'messages',
|
section: 'messages',
|
||||||
|
activeChat: null,
|
||||||
|
messages: {},
|
||||||
|
unread: {},
|
||||||
|
|
||||||
setBooted: (v) => set({ booted: v }),
|
setBooted: (v) => set({ booted: v }),
|
||||||
setKeyFile: (k) => set({ keyFile: k }),
|
setKeyFile: (k) => set({ keyFile: k }),
|
||||||
@@ -44,4 +60,25 @@ export const useStore = create<State>((set) => ({
|
|||||||
return { contacts: [...st.contacts, c] };
|
return { contacts: [...st.contacts, c] };
|
||||||
}),
|
}),
|
||||||
setSection: (s) => set({ section: s }),
|
setSection: (s) => set({ section: s }),
|
||||||
|
setActiveChat: (addr) => set({ activeChat: addr }),
|
||||||
|
|
||||||
|
setMessages: (addr, msgs) => set((st) => ({
|
||||||
|
messages: { ...st.messages, [addr]: msgs },
|
||||||
|
})),
|
||||||
|
appendMessage: (addr, m) => set((st) => {
|
||||||
|
const cur = st.messages[addr] ?? [];
|
||||||
|
// Idempotent — duplicate envelope deliveries (WS + HTTP race) shouldn't
|
||||||
|
// double-insert.
|
||||||
|
if (cur.some(x => x.id === m.id)) return {};
|
||||||
|
return { messages: { ...st.messages, [addr]: [...cur, m] } };
|
||||||
|
}),
|
||||||
|
bumpUnread: (addr) => set((st) => ({
|
||||||
|
unread: { ...st.unread, [addr]: (st.unread[addr] ?? 0) + 1 },
|
||||||
|
})),
|
||||||
|
clearUnread: (addr) => set((st) => {
|
||||||
|
if (!(addr in st.unread)) return {};
|
||||||
|
const next = { ...st.unread };
|
||||||
|
delete next[addr];
|
||||||
|
return { unread: next };
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
145
desktop/src/lib/tx.ts
Normal file
145
desktop/src/lib/tx.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
// Transaction builders + submission.
|
||||||
|
//
|
||||||
|
// Mirrors the handful of builders we actually use from client-app/lib/api.ts
|
||||||
|
// (Transfer, Link/UnlinkDevice for now; more will follow as sections land).
|
||||||
|
// Canonical bytes and wire format are identical to the mobile client —
|
||||||
|
// both talk to the same Go node, so any divergence here is a bug.
|
||||||
|
|
||||||
|
import { bytesToBase64, signBase64 } from './crypto';
|
||||||
|
import { post } from './api';
|
||||||
|
|
||||||
|
const MIN_TX_FEE = 1_000;
|
||||||
|
const _encoder = new TextEncoder();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transaction as sent to /api/tx — maps 1-to-1 to blockchain.Transaction
|
||||||
|
* JSON. `payload` and `signature` are base64 because Go's json.Marshal
|
||||||
|
* encodes []byte that way; `timestamp` is RFC3339 because Go's time.Time
|
||||||
|
* does the same.
|
||||||
|
*/
|
||||||
|
export interface RawTx {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
amount: number;
|
||||||
|
fee: number;
|
||||||
|
memo?: string;
|
||||||
|
payload: string;
|
||||||
|
signature: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rfc3339Now(): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setMilliseconds(0);
|
||||||
|
return d.toISOString().replace('.000Z', 'Z');
|
||||||
|
}
|
||||||
|
|
||||||
|
function newTxID(): string {
|
||||||
|
return `tx-${Date.now()}${Math.floor(Math.random() * 1_000_000)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical bytes the node re-derives to verify tx.signature. Order of
|
||||||
|
* keys matches Go's field order in identity.txSignBytes — JS object
|
||||||
|
* literals preserve insertion order so JSON.stringify is enough.
|
||||||
|
*/
|
||||||
|
function canonicalBytes(tx: {
|
||||||
|
id: string; type: string; from: string; to: string;
|
||||||
|
amount: number; fee: number; payload: string; timestamp: string;
|
||||||
|
}): Uint8Array {
|
||||||
|
return _encoder.encode(JSON.stringify({
|
||||||
|
id: tx.id,
|
||||||
|
type: tx.type,
|
||||||
|
from: tx.from,
|
||||||
|
to: tx.to,
|
||||||
|
amount: tx.amount,
|
||||||
|
fee: tx.fee,
|
||||||
|
payload: tx.payload,
|
||||||
|
timestamp: tx.timestamp,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function strToBase64(s: string): string {
|
||||||
|
return bytesToBase64(_encoder.encode(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitTx(tx: RawTx): Promise<{ id: string; status: string }> {
|
||||||
|
return post<{ id: string; status: string }>('/api/tx', tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Builders ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function buildTransferTx(p: {
|
||||||
|
from: string; to: string; amount: number; fee: number;
|
||||||
|
privKey: string; memo?: string;
|
||||||
|
}): RawTx {
|
||||||
|
const id = newTxID();
|
||||||
|
const timestamp = rfc3339Now();
|
||||||
|
const payload = strToBase64(JSON.stringify(p.memo ? { memo: p.memo } : {}));
|
||||||
|
const canon = canonicalBytes({
|
||||||
|
id, type: 'TRANSFER', from: p.from, to: p.to,
|
||||||
|
amount: p.amount, fee: p.fee, payload, timestamp,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id, type: 'TRANSFER', from: p.from, to: p.to,
|
||||||
|
amount: p.amount, fee: p.fee, memo: p.memo, payload, timestamp,
|
||||||
|
signature: signBase64(canon, p.privKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLinkDeviceTx(p: {
|
||||||
|
from: string; x25519Pub: string; deviceName: string; privKey: string;
|
||||||
|
}): RawTx {
|
||||||
|
const id = newTxID();
|
||||||
|
const timestamp = rfc3339Now();
|
||||||
|
const payload = strToBase64(JSON.stringify({
|
||||||
|
x25519_pub_key: p.x25519Pub,
|
||||||
|
device_name: p.deviceName,
|
||||||
|
}));
|
||||||
|
const canon = canonicalBytes({
|
||||||
|
id, type: 'LINK_DEVICE', from: p.from, to: '',
|
||||||
|
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id, type: 'LINK_DEVICE', from: p.from, to: '',
|
||||||
|
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||||
|
signature: signBase64(canon, p.privKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUnlinkDeviceTx(p: {
|
||||||
|
from: string; x25519Pub: string; privKey: string;
|
||||||
|
}): RawTx {
|
||||||
|
const id = newTxID();
|
||||||
|
const timestamp = rfc3339Now();
|
||||||
|
const payload = strToBase64(JSON.stringify({ x25519_pub_key: p.x25519Pub }));
|
||||||
|
const canon = canonicalBytes({
|
||||||
|
id, type: 'UNLINK_DEVICE', from: p.from, to: '',
|
||||||
|
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id, type: 'UNLINK_DEVICE', from: p.from, to: '',
|
||||||
|
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||||
|
signature: signBase64(canon, p.privKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* humanizeTxError unwraps the server's `{"error":"…"}` shape and common
|
||||||
|
* message wrappers into a one-line user-facing string. Same helper the
|
||||||
|
* mobile client exposes from lib/api.ts; copied here to keep the two
|
||||||
|
* codebases independent until we factor into a shared package.
|
||||||
|
*/
|
||||||
|
export function humanizeTxError(err: unknown): string {
|
||||||
|
const raw = err instanceof Error ? err.message : String(err);
|
||||||
|
const m = /→\s*({[^}]+})/.exec(raw);
|
||||||
|
if (m) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(m[1]);
|
||||||
|
if (parsed.error) return parsed.error;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
165
desktop/src/sections/messages/ChatList.tsx
Normal file
165
desktop/src/sections/messages/ChatList.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
// ChatList — the Messages left-pane list of conversations.
|
||||||
|
// Rows sort by last-activity timestamp (most recent first); empty state
|
||||||
|
// renders as a full-height notice so the layout doesn't collapse.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import type { Contact, Message } from '@/lib/types';
|
||||||
|
import { shortAddr } from '@/lib/crypto';
|
||||||
|
|
||||||
|
export function ChatList(): React.ReactElement {
|
||||||
|
const contacts = useStore(s => s.contacts);
|
||||||
|
const messages = useStore(s => s.messages);
|
||||||
|
const unread = useStore(s => s.unread);
|
||||||
|
const activeChat = useStore(s => s.activeChat);
|
||||||
|
const setActive = useStore(s => s.setActiveChat);
|
||||||
|
|
||||||
|
const lastOf = (c: Contact): Message | null => {
|
||||||
|
const list = messages[c.address];
|
||||||
|
return list && list.length > 0 ? list[list.length - 1] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sorted = [...contacts]
|
||||||
|
.map(c => ({ c, last: lastOf(c) }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const ka = a.last ? a.last.timestamp : a.c.addedAt / 1000;
|
||||||
|
const kb = b.last ? b.last.timestamp : b.c.addedAt / 1000;
|
||||||
|
return kb - ka;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: 28, textAlign: 'center', color: '#8b8b8b', fontSize: 13,
|
||||||
|
}}>
|
||||||
|
No conversations yet. Messages from pairing devices or contacts
|
||||||
|
will appear here.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{sorted.map(({ c, last }) => (
|
||||||
|
<ChatRow
|
||||||
|
key={c.address}
|
||||||
|
contact={c}
|
||||||
|
last={last}
|
||||||
|
unread={unread[c.address] ?? 0}
|
||||||
|
active={activeChat === c.address}
|
||||||
|
onClick={() => {
|
||||||
|
setActive(c.address);
|
||||||
|
useStore.getState().clearUnread(c.address);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatRow({
|
||||||
|
contact, last, unread, active, onClick,
|
||||||
|
}: {
|
||||||
|
contact: Contact;
|
||||||
|
last: Message | null;
|
||||||
|
unread: number;
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const name = contact.alias || contact.username
|
||||||
|
? (contact.username ? `@${contact.username}` : contact.alias!)
|
||||||
|
: shortAddr(contact.address, 6);
|
||||||
|
|
||||||
|
const time = last
|
||||||
|
? formatWhen(last.timestamp)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
padding: '12px 14px',
|
||||||
|
borderBottom: '1px solid #1f1f1f',
|
||||||
|
background: active ? '#0a1a29' : 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = '#0a0a0a'; }}
|
||||||
|
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
<Avatar name={name} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between',
|
||||||
|
alignItems: 'center', gap: 8,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
color: '#fff', fontSize: 14, fontWeight: 700,
|
||||||
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||||
|
}}>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
{time && (
|
||||||
|
<div style={{ color: '#6a6a6a', fontSize: 11, flexShrink: 0 }}>
|
||||||
|
{time}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6, marginTop: 3,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
flex: 1, color: '#8b8b8b', fontSize: 12,
|
||||||
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||||
|
}}>
|
||||||
|
{last ? preview(last) : 'Tap to start'}
|
||||||
|
</div>
|
||||||
|
{unread > 0 && (
|
||||||
|
<div style={{
|
||||||
|
minWidth: 18, height: 18, borderRadius: 9,
|
||||||
|
padding: '0 6px', background: '#1d9bf0', color: '#fff',
|
||||||
|
fontSize: 11, fontWeight: 700,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{unread > 99 ? '99+' : unread}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Avatar({ name }: { name: string }) {
|
||||||
|
const letter = name.replace(/^@/, '').charAt(0).toUpperCase() || '?';
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: 40, height: 40, borderRadius: 20, flexShrink: 0,
|
||||||
|
background: '#1a1a1a',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: '#d0d0d0', fontWeight: 700,
|
||||||
|
}}>{letter}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function preview(m: Message): string {
|
||||||
|
const t = m.text.trim();
|
||||||
|
if (t.length === 0) return m.attachment ? '(attachment)' : '';
|
||||||
|
return t.length > 60 ? t.slice(0, 60) + '…' : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWhen(unixSec: number): string {
|
||||||
|
const d = new Date(unixSec * 1000);
|
||||||
|
const now = new Date();
|
||||||
|
const sameDay =
|
||||||
|
d.getFullYear() === now.getFullYear() &&
|
||||||
|
d.getMonth() === now.getMonth() &&
|
||||||
|
d.getDate() === now.getDate();
|
||||||
|
if (sameDay) {
|
||||||
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
const sameYear = d.getFullYear() === now.getFullYear();
|
||||||
|
return sameYear
|
||||||
|
? d.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||||||
|
: d.toLocaleDateString();
|
||||||
|
}
|
||||||
213
desktop/src/sections/messages/Conversation.tsx
Normal file
213
desktop/src/sections/messages/Conversation.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
// Conversation — the Messages right-pane showing one chat + composer.
|
||||||
|
//
|
||||||
|
// Responsibilities:
|
||||||
|
// * Render header with contact identity + close button.
|
||||||
|
// * Auto-scroll the message list to the bottom on new arrival.
|
||||||
|
// * Composer with Enter-to-send, Shift+Enter for newline.
|
||||||
|
// * Fan out every outgoing message across the recipient's device
|
||||||
|
// registry (falls back to legacy single-X25519 for pre-v2.2.0
|
||||||
|
// peers). One envelope per device; Promise.all, any failure
|
||||||
|
// rejects the batch so the user sees it.
|
||||||
|
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { encryptMessage, shortAddr } from '@/lib/crypto';
|
||||||
|
import { sendEnvelope, resolveRecipientKeys } from '@/lib/relay';
|
||||||
|
import { appendMessage as persist } from '@/lib/storage';
|
||||||
|
import type { Message } from '@/lib/types';
|
||||||
|
|
||||||
|
export function Conversation({ address }: { address: string }): React.ReactElement {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const contact = useStore(s => s.contacts.find(c => c.address === address));
|
||||||
|
const messages = useStore(s => s.messages[address] ?? []);
|
||||||
|
const clearUnread = useStore(s => s.clearUnread);
|
||||||
|
const appendMsg = useStore(s => s.appendMessage);
|
||||||
|
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Seeing a conversation drops its unread count.
|
||||||
|
useEffect(() => { clearUnread(address); }, [address, clearUnread]);
|
||||||
|
|
||||||
|
// Pin the scroll to the bottom on new messages. Only if the user
|
||||||
|
// is already near the bottom — don't yank them back if they're
|
||||||
|
// scrolling through older history.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
|
||||||
|
if (nearBottom) el.scrollTop = el.scrollHeight;
|
||||||
|
}, [messages.length]);
|
||||||
|
|
||||||
|
const isSelf = !!keyFile && keyFile.pub_key === address;
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
if (!keyFile || sending) return;
|
||||||
|
const body = text.trim();
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
setSending(true); setError(null);
|
||||||
|
try {
|
||||||
|
// Saved Messages path — the conversation address equals our own
|
||||||
|
// master pub. Mobile parity: append locally, skip the relay
|
||||||
|
// round-trip entirely (no fees, no ciphertext ever leaves).
|
||||||
|
if (!isSelf) {
|
||||||
|
const pubs = await resolveRecipientKeys(address);
|
||||||
|
if (pubs.length === 0) {
|
||||||
|
throw new Error('recipient has no encryption key published');
|
||||||
|
}
|
||||||
|
await Promise.all(pubs.map(async (rpub) => {
|
||||||
|
const { nonce, ciphertext } = encryptMessage(
|
||||||
|
body, keyFile.x25519_priv, rpub,
|
||||||
|
);
|
||||||
|
await sendEnvelope({
|
||||||
|
senderPub: keyFile.x25519_pub,
|
||||||
|
recipientPub: rpub,
|
||||||
|
senderEd25519Pub: keyFile.pub_key,
|
||||||
|
nonce, ciphertext,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const m: Message = {
|
||||||
|
id: `out-${Date.now()}${Math.floor(Math.random() * 1e6)}`,
|
||||||
|
from: keyFile.x25519_pub,
|
||||||
|
text: body,
|
||||||
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
|
mine: true,
|
||||||
|
read: false,
|
||||||
|
edited: false,
|
||||||
|
};
|
||||||
|
appendMsg(address, m);
|
||||||
|
persist(address, m);
|
||||||
|
setText('');
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
send();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const name = contact?.username ? `@${contact.username}`
|
||||||
|
: contact?.alias
|
||||||
|
? contact.alias
|
||||||
|
: isSelf
|
||||||
|
? 'Saved Messages'
|
||||||
|
: shortAddr(address, 8);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 16px', borderBottom: '1px solid #1f1f1f',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 32, height: 32, borderRadius: 16,
|
||||||
|
background: isSelf ? '#1d9bf0' : '#1a1a1a',
|
||||||
|
color: '#fff', fontWeight: 700, fontSize: 14,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{isSelf ? '★' : name.replace(/^@/, '').charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ color: '#fff', fontSize: 14, fontWeight: 700 }}>{name}</div>
|
||||||
|
<div style={{ color: '#6a6a6a', fontSize: 11, fontFamily: 'monospace' }}>
|
||||||
|
{shortAddr(address, 6)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
style={{ flex: 1, overflowY: 'auto', padding: '14px 16px' }}
|
||||||
|
>
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div style={{
|
||||||
|
color: '#6a6a6a', fontSize: 13, textAlign: 'center',
|
||||||
|
marginTop: 40,
|
||||||
|
}}>
|
||||||
|
{isSelf
|
||||||
|
? 'Notes to self. Messages here stay on this device only.'
|
||||||
|
: 'No messages yet. Type below to send the first one.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{messages.map(m => <Bubble key={m.id} message={m} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Composer */}
|
||||||
|
<div style={{
|
||||||
|
borderTop: '1px solid #1f1f1f', padding: 12,
|
||||||
|
display: 'flex', gap: 10, alignItems: 'flex-end',
|
||||||
|
}}>
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={e => setText(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder="Message…"
|
||||||
|
rows={1}
|
||||||
|
style={{
|
||||||
|
flex: 1, resize: 'none',
|
||||||
|
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||||
|
borderRadius: 10, padding: '10px 12px',
|
||||||
|
color: '#fff', fontSize: 13, fontFamily: 'inherit',
|
||||||
|
outline: 'none', lineHeight: 1.4, maxHeight: 140,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={send}
|
||||||
|
disabled={sending || text.trim().length === 0}
|
||||||
|
style={{
|
||||||
|
padding: '10px 16px', borderRadius: 999, border: 'none',
|
||||||
|
background: '#1d9bf0', color: '#fff', fontSize: 13, fontWeight: 700,
|
||||||
|
cursor: sending || text.trim().length === 0 ? 'default' : 'pointer',
|
||||||
|
opacity: sending || text.trim().length === 0 ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sending ? '…' : 'Send'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
padding: '6px 16px 10px', fontSize: 11, color: '#ff6b6b',
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Bubble({ message }: { message: Message }) {
|
||||||
|
const mine = message.mine;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', justifyContent: mine ? 'flex-end' : 'flex-start',
|
||||||
|
}}>
|
||||||
|
<div className="selectable" style={{
|
||||||
|
maxWidth: '70%',
|
||||||
|
padding: '8px 12px', borderRadius: 14,
|
||||||
|
background: mine ? '#1d9bf0' : '#1a1a1a',
|
||||||
|
color: mine ? '#fff' : '#e0e0e0',
|
||||||
|
fontSize: 13, lineHeight: 1.45,
|
||||||
|
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||||
|
}}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
desktop/src/sections/messages/EmptyConversation.tsx
Normal file
13
desktop/src/sections/messages/EmptyConversation.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export function EmptyConversation(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 40, color: '#6a6a6a', fontSize: 13, textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
Select a conversation from the list,<br/>or wait for one to appear as messages arrive.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,29 +1,44 @@
|
|||||||
// Messages section — chat list (left pane) + conversation (detail pane).
|
// Messages section — full implementation. Left pane is the chat list;
|
||||||
// v2.2.0-alpha4 ships the scaffold only; real list + conversation come
|
// right pane mounts the active conversation or an empty-state.
|
||||||
// in follow-up commits sharing logic with client-app/app/(app)/chats.
|
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
|
|
||||||
import { useStore } from '@/lib/store';
|
import { useStore } from '@/lib/store';
|
||||||
|
import { loadMessages } from '@/lib/storage';
|
||||||
|
import { useInboxPoll } from '@/hooks/useInboxPoll';
|
||||||
|
import { ChatList } from './ChatList';
|
||||||
|
import { Conversation } from './Conversation';
|
||||||
|
import { EmptyConversation } from './EmptyConversation';
|
||||||
|
|
||||||
export function MessagesList(): React.ReactElement {
|
export function MessagesList(): React.ReactElement {
|
||||||
|
// Warm cached messages from localStorage once per mount, so toggling
|
||||||
|
// back into Messages after visiting another section doesn't forget
|
||||||
|
// history. Zustand wins on conflict — once the hook has appended
|
||||||
|
// live messages, we don't overwrite them with stale disk snapshots.
|
||||||
const contacts = useStore(s => s.contacts);
|
const contacts = useStore(s => s.contacts);
|
||||||
return (
|
const setMsgs = useStore(s => s.setMessages);
|
||||||
<SectionPlaceholder
|
const hydrated = useRef(false);
|
||||||
title="Messages"
|
|
||||||
note={contacts.length === 0
|
// Kick off the inbox polling loop while Messages is mounted.
|
||||||
? 'No chats yet. Add a contact from the Contacts tab.'
|
// Section-scoped for now so we don't pay the bandwidth cost when
|
||||||
: `${contacts.length} conversation${contacts.length === 1 ? '' : 's'} cached.`}
|
// the user is in Feed / Wallet / etc.; a future alpha can promote
|
||||||
/>
|
// it to the shell if we want notifications in other sections too.
|
||||||
);
|
useInboxPoll();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hydrated.current) return;
|
||||||
|
hydrated.current = true;
|
||||||
|
const st = useStore.getState();
|
||||||
|
for (const c of contacts) {
|
||||||
|
if ((st.messages[c.address] ?? []).length > 0) continue;
|
||||||
|
const cached = loadMessages(c.address);
|
||||||
|
if (cached.length > 0) setMsgs(c.address, cached);
|
||||||
|
}
|
||||||
|
}, [contacts, setMsgs]);
|
||||||
|
|
||||||
|
return <ChatList />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessagesDetail(): React.ReactElement {
|
export function MessagesDetail(): React.ReactElement {
|
||||||
return (
|
const activeChat = useStore(s => s.activeChat);
|
||||||
<SectionPlaceholder
|
return activeChat ? <Conversation address={activeChat} /> : <EmptyConversation />;
|
||||||
title="Select a chat"
|
|
||||||
note="Pick a conversation from the list on the left, or start a new one."
|
|
||||||
centered
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ func relayInboxList(rc RelayConfig) http.HandlerFunc {
|
|||||||
|
|
||||||
type item struct {
|
type item struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
SenderPub string `json:"sender_pub"`
|
SenderPub string `json:"sender_pub"` // X25519 hex
|
||||||
|
SenderEd25519Pub string `json:"sender_ed25519_pub"` // master Ed25519 hex (optional; may be empty for legacy senders)
|
||||||
RecipientPub string `json:"recipient_pub"`
|
RecipientPub string `json:"recipient_pub"`
|
||||||
FeeUT uint64 `json:"fee_ut,omitempty"`
|
FeeUT uint64 `json:"fee_ut,omitempty"`
|
||||||
SentAt int64 `json:"sent_at"`
|
SentAt int64 `json:"sent_at"`
|
||||||
@@ -105,6 +106,7 @@ func relayInboxList(rc RelayConfig) http.HandlerFunc {
|
|||||||
out = append(out, item{
|
out = append(out, item{
|
||||||
ID: env.ID,
|
ID: env.ID,
|
||||||
SenderPub: env.SenderPub,
|
SenderPub: env.SenderPub,
|
||||||
|
SenderEd25519Pub: env.SenderEd25519PubKey,
|
||||||
RecipientPub: env.RecipientPub,
|
RecipientPub: env.RecipientPub,
|
||||||
FeeUT: env.FeeUT,
|
FeeUT: env.FeeUT,
|
||||||
SentAt: env.SentAt,
|
SentAt: env.SentAt,
|
||||||
|
|||||||
Reference in New Issue
Block a user