Two bugs reported by the user:
1. After accepting a contact request on the desktop, the requester's
"Send message" call errored with "no encryption key published" for
the newly-accepted contact. Root cause: desktop never ran the
device-registry bootstrap (mobile does it from _layout.tsx on
sign-in) — so the desktop's X25519 pub was never published via
LINK_DEVICE, and resolveRecipientKeys returned an empty list.
2. On the accepting device, the new chat didn't appear in Messages
after tapping Accept — accept wrote the contact to store + disk
but didn't switch sections, so the user was stuck in Contacts
watching nothing happen.
Fixes:
* hooks/useDeviceBootstrap — direct port of mobile's _layout.tsx
bootstrap effect. On every sign-in:
- fetchDevices(master) → if our X25519 is listed, mark local
registered flag.
- not listed + was registered before → REVOKED → wipe state +
bounce to Welcome.
- not listed + never registered → submit LINK_DEVICE. Tx may
bounce if balance is zero; next launch retries.
Mounted from App.tsx so it runs once per authenticated session.
* RequestsList.accept — after submitting ACCEPT_CONTACT, check if
OUR X25519 is in the on-chain registry. If not, submit LINK_DEVICE
immediately (balance is now covered by the contact fee the peer
paid us). This closes the window where the peer couldn't encrypt
to us because our key wasn't published yet.
Also: after a successful accept, setSection('messages') +
setActiveChat(requester_pub), matching mobile's
router.replace('/chats/<pub>') flow.
* Conversation.send — nicer error copy when
resolveRecipientKeys returns []. Was: "recipient has no
encryption key published". Now: actionable text asking the peer
to re-open their app so the LINK_DEVICE tx commits.
204 lines
7.1 KiB
TypeScript
204 lines
7.1 KiB
TypeScript
// 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>
|
|
);
|
|
}
|