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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user