Compare commits
4 Commits
v2.2.0-rc1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82d3706e38 | ||
|
|
7e6fe2c2a0 | ||
|
|
481d4d2fa8 | ||
|
|
6b7cb1c5a9 |
@@ -9,6 +9,13 @@
|
||||
dev vs. production rules cleanly. -->
|
||||
<title>DChain</title>
|
||||
<style>
|
||||
/* Global box-sizing — every padding+border counts toward the declared
|
||||
width, not on top of it. Without this, `<input style="width:100%">`
|
||||
inside a padded flex container visibly overflows its parent on
|
||||
every modal / Settings card. Applied universally because almost
|
||||
every per-element override was forgetting it anyway. */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
html, body, #root { margin: 0; padding: 0; height: 100%; background: #000; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
@@ -23,6 +30,11 @@
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
/* Form elements should never paint their own background/border over
|
||||
our dark theme. Each component still sets its own explicit colours. */
|
||||
input, textarea, button {
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dchain-desktop",
|
||||
"version": "2.2.0-rc1",
|
||||
"version": "2.2.0",
|
||||
"description": "DChain desktop client — Electron shell mirroring the mobile app's functionality with a keyboard-first 3-panel layout.",
|
||||
"private": true,
|
||||
"main": "dist-electron/main.js",
|
||||
@@ -36,25 +36,40 @@
|
||||
"build": {
|
||||
"appId": "com.dchain.desktop",
|
||||
"productName": "DChain",
|
||||
"copyright": "Copyright © 2026 DChain contributors",
|
||||
"asar": true,
|
||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"dist-electron/**/*"
|
||||
"dist-electron/**/*",
|
||||
"!**/*.map",
|
||||
"!**/node_modules/**/test/**",
|
||||
"!**/node_modules/**/tests/**"
|
||||
],
|
||||
"directories": {
|
||||
"output": "release",
|
||||
"buildResources": "resources"
|
||||
},
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg"
|
||||
]
|
||||
"target": ["dmg", "zip"],
|
||||
"category": "public.app-category.social-networking",
|
||||
"hardenedRuntime": true,
|
||||
"gatekeeperAssess": false
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
]
|
||||
"target": ["nsis", "portable"]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowElevation": true,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb"
|
||||
]
|
||||
}
|
||||
"target": ["AppImage", "deb"],
|
||||
"category": "Network"
|
||||
},
|
||||
"publish": null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { loadKeyFile, loadSettings, loadContacts } from '@/lib/storage';
|
||||
import { setNodeUrl } from '@/lib/api';
|
||||
import { useDeviceBootstrap } from '@/hooks/useDeviceBootstrap';
|
||||
import { Shell } from '@/shell/Shell';
|
||||
import { Welcome } from '@/auth/Welcome';
|
||||
|
||||
@@ -16,6 +17,11 @@ export function App(): React.ReactElement {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const [bootError, setBootError] = useState<string | null>(null);
|
||||
|
||||
// Multi-device registry bootstrap — publishes THIS device on the
|
||||
// chain so senders can fan out envelopes to us, and self-wipes if
|
||||
// another device has since revoked us. See hooks/useDeviceBootstrap.
|
||||
useDeviceBootstrap();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
|
||||
92
desktop/src/hooks/useDeviceBootstrap.ts
Normal file
92
desktop/src/hooks/useDeviceBootstrap.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// Mirror of mobile's _layout.tsx bootstrap effect (v2.2.0-alpha2):
|
||||
// ensures this device is visible to senders via the on-chain device
|
||||
// registry, and detects remote revoke so a revoked laptop wipes its
|
||||
// state the moment it sees it's no longer active.
|
||||
//
|
||||
// Three branches by (chain list × local "was registered" flag):
|
||||
//
|
||||
// 1. Our X25519 pub IS in the active list — flip the local marker
|
||||
// (idempotent), done. Next sign-in is a no-op.
|
||||
//
|
||||
// 2. Our X25519 pub is NOT in the active list, but we had marked
|
||||
// ourselves registered before → another device issued
|
||||
// UNLINK_DEVICE against us. Wipe master priv + local caches and
|
||||
// bounce back to the Welcome screen. Not fatal: user can import
|
||||
// the key again if that was a mistake.
|
||||
//
|
||||
// 3. Our X25519 pub is NOT in the active list, and we've never
|
||||
// registered before → first sign-in. Submit LINK_DEVICE. On
|
||||
// a zero-balance wallet the tx bounces; next launch retries.
|
||||
// No user-facing error — this is best-effort plumbing.
|
||||
//
|
||||
// Network errors never trigger the wipe path: we only act when the
|
||||
// chain explicitly reports the absence.
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { fetchDevices } from '@/lib/api';
|
||||
import { buildLinkDeviceTx, submitTx } from '@/lib/tx';
|
||||
import {
|
||||
isDeviceRegistered, markDeviceRegistered, wipeAllLocalState,
|
||||
} from '@/lib/storage';
|
||||
|
||||
export function useDeviceBootstrap(): void {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
|
||||
useEffect(() => {
|
||||
if (!keyFile) return;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
let chainList;
|
||||
try {
|
||||
chainList = await fetchDevices(keyFile.pub_key);
|
||||
} catch {
|
||||
// Network issue — leave state alone; try again next sign-in.
|
||||
return;
|
||||
}
|
||||
if (cancelled) return;
|
||||
|
||||
const inActive = chainList.some(d => d.x25519_pub_key === keyFile.x25519_pub);
|
||||
const previouslyRegistered = isDeviceRegistered();
|
||||
|
||||
if (inActive) {
|
||||
if (!previouslyRegistered) markDeviceRegistered();
|
||||
return;
|
||||
}
|
||||
|
||||
if (previouslyRegistered) {
|
||||
// Revoked from another device. Wipe and send the user back to
|
||||
// onboarding; the App-level render branch will route to Welcome
|
||||
// as soon as keyFile flips to null.
|
||||
await wipeAllLocalState();
|
||||
useStore.getState().setKeyFile(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// First boot — publish this device. Desktop is almost always a
|
||||
// "second" device (paired from a phone), so a balance is normally
|
||||
// available; we just need to ship the tx. Failures (insufficient
|
||||
// balance, a node that doesn't grok v2.2.0) are swallowed.
|
||||
try {
|
||||
const platform = await window.dchain.app.platform().catch(() => 'unknown');
|
||||
const deviceName = platform === 'darwin' ? 'Mac'
|
||||
: platform === 'win32' ? 'Windows'
|
||||
: platform === 'linux' ? 'Linux'
|
||||
: 'Desktop';
|
||||
const tx = buildLinkDeviceTx({
|
||||
from: keyFile.pub_key,
|
||||
x25519Pub: keyFile.x25519_pub,
|
||||
deviceName,
|
||||
privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(tx);
|
||||
markDeviceRegistered();
|
||||
} catch {
|
||||
/* next launch retries */
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [keyFile]);
|
||||
}
|
||||
100
desktop/src/hooks/useUpdateCheck.ts
Normal file
100
desktop/src/hooks/useUpdateCheck.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
// useUpdateCheck — polls the configured node's /api/update-check once
|
||||
// per launch (+ every 6h while the window stays open), compares the
|
||||
// Gitea release tag against this client's app version, and exposes
|
||||
// { latest, url } when ours is older.
|
||||
//
|
||||
// Why reuse the node endpoint? The DChain node already fetches Gitea
|
||||
// releases on behalf of its operator; piggybacking on the same cached
|
||||
// JSON means the desktop client doesn't need a direct Gitea token or
|
||||
// a separate update feed. One source of truth, no new infra.
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { get } from '@/lib/api';
|
||||
|
||||
interface UpdateCheck {
|
||||
current?: { tag?: string };
|
||||
latest?: { tag?: string; commit?: string; url?: string; published_at?: string };
|
||||
update_available?: boolean;
|
||||
source?: string;
|
||||
checked_at?: string;
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
latestTag: string;
|
||||
url: string;
|
||||
publishedAt: string;
|
||||
}
|
||||
|
||||
export function useUpdateCheck(): UpdateInfo | null {
|
||||
const [info, setInfo] = useState<UpdateInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const tick = async () => {
|
||||
try {
|
||||
// Our version (set in package.json, baked into Electron at build time).
|
||||
const myVersion = (await window.dchain.app.version()).trim();
|
||||
const r = await get<UpdateCheck>('/api/update-check');
|
||||
if (cancelled) return;
|
||||
const latest = r.latest?.tag?.trim() ?? '';
|
||||
if (!latest || !r.latest?.url) { setInfo(null); return; }
|
||||
// Compare semver-ish. The node's own `update_available` flag
|
||||
// compares vs. the NODE's version, not ours, so we re-derive.
|
||||
if (isNewer(latest, myVersion)) {
|
||||
setInfo({
|
||||
latestTag: latest,
|
||||
url: r.latest.url,
|
||||
publishedAt: r.latest.published_at ?? '',
|
||||
});
|
||||
} else {
|
||||
setInfo(null);
|
||||
}
|
||||
} catch {
|
||||
// Node doesn't have the endpoint configured, or offline — quiet fail.
|
||||
}
|
||||
};
|
||||
|
||||
tick();
|
||||
const t = setInterval(tick, 6 * 60 * 60 * 1000);
|
||||
return () => { cancelled = true; clearInterval(t); };
|
||||
}, []);
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* isNewer — loose semver compare for strings like `v2.2.0` / `2.2.0-rc1`.
|
||||
* Strips leading `v`, splits on dots and the first `-` (pre-release
|
||||
* suffix), compares numerically left-to-right. Pre-release tags are
|
||||
* considered OLDER than the bare version (so `2.2.0 > 2.2.0-rc1`).
|
||||
* Not a full semver implementation — good enough to decide whether to
|
||||
* show the "update available" badge. If our parse fails, we assume no
|
||||
* update (safer than nagging users with false positives).
|
||||
*/
|
||||
export function isNewer(candidate: string, reference: string): boolean {
|
||||
const a = parseVersion(candidate);
|
||||
const b = parseVersion(reference);
|
||||
if (!a || !b) return false;
|
||||
for (let i = 0; i < Math.max(a.nums.length, b.nums.length); i++) {
|
||||
const x = a.nums[i] ?? 0;
|
||||
const y = b.nums[i] ?? 0;
|
||||
if (x !== y) return x > y;
|
||||
}
|
||||
// All numeric parts equal → compare pre-release. `""` (stable) beats any suffix.
|
||||
if (a.pre === b.pre) return false;
|
||||
if (a.pre === '') return true; // stable > prerelease
|
||||
if (b.pre === '') return false; // prerelease < stable
|
||||
return a.pre > b.pre; // alpha6 > alpha5 lexically, fine in practice
|
||||
}
|
||||
|
||||
function parseVersion(v: string): { nums: number[]; pre: string } | null {
|
||||
if (!v) return null;
|
||||
const clean = v.trim().replace(/^v/i, '');
|
||||
const dash = clean.indexOf('-');
|
||||
const head = dash >= 0 ? clean.slice(0, dash) : clean;
|
||||
const pre = dash >= 0 ? clean.slice(dash + 1) : '';
|
||||
const nums = head.split('.').map(s => parseInt(s, 10));
|
||||
if (nums.some(n => !Number.isFinite(n))) return null;
|
||||
return { nums, pre };
|
||||
}
|
||||
@@ -175,6 +175,30 @@ export async function getTxDetail(txID: string): Promise<TxDetail | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Contact requests (on-chain, via /relay/contacts) ───────────────────
|
||||
|
||||
export interface ContactRequestRaw {
|
||||
requester_pub: string;
|
||||
requester_addr: string;
|
||||
status: string; // "pending" | "accepted" | "blocked"
|
||||
intro: string;
|
||||
fee_ut: number;
|
||||
tx_id: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /relay/contacts?pub=<ed25519> — returns every on-chain
|
||||
* CONTACT_REQUEST addressed to `pub`, regardless of status. The UI
|
||||
* filters by pending before showing.
|
||||
*/
|
||||
export async function fetchContactRequests(edPub: string): Promise<ContactRequestRaw[]> {
|
||||
try {
|
||||
const r = await get<{ contacts?: ContactRequestRaw[] }>(`/relay/contacts?pub=${edPub}`);
|
||||
return r.contacts ?? [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
/** Resolve a DC address or @username into an Ed25519 pub (hex). */
|
||||
export async function resolveAccount(input: string): Promise<string | null> {
|
||||
const trimmed = input.trim();
|
||||
|
||||
@@ -126,6 +126,77 @@ export function buildUnlinkDeviceTx(p: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CONTACT_REQUEST — paid first-contact tx. `amount` carries the
|
||||
* anti-spam fee (≥ MinContactFee = 5000 µT on the node), credited to
|
||||
* the recipient's balance as an incentive to accept; `fee` is the
|
||||
* regular network fee. Optional `intro` plaintext is embedded in the
|
||||
* payload so the receiver sees "who is this" before accepting.
|
||||
*/
|
||||
export function buildContactRequestTx(p: {
|
||||
from: string;
|
||||
to: string;
|
||||
contactFee: number; // µT — ≥ 5000, paid to recipient
|
||||
privKey: string;
|
||||
intro?: string;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const payload = strToBase64(JSON.stringify(p.intro ? { intro: p.intro } : {}));
|
||||
const canon = canonicalBytes({
|
||||
id, type: 'CONTACT_REQUEST', from: p.from, to: p.to,
|
||||
amount: p.contactFee, fee: MIN_TX_FEE, payload, timestamp,
|
||||
});
|
||||
return {
|
||||
id, type: 'CONTACT_REQUEST', from: p.from, to: p.to,
|
||||
amount: p.contactFee, fee: MIN_TX_FEE, payload, timestamp,
|
||||
signature: signBase64(canon, p.privKey),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ACCEPT_CONTACT — recipient side, empties the pending request and
|
||||
* publishes the peer's X25519 key so the requester can start sending
|
||||
* encrypted envelopes. Tx.to = original requester's pub.
|
||||
*/
|
||||
export function buildAcceptContactTx(p: {
|
||||
from: string; to: string; privKey: string;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const payload = strToBase64('{}');
|
||||
const canon = canonicalBytes({
|
||||
id, type: 'ACCEPT_CONTACT', from: p.from, to: p.to,
|
||||
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||
});
|
||||
return {
|
||||
id, type: 'ACCEPT_CONTACT', from: p.from, to: p.to,
|
||||
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||
signature: signBase64(canon, p.privKey),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* BLOCK_CONTACT — sticky rejection. Subsequent CONTACT_REQUEST txs
|
||||
* from the same sender are dropped at applyTx level on the node.
|
||||
*/
|
||||
export function buildBlockContactTx(p: {
|
||||
from: string; to: string; privKey: string;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const payload = strToBase64('{}');
|
||||
const canon = canonicalBytes({
|
||||
id, type: 'BLOCK_CONTACT', from: p.from, to: p.to,
|
||||
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||
});
|
||||
return {
|
||||
id, type: 'BLOCK_CONTACT', from: p.from, to: p.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
|
||||
|
||||
@@ -3,17 +3,40 @@
|
||||
// WS presence + request inbox plumbing; placeholder headers are left
|
||||
// in the UI so the shape is visible.
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { shortAddr } from '@/lib/crypto';
|
||||
import { fetchContactRequests, type ContactRequestRaw } from '@/lib/api';
|
||||
import type { Contact } from '@/lib/types';
|
||||
import { NewContactModal } from './NewContactModal';
|
||||
import { RequestsList } from './RequestsList';
|
||||
|
||||
export function ContactsList(): React.ReactElement {
|
||||
const contacts = useStore(s => s.contacts);
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const sel = useStore(s => s.selectedContact);
|
||||
const setSel = useStore(s => s.setSelectedContact);
|
||||
|
||||
const [q, setQ] = useState('');
|
||||
const [tab, setTab] = useState<'list' | 'requests'>('list');
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [requests, setRequests] = useState<ContactRequestRaw[]>([]);
|
||||
|
||||
// Load pending contact requests (on-chain inbox). Refreshes when the
|
||||
// tab is opened and after a new request is sent so the counter moves.
|
||||
const refreshRequests = async () => {
|
||||
if (!keyFile) return;
|
||||
const list = await fetchContactRequests(keyFile.pub_key);
|
||||
// Filter to pending only — accepted ones turn into contacts.
|
||||
const knownContacts = new Set(contacts.map(c => c.address));
|
||||
setRequests(list.filter(r =>
|
||||
r.status === 'pending' && !knownContacts.has(r.requester_pub),
|
||||
));
|
||||
};
|
||||
|
||||
useEffect(() => { refreshRequests(); const t = setInterval(refreshRequests, 15_000); return () => clearInterval(t); },
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[keyFile, contacts]);
|
||||
const filtered = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
if (!needle) return contacts;
|
||||
@@ -34,41 +57,111 @@ export function ContactsList(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Search */}
|
||||
{/* Sticky header: tab switcher + search / action row */}
|
||||
<div style={{
|
||||
position: 'sticky', top: 0, zIndex: 1,
|
||||
padding: 10, background: '#000',
|
||||
borderBottom: '1px solid #1f1f1f',
|
||||
background: '#000', borderBottom: '1px solid #1f1f1f',
|
||||
}}>
|
||||
<input
|
||||
value={q}
|
||||
onChange={e => setQ(e.target.value)}
|
||||
placeholder="Filter…"
|
||||
style={{
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||
borderRadius: 8, padding: '8px 10px',
|
||||
color: '#fff', fontSize: 13, outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
display: 'flex', padding: '8px 10px 0', gap: 4,
|
||||
}}>
|
||||
<TabBtn
|
||||
label="Contacts"
|
||||
active={tab === 'list'}
|
||||
onClick={() => setTab('list')}
|
||||
/>
|
||||
<TabBtn
|
||||
label="Requests"
|
||||
active={tab === 'requests'}
|
||||
badge={requests.length}
|
||||
onClick={() => setTab('requests')}
|
||||
/>
|
||||
</div>
|
||||
{tab === 'list' && (
|
||||
<div style={{ padding: 10, display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
value={q}
|
||||
onChange={e => setQ(e.target.value)}
|
||||
placeholder="Filter…"
|
||||
style={{
|
||||
flex: 1, boxSizing: 'border-box',
|
||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||
borderRadius: 8, padding: '8px 10px',
|
||||
color: '#fff', fontSize: 13, outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setNewOpen(true)}
|
||||
title="Send contact request"
|
||||
style={{
|
||||
padding: '8px 12px', borderRadius: 8, border: 'none',
|
||||
background: '#1d9bf0', color: '#fff',
|
||||
fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||||
}}
|
||||
>+ New</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sorted.length === 0 ? (
|
||||
{tab === 'requests' ? (
|
||||
<RequestsList
|
||||
requests={requests}
|
||||
onChanged={refreshRequests}
|
||||
/>
|
||||
) : sorted.length === 0 ? (
|
||||
<div style={{
|
||||
padding: 32, color: '#6a6a6a', fontSize: 13, textAlign: 'center',
|
||||
}}>
|
||||
No contacts yet. They appear as chats start, or as peers pair
|
||||
their own devices with yours.
|
||||
No contacts yet. Tap <b>+ New</b> above to send a contact request,
|
||||
or pair another of your own devices via Settings → Devices.
|
||||
</div>
|
||||
) : (
|
||||
sorted.map(c => (
|
||||
<Row key={c.address} c={c} active={c.address === sel} onClick={() => setSel(c.address)} />
|
||||
))
|
||||
)}
|
||||
|
||||
{newOpen && (
|
||||
<NewContactModal
|
||||
onClose={() => setNewOpen(false)}
|
||||
onSent={() => { setNewOpen(false); refreshRequests(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabBtn({
|
||||
label, active, onClick, badge,
|
||||
}: {
|
||||
label: string; active: boolean; onClick: () => void; badge?: number;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: '8px 12px', borderRadius: 8,
|
||||
border: 'none', background: 'transparent',
|
||||
color: active ? '#1d9bf0' : '#8b8b8b',
|
||||
fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||||
position: 'relative',
|
||||
borderBottom: active ? '2px solid #1d9bf0' : '2px solid transparent',
|
||||
marginBottom: -2,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{badge !== undefined && badge > 0 && (
|
||||
<span style={{
|
||||
marginLeft: 6, padding: '0 6px', height: 16,
|
||||
borderRadius: 8, background: '#1d9bf0', color: '#fff',
|
||||
fontSize: 10, fontWeight: 700,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>{badge > 99 ? '99+' : badge}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ c, active, onClick }: {
|
||||
c: Contact; active: boolean; onClick: () => void;
|
||||
}) {
|
||||
|
||||
323
desktop/src/sections/contacts/NewContactModal.tsx
Normal file
323
desktop/src/sections/contacts/NewContactModal.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
// NewContactModal — send an on-chain CONTACT_REQUEST to a new peer.
|
||||
//
|
||||
// Flow:
|
||||
// 1. Enter @username / DC / hex → resolve into an Ed25519 pub.
|
||||
// 2. Optional intro + fee-tier pick (5k / 10k / 50k µT).
|
||||
// 3. Submit CONTACT_REQUEST tx with amount = contactFee.
|
||||
// The peer sees the request in their Contacts → Requests tab and can
|
||||
// Accept / Reject. After acceptance an encrypted chat becomes possible
|
||||
// via the existing /relay/broadcast pipeline.
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import {
|
||||
resolveAccount, getIdentity, getBalance,
|
||||
type IdentityInfo,
|
||||
} from '@/lib/api';
|
||||
import { buildContactRequestTx, submitTx, humanizeTxError } from '@/lib/tx';
|
||||
import { shortAddr } from '@/lib/crypto';
|
||||
|
||||
const FEE_TIERS = [
|
||||
{ value: 5_000, label: 'Min', hint: 'enough for a low-spam node' },
|
||||
{ value: 10_000, label: 'Standard', hint: 'default' },
|
||||
{ value: 50_000, label: 'Priority', hint: 'more attention-grabbing' },
|
||||
];
|
||||
const MIN_NETWORK_FEE = 1_000;
|
||||
|
||||
export function NewContactModal({ onClose, onSent }: {
|
||||
onClose: () => void;
|
||||
onSent: () => void;
|
||||
}): React.ReactElement {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [resolved, setResolved] = useState<{
|
||||
pub: string; identity: IdentityInfo | null;
|
||||
} | null>(null);
|
||||
const [intro, setIntro] = useState('');
|
||||
const [fee, setFee] = useState<number>(FEE_TIERS[1].value);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [balance, setBalance] = useState<number | null>(null);
|
||||
|
||||
const totalCost = fee + MIN_NETWORK_FEE;
|
||||
const insufficient = balance !== null && balance < totalCost;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!keyFile) return;
|
||||
getBalance(keyFile.pub_key).then(setBalance).catch(() => setBalance(null));
|
||||
}, [keyFile]);
|
||||
|
||||
const search = async () => {
|
||||
const q = query.trim();
|
||||
if (!q) return;
|
||||
setSearching(true); setErr(null); setResolved(null);
|
||||
try {
|
||||
const pub = await resolveAccount(q);
|
||||
if (!pub) { setErr(`Couldn't resolve "${q}"`); return; }
|
||||
if (keyFile && pub.toLowerCase() === keyFile.pub_key.toLowerCase()) {
|
||||
setErr('That\'s you — open Saved Messages in the chat list instead.');
|
||||
return;
|
||||
}
|
||||
const id = await getIdentity(pub);
|
||||
setResolved({ pub, identity: id });
|
||||
} catch (e) {
|
||||
setErr(String(e));
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
if (!keyFile || !resolved || sending) return;
|
||||
setSending(true); setErr(null);
|
||||
try {
|
||||
const tx = buildContactRequestTx({
|
||||
from: keyFile.pub_key,
|
||||
to: resolved.pub,
|
||||
contactFee: fee,
|
||||
intro: intro.trim() || undefined,
|
||||
privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(tx);
|
||||
onSent();
|
||||
} catch (e) {
|
||||
setErr(humanizeTxError(e));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const peerName = useMemo(() => {
|
||||
if (!resolved) return '';
|
||||
if (resolved.identity?.nickname) return `@${resolved.identity.nickname}`;
|
||||
return shortAddr(resolved.pub, 8);
|
||||
}, [resolved]);
|
||||
|
||||
return (
|
||||
<Backdrop onClose={sending ? () => {} : onClose}>
|
||||
<div style={{
|
||||
width: '100%', maxWidth: 480, padding: 20, borderRadius: 16,
|
||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||
}}>
|
||||
<Header title="Send contact request" onClose={onClose} busy={sending} />
|
||||
|
||||
{/* Search */}
|
||||
<Label>Who</Label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') search(); }}
|
||||
placeholder="@username, DC-address, or hex pub"
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
style={{
|
||||
flex: 1, background: '#000', border: '1px solid #1f1f1f',
|
||||
borderRadius: 8, padding: '10px 12px',
|
||||
color: '#fff', fontSize: 13, fontFamily: 'monospace',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={search}
|
||||
disabled={searching || query.trim().length === 0}
|
||||
style={{
|
||||
padding: '9px 14px', borderRadius: 8, border: 'none',
|
||||
background: '#1d9bf0', color: '#fff',
|
||||
fontSize: 13, fontWeight: 700,
|
||||
cursor: searching ? 'default' : 'pointer',
|
||||
opacity: searching || query.trim().length === 0 ? 0.5 : 1,
|
||||
}}
|
||||
>{searching ? '…' : 'Find'}</button>
|
||||
</div>
|
||||
|
||||
{/* Resolved peer preview */}
|
||||
{resolved && (
|
||||
<div style={{
|
||||
marginTop: 12, padding: 12, borderRadius: 10,
|
||||
background: '#000', border: '1px solid #1f1f1f',
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 18, background: '#1a1a1a',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#d0d0d0', fontWeight: 700,
|
||||
}}>{peerName.replace(/^@/, '').charAt(0).toUpperCase()}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: '#fff', fontSize: 13, fontWeight: 700 }}>
|
||||
{peerName}
|
||||
</div>
|
||||
<div style={{
|
||||
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
|
||||
wordBreak: 'break-all',
|
||||
}}>
|
||||
{resolved.pub}
|
||||
</div>
|
||||
<div style={{
|
||||
color: resolved.identity?.x25519_pub ? '#3ba55d' : '#f0b35a',
|
||||
fontSize: 11, marginTop: 3,
|
||||
}}>
|
||||
{resolved.identity?.x25519_pub
|
||||
? '✓ has encryption key published'
|
||||
: '⚠ no encryption key on chain yet (messaging disabled until they register)'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Intro */}
|
||||
{resolved && (
|
||||
<>
|
||||
<Label style={{ marginTop: 14 }}>Intro (optional)</Label>
|
||||
<textarea
|
||||
value={intro}
|
||||
onChange={e => setIntro(e.target.value)}
|
||||
placeholder="Hey — we met at …"
|
||||
rows={2}
|
||||
maxLength={280}
|
||||
style={{
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
background: '#000', border: '1px solid #1f1f1f',
|
||||
borderRadius: 8, padding: '10px 12px',
|
||||
color: '#fff', fontSize: 13, fontFamily: 'inherit',
|
||||
outline: 'none', resize: 'vertical',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Fee tiers */}
|
||||
{resolved && (
|
||||
<>
|
||||
<Label style={{ marginTop: 14 }}>Anti-spam fee (paid to recipient)</Label>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{FEE_TIERS.map(t => (
|
||||
<button
|
||||
key={t.value}
|
||||
onClick={() => setFee(t.value)}
|
||||
style={{
|
||||
flex: 1, minWidth: 120,
|
||||
padding: '10px 12px', borderRadius: 10, cursor: 'pointer',
|
||||
background: fee === t.value ? '#0a1a29' : '#000',
|
||||
border: fee === t.value ? '1px solid #1d9bf0' : '1px solid #1f1f1f',
|
||||
color: '#fff', textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: 12, fontWeight: 700,
|
||||
color: fee === t.value ? '#1d9bf0' : '#fff',
|
||||
}}>{t.label}</div>
|
||||
<div style={{ fontSize: 11, color: '#8b8b8b', marginTop: 2 }}>
|
||||
{(t.value / 1_000_000).toFixed(3)} T · {t.hint}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Summary + actions */}
|
||||
{resolved && (
|
||||
<div style={{
|
||||
marginTop: 14, color: '#8b8b8b', fontSize: 11, lineHeight: 1.5,
|
||||
}}>
|
||||
Cost: <span style={{ color: '#fff' }}>
|
||||
{(totalCost / 1_000_000).toFixed(3)} T
|
||||
</span> ({(fee / 1_000_000).toFixed(3)} to recipient · {(MIN_NETWORK_FEE / 1_000_000).toFixed(3)} network fee)
|
||||
{balance !== null && (
|
||||
<> · Balance: <span style={{
|
||||
color: insufficient ? '#f4212e' : '#fff',
|
||||
}}>{(balance / 1_000_000).toFixed(3)} T</span></>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{err && (
|
||||
<div style={{
|
||||
marginTop: 12, padding: 10, borderRadius: 8,
|
||||
background: '#2a1414', color: '#ff9b9b', fontSize: 12,
|
||||
}}>{err}</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
marginTop: 16, display: 'flex', justifyContent: 'flex-end', gap: 10,
|
||||
}}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={sending}
|
||||
style={{
|
||||
padding: '9px 14px', borderRadius: 999,
|
||||
background: 'transparent', border: '1px solid #1f1f1f',
|
||||
color: '#8b8b8b', fontSize: 13, fontWeight: 700,
|
||||
cursor: sending ? 'default' : 'pointer',
|
||||
}}
|
||||
>Cancel</button>
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={!resolved || insufficient || sending}
|
||||
style={{
|
||||
padding: '9px 18px', borderRadius: 999, border: 'none',
|
||||
background: '#1d9bf0', color: '#fff',
|
||||
fontSize: 13, fontWeight: 700,
|
||||
cursor: (!resolved || insufficient || sending) ? 'default' : 'pointer',
|
||||
opacity: (!resolved || insufficient || sending) ? 0.5 : 1,
|
||||
}}
|
||||
>{sending ? '…' : 'Send request'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Backdrop>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── small shared primitives (private to this file — Contacts is the only caller)
|
||||
|
||||
function Backdrop({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 20,
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<div onClick={e => e.stopPropagation()} style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({ title, onClose, busy }: {
|
||||
title: string; onClose: () => void; busy: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
marginBottom: 14,
|
||||
}}>
|
||||
<div style={{ color: '#fff', fontSize: 16, fontWeight: 700 }}>{title}</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
style={{
|
||||
background: 'transparent', border: 'none',
|
||||
color: '#8b8b8b', fontSize: 20, cursor: 'pointer',
|
||||
}}
|
||||
>×</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Label({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) {
|
||||
return (
|
||||
<div style={{
|
||||
color: '#8b8b8b', fontSize: 11, fontWeight: 700,
|
||||
letterSpacing: 1, textTransform: 'uppercase', marginBottom: 6,
|
||||
...style,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
203
desktop/src/sections/contacts/RequestsList.tsx
Normal file
203
desktop/src/sections/contacts/RequestsList.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
// RequestsList — pending contact requests inbox.
|
||||
//
|
||||
// Each row shows the requester (identity if known + DC address + fee paid)
|
||||
// and their intro message. Accept publishes ACCEPT_CONTACT on-chain,
|
||||
// adds the peer to the local contacts store, and optimistically drops
|
||||
// the row. Reject (Block) publishes BLOCK_CONTACT; subsequent requests
|
||||
// from the same sender are refused by the node.
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import {
|
||||
buildAcceptContactTx, buildBlockContactTx, buildLinkDeviceTx,
|
||||
submitTx, humanizeTxError,
|
||||
} from '@/lib/tx';
|
||||
import { upsertContact as persistContact, markDeviceRegistered, isDeviceRegistered } from '@/lib/storage';
|
||||
import { getIdentity, fetchDevices, type ContactRequestRaw } from '@/lib/api';
|
||||
import { shortAddr } from '@/lib/crypto';
|
||||
|
||||
export function RequestsList({
|
||||
requests, onChanged,
|
||||
}: {
|
||||
requests: ContactRequestRaw[];
|
||||
onChanged: () => void;
|
||||
}): React.ReactElement {
|
||||
if (requests.length === 0) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: 32, color: '#6a6a6a', fontSize: 13, textAlign: 'center',
|
||||
}}>
|
||||
No pending requests. Inbound CONTACT_REQUEST txs will show up here
|
||||
for you to accept or block.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{requests.map(r => (
|
||||
<RequestRow key={r.tx_id} req={r} onChanged={onChanged} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestRow({
|
||||
req, onChanged,
|
||||
}: { req: ContactRequestRaw; onChanged: () => void }) {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const upsertContact = useStore(s => s.upsertContact);
|
||||
const setSection = useStore(s => s.setSection);
|
||||
const setActiveChat = useStore(s => s.setActiveChat);
|
||||
|
||||
const [busy, setBusy] = useState<'accept' | 'block' | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const act = async (kind: 'accept' | 'block') => {
|
||||
if (!keyFile) return;
|
||||
setBusy(kind); setErr(null);
|
||||
try {
|
||||
if (kind === 'accept') {
|
||||
// Need the requester's X25519 so a local contact is created
|
||||
// with encryption enabled out of the gate — without it the
|
||||
// first outgoing message would surface "no key" until we
|
||||
// refetched via resolveRecipientKeys.
|
||||
const identity = await getIdentity(req.requester_pub);
|
||||
const tx = buildAcceptContactTx({
|
||||
from: keyFile.pub_key,
|
||||
to: req.requester_pub,
|
||||
privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(tx);
|
||||
|
||||
// Make sure OUR device is published on-chain too. The
|
||||
// useDeviceBootstrap effect tries this on sign-in, but if the
|
||||
// user had zero balance then the tx bounced; now that the
|
||||
// incoming CONTACT_REQUEST has paid us the contact fee, we
|
||||
// have the µT needed. Without this, the peer couldn't encrypt
|
||||
// to us — they'd see "recipient has no encryption key" even
|
||||
// though we just accepted.
|
||||
try {
|
||||
const ownDevices = await fetchDevices(keyFile.pub_key);
|
||||
const alreadyLinked = ownDevices.some(d => d.x25519_pub_key === keyFile.x25519_pub);
|
||||
if (!alreadyLinked && !isDeviceRegistered()) {
|
||||
const platform = await window.dchain.app.platform().catch(() => 'unknown');
|
||||
const deviceName = platform === 'darwin' ? 'Mac'
|
||||
: platform === 'win32' ? 'Windows'
|
||||
: platform === 'linux' ? 'Linux'
|
||||
: 'Desktop';
|
||||
const linkTx = buildLinkDeviceTx({
|
||||
from: keyFile.pub_key,
|
||||
x25519Pub: keyFile.x25519_pub,
|
||||
deviceName,
|
||||
privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(linkTx);
|
||||
markDeviceRegistered();
|
||||
}
|
||||
} catch { /* best-effort — next sign-in retries */ }
|
||||
|
||||
const c = {
|
||||
address: req.requester_pub,
|
||||
x25519Pub: identity?.x25519_pub ?? '',
|
||||
username: identity?.nickname || undefined,
|
||||
alias: undefined,
|
||||
addedAt: Date.now(),
|
||||
};
|
||||
upsertContact(c);
|
||||
persistContact(c);
|
||||
// Jump the user straight into the new chat — mirrors mobile's
|
||||
// router.replace(/chats/<pub>) after accept.
|
||||
setActiveChat(req.requester_pub);
|
||||
setSection('messages');
|
||||
} else {
|
||||
const tx = buildBlockContactTx({
|
||||
from: keyFile.pub_key,
|
||||
to: req.requester_pub,
|
||||
privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(tx);
|
||||
}
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setErr(humanizeTxError(e));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: 14, borderBottom: '1px solid #1f1f1f',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 18, background: '#1a1a1a',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#d0d0d0', fontWeight: 700,
|
||||
}}>{shortAddr(req.requester_pub, 1).charAt(0).toUpperCase()}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
color: '#fff', fontSize: 13, fontWeight: 700,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{shortAddr(req.requester_pub, 8)}
|
||||
</div>
|
||||
<div style={{ color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace' }}>
|
||||
{req.requester_addr}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
color: '#f0b35a', fontSize: 11, fontWeight: 700,
|
||||
}}>
|
||||
+{(req.fee_ut / 1_000_000).toFixed(3)} T
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{req.intro && (
|
||||
<div className="selectable" style={{
|
||||
padding: 10, borderRadius: 8,
|
||||
background: '#000', border: '1px solid #1f1f1f',
|
||||
color: '#e0e0e0', fontSize: 12, lineHeight: 1.5,
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
{req.intro}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => act('block')}
|
||||
disabled={!!busy}
|
||||
style={{
|
||||
padding: '7px 12px', borderRadius: 999,
|
||||
background: 'transparent', border: '1px solid #3a2020',
|
||||
color: '#ff6b6b', fontSize: 12, fontWeight: 700,
|
||||
cursor: busy ? 'default' : 'pointer',
|
||||
opacity: busy ? 0.5 : 1,
|
||||
}}
|
||||
>{busy === 'block' ? '…' : 'Block'}</button>
|
||||
<button
|
||||
onClick={() => act('accept')}
|
||||
disabled={!!busy}
|
||||
style={{
|
||||
padding: '7px 14px', borderRadius: 999,
|
||||
border: 'none', background: '#1d9bf0', color: '#fff',
|
||||
fontSize: 12, fontWeight: 700,
|
||||
cursor: busy ? 'default' : 'pointer',
|
||||
opacity: busy ? 0.5 : 1,
|
||||
}}
|
||||
>{busy === 'accept' ? '…' : 'Accept'}</button>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div style={{
|
||||
marginTop: 8, padding: 8, borderRadius: 6,
|
||||
background: '#2a1414', color: '#ff9b9b', fontSize: 11,
|
||||
}}>{err}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,10 +16,18 @@ import { sendEnvelope, resolveRecipientKeys } from '@/lib/relay';
|
||||
import { appendMessage as persist } from '@/lib/storage';
|
||||
import type { Message } from '@/lib/types';
|
||||
|
||||
// A module-level stable reference for "no messages yet". Without this the
|
||||
// selector `s.messages[address] ?? []` allocates a fresh empty array on
|
||||
// every render when the conversation has no cached entries, which zustand
|
||||
// sees as a changed value → triggers another render → new empty array
|
||||
// again → "Maximum update depth exceeded". Returning the exact same
|
||||
// reference every time breaks the cycle.
|
||||
const EMPTY_MESSAGES: Message[] = [];
|
||||
|
||||
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 messages = useStore(s => s.messages[address] ?? EMPTY_MESSAGES);
|
||||
const clearUnread = useStore(s => s.clearUnread);
|
||||
const appendMsg = useStore(s => s.appendMessage);
|
||||
|
||||
@@ -57,7 +65,15 @@ export function Conversation({ address }: { address: string }): React.ReactEleme
|
||||
if (!isSelf) {
|
||||
const pubs = await resolveRecipientKeys(address);
|
||||
if (pubs.length === 0) {
|
||||
throw new Error('recipient has no encryption key published');
|
||||
// Most common cause: the peer's device hasn't published a
|
||||
// LINK_DEVICE yet (they accepted just now and haven't had the
|
||||
// fee debited, or they haven't re-opened the app). Clearer
|
||||
// copy than "recipient has no encryption key".
|
||||
throw new Error(
|
||||
'Recipient has no device key published on-chain yet. ' +
|
||||
'Ask them to re-open their app so the LINK_DEVICE tx commits, ' +
|
||||
'then try again.',
|
||||
);
|
||||
}
|
||||
await Promise.all(pubs.map(async (rpub) => {
|
||||
const { nonce, ciphertext } = encryptMessage(
|
||||
@@ -98,12 +114,13 @@ export function Conversation({ address }: { address: string }): React.ReactEleme
|
||||
}
|
||||
};
|
||||
|
||||
const name = contact?.username ? `@${contact.username}`
|
||||
const name = (contact?.username ? `@${contact.username}`
|
||||
: contact?.alias
|
||||
? contact.alias
|
||||
: isSelf
|
||||
? 'Saved Messages'
|
||||
: shortAddr(address, 8);
|
||||
: shortAddr(address || '', 8)) || shortAddr(address || '', 8);
|
||||
const firstLetter = (name || '?').replace(/^@/, '').charAt(0).toUpperCase() || '?';
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
@@ -118,12 +135,18 @@ export function Conversation({ address }: { address: string }): React.ReactEleme
|
||||
color: '#fff', fontWeight: 700, fontSize: 14,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{isSelf ? '★' : name.replace(/^@/, '').charAt(0).toUpperCase()}
|
||||
{isSelf ? '★' : firstLetter}
|
||||
</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 style={{
|
||||
color: '#fff', fontSize: 14, fontWeight: 700,
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}>{name}</div>
|
||||
<div style={{
|
||||
color: '#6a6a6a', fontSize: 11, fontFamily: 'monospace',
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{shortAddr(address || '', 6)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
62
desktop/src/shell/PaneBoundary.tsx
Normal file
62
desktop/src/shell/PaneBoundary.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
// PaneBoundary — ErrorBoundary scoped to one Shell pane. A crash in
|
||||
// the Conversation component shouldn't black-out the whole window; it
|
||||
// should leave NavBar + List + StatusBar usable so the operator can
|
||||
// switch sections and report the bug. Resets when the keyed section
|
||||
// changes.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
/** Used as React key at the callsite; also shown in the panic copy. */
|
||||
sectionName: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class PaneBoundary extends React.Component<Props, State> {
|
||||
state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo): void {
|
||||
console.error(`[PaneBoundary:${this.props.sectionName}]`, error, info);
|
||||
}
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (!this.state.error) return this.props.children;
|
||||
return (
|
||||
<div style={{
|
||||
padding: 20, height: '100%', overflow: 'auto',
|
||||
background: '#000', color: '#fff', fontFamily: 'monospace',
|
||||
}}>
|
||||
<div style={{
|
||||
color: '#ff6b6b', fontSize: 14, fontWeight: 700, marginBottom: 8,
|
||||
}}>
|
||||
{this.props.sectionName} crashed
|
||||
</div>
|
||||
<div style={{ color: '#fff', fontSize: 13, marginBottom: 8 }}>
|
||||
{this.state.error.message}
|
||||
</div>
|
||||
<pre style={{
|
||||
color: '#8b8b8b', fontSize: 11, lineHeight: 1.4,
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||
}}>
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
<button
|
||||
onClick={() => this.setState({ error: null })}
|
||||
style={{
|
||||
marginTop: 10, padding: '6px 12px', borderRadius: 999,
|
||||
border: '1px solid #1f1f1f', background: '#111',
|
||||
color: '#fff', fontSize: 12, cursor: 'pointer',
|
||||
}}
|
||||
>Retry</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ import { useGlobalKeybinds } from '@/hooks/useGlobalKeybinds';
|
||||
import { TitleBar } from './TitleBar';
|
||||
import { NavBar } from './NavBar';
|
||||
import { StatusBar } from './StatusBar';
|
||||
import { UpdateBanner } from './UpdateBanner';
|
||||
import { PaneBoundary } from './PaneBoundary';
|
||||
import { MessagesList, MessagesDetail } from '@/sections/messages';
|
||||
import { FeedList, FeedDetail } from '@/sections/feed';
|
||||
import { WalletList, WalletDetail } from '@/sections/wallet';
|
||||
@@ -50,12 +52,17 @@ export function Shell(): React.ReactElement {
|
||||
borderRight: '1px solid #1f1f1f',
|
||||
overflowY: 'auto',
|
||||
}}>
|
||||
<List />
|
||||
<PaneBoundary key={`${section}-list`} sectionName={`${section} / list`}>
|
||||
<List />
|
||||
</PaneBoundary>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
||||
<Detail />
|
||||
<PaneBoundary key={`${section}-detail`} sectionName={`${section} / detail`}>
|
||||
<Detail />
|
||||
</PaneBoundary>
|
||||
</div>
|
||||
</div>
|
||||
<UpdateBanner />
|
||||
<StatusBar />
|
||||
</div>
|
||||
);
|
||||
|
||||
56
desktop/src/shell/UpdateBanner.tsx
Normal file
56
desktop/src/shell/UpdateBanner.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
// UpdateBanner — appears just above the status bar when a newer release
|
||||
// tag is available on Gitea. Single action: open the release page in
|
||||
// the default browser. We deliberately don't auto-download — the user
|
||||
// probably wants to read the changelog first, and the binary hosting
|
||||
// story is still "Gitea release assets" rather than a signed feed.
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useUpdateCheck } from '@/hooks/useUpdateCheck';
|
||||
|
||||
export function UpdateBanner(): React.ReactElement | null {
|
||||
const info = useUpdateCheck();
|
||||
const [dismissed, setDismissed] = useState<string | null>(null);
|
||||
|
||||
if (!info) return null;
|
||||
if (dismissed === info.latestTag) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '8px 16px',
|
||||
background: '#0d2540',
|
||||
borderTop: '1px solid #1d9bf022',
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
}}>
|
||||
<span style={{ color: '#1d9bf0', fontSize: 16 }}>↗</span>
|
||||
<span style={{ flex: 1 }}>
|
||||
Update available: <b>{info.latestTag}</b>
|
||||
{info.publishedAt && (
|
||||
<span style={{ color: '#8b8b8b', marginLeft: 8 }}>
|
||||
published {new Date(info.publishedAt).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<a
|
||||
href={info.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{
|
||||
padding: '5px 12px', borderRadius: 999,
|
||||
background: '#1d9bf0', color: '#fff',
|
||||
fontSize: 11, fontWeight: 700,
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>Download</a>
|
||||
<button
|
||||
onClick={() => setDismissed(info.latestTag)}
|
||||
style={{
|
||||
background: 'transparent', border: 'none',
|
||||
color: '#8b8b8b', fontSize: 16, cursor: 'pointer',
|
||||
padding: 0, lineHeight: 1,
|
||||
}}
|
||||
>×</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -198,10 +198,15 @@ desktop/
|
||||
Settings → Devices (list + unlink + link-new-device modal with the
|
||||
same protocol as mobile), expanded Profile, QR in Receive, global
|
||||
keybinds (Ctrl+W close chat / Ctrl+K jump to Contacts / Ctrl+, Settings).
|
||||
- [ ] **v2.2.0** — Auto-update through the same `/api/update-check`
|
||||
pipeline nodes use; `electron-builder` → `.dmg`, `.exe`,
|
||||
`.AppImage`, `.deb`; optional: attachments in Compose
|
||||
(file picker + client-side image resize + scrub).
|
||||
- [x] **v2.2.0** — Contact-request flow (New contact modal + Requests
|
||||
inbox tab with Accept/Block), auto-update banner that polls
|
||||
`/api/update-check` and offers the latest Gitea release,
|
||||
electron-builder config ready for `.dmg` / `.exe` / `.AppImage` /
|
||||
`.deb` + NSIS installer + macOS hardenedRuntime.
|
||||
- [ ] **post-v2.2.0** — Attachments in Compose (file picker +
|
||||
client-side image resize + metadata scrub), code signing
|
||||
certificates, draft group chats (multi-recipient envelopes or
|
||||
MLS integration).
|
||||
|
||||
### Открытые вопросы (desktop)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user