feat(desktop): contact requests, auto-update banner, packaging polish (v2.2.0)

Closes the v2.2.0 roadmap. Desktop client is feature-complete and
ready for first installer builds.

Contact request flow (fills a real gap flagged by the user):
  * lib/tx.ts grows buildContactRequestTx / buildAcceptContactTx /
    buildBlockContactTx with canonical bytes matching mobile.
  * lib/api.ts: fetchContactRequests + ContactRequestRaw.
  * New contact modal — sections/contacts/NewContactModal.tsx — resolves
    @username / DC-address / hex pub via resolveAccount, shows identity
    preview (incl. "has encryption key / key not published" hint),
    fee tier picker (5k / 10k / 50k µT), optional 280-char intro,
    balance guard.
  * Requests inbox — sections/contacts/RequestsList.tsx — polled every
    15 s via /relay/contacts, filters pending, Accept submits
    ACCEPT_CONTACT + adds the peer to local contacts with their
    identity.x25519_pub pre-cached, Block submits BLOCK_CONTACT.
  * ContactsList grows a two-tab header (Contacts / Requests with a
    pending-count badge) + "+ New" button next to the filter input.

Auto-update:
  * hooks/useUpdateCheck.ts — polls /api/update-check on mount and
    every 6 hours; loose semver compares the Gitea release tag
    against this build's app.version (from Electron IPC), ignores
    the node's own update_available flag (it compares vs. the node,
    not the desktop).
  * shell/UpdateBanner.tsx — thin strip above the status bar with
    the new tag, Download button (opens the release URL in the
    default browser), and a dismiss-for-this-tag × so once-seen
    updates don't nag.

Packaging — electron-builder config tightened:
  * artifactName pattern includes version + os + arch.
  * Mac: hardenedRuntime on, dmg + zip outputs, social-networking
    category.
  * Windows: NSIS (full installer, per-user or per-machine) +
    portable exe.
  * Linux: AppImage + deb.
  * Strip source maps and test folders from the asar.
  * publish: null — no auto-publisher yet; Gitea releases are
    uploaded manually for now.
  * directories.output = release/, directories.buildResources =
    resources/ so icons land in a predictable place once we add them.

Version bumped to 2.2.0 in package.json. docs/ROADMAP.md marks
v2.2.0 row complete; remaining work (attachments, code signing,
group chats) moved to a post-v2.2.0 bucket.
This commit is contained in:
vsecoder
2026-04-22 18:47:19 +03:00
parent 96b347076e
commit 5aad9f780f
10 changed files with 892 additions and 35 deletions

View File

@@ -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
}
}

View 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 };
}

View File

@@ -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();

View File

@@ -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

View File

@@ -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;
}) {

View 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>
);
}

View File

@@ -0,0 +1,168 @@
// 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, submitTx, humanizeTxError,
} from '@/lib/tx';
import { upsertContact as persistContact } from '@/lib/storage';
import { getIdentity, 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 [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);
const c = {
address: req.requester_pub,
x25519Pub: identity?.x25519_pub ?? '',
username: identity?.nickname || undefined,
alias: undefined,
addedAt: Date.now(),
};
upsertContact(c);
persistContact(c);
} 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>
);
}

View File

@@ -23,6 +23,7 @@ import { useGlobalKeybinds } from '@/hooks/useGlobalKeybinds';
import { TitleBar } from './TitleBar';
import { NavBar } from './NavBar';
import { StatusBar } from './StatusBar';
import { UpdateBanner } from './UpdateBanner';
import { MessagesList, MessagesDetail } from '@/sections/messages';
import { FeedList, FeedDetail } from '@/sections/feed';
import { WalletList, WalletDetail } from '@/sections/wallet';
@@ -56,6 +57,7 @@ export function Shell(): React.ReactElement {
<Detail />
</div>
</div>
<UpdateBanner />
<StatusBar />
</div>
);

View 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>
);
}

View File

@@ -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)