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:
168
desktop/src/sections/contacts/RequestsList.tsx
Normal file
168
desktop/src/sections/contacts/RequestsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user