Completes the desktop feature surface ahead of the v2.2.0 tag. Only
auto-update + packaging remain.
Settings — now two-paned (nav on the left, pages on the right):
* NodePage — URL ping-on-commit + API token field.
* IdentityPage — pub key / X25519 pub, Export (safe-save dialog) /
Import (open dialog + wipe + replace) / Delete identity.
* DevicesPage — full multi-device UI: list every active device with
a THIS DEVICE badge; Unlink button on every other row submits
UNLINK_DEVICE + optimistic local remove; Link new device modal
takes {code, device key, name}, submits LINK_DEVICE, then ships
the handshake envelope (master Ed25519 priv encrypted for the
new X25519) — same protocol as mobile's primary-device modal.
* AboutPage — version, platform, Gitea links.
* store.settingsPage discriminated union keeps selection across
section switches.
Contacts section (now real):
* ContactsList — alphabetical, filter-as-you-type; each row shows
avatar letter + name + short address.
* ContactsDetail — profile card (username/alias/pub) + Open chat /
View posts / Copy address actions + stats grid
(Balance, Devices, Encryption, Added) + Identity card with
DC address, username, published X25519, device_count.
* store.selectedContact persists across navigation.
Profile section (expanded):
* ProfileList — big avatar + pub key + contacts count.
* ProfileDetail — balance hero, quick actions (My posts →
feed author wall, Manage devices → Settings→Devices, Copy
address), Identity card, inline Linked devices list with a
THIS DEVICE badge matching the Settings page.
Receive modal — canvas QR via `qrcode` (new dep, ~5 KB gzipped),
white-on-transparent so it sits inside the same black modal chrome.
Global keybinds (useGlobalKeybinds hook mounted in Shell):
* Ctrl/Cmd+W — close the current conversation (drops activeChat,
keeps section). Does NOT close the window.
* Ctrl/Cmd+K — jump to Contacts.
* Ctrl/Cmd+, — Settings.
Each guards against being in a text field so typing `k,` in a
composer / search doesn't hijack.
docs/ROADMAP.md — rc1 row flipped to done; v2.2.0 narrows to
auto-update + packaging + optional attachments in Compose.
201 lines
6.8 KiB
TypeScript
201 lines
6.8 KiB
TypeScript
// Right-pane for Contacts — profile card for the selected contact.
|
|
// Shows identity, balance, device count, linked action buttons:
|
|
// Open chat (switch to Messages section), Transfer, View posts (switch
|
|
// to Feed author wall), Block (local only for now).
|
|
|
|
import React, { useEffect, useState } from 'react';
|
|
import { useStore } from '@/lib/store';
|
|
import { getIdentity, fetchDevices, getBalance } from '@/lib/api';
|
|
import { shortAddr } from '@/lib/crypto';
|
|
import type { IdentityInfo } from '@/lib/api';
|
|
|
|
function formatT(ut: number): string {
|
|
return (ut / 1_000_000).toLocaleString(undefined, { maximumFractionDigits: 3 });
|
|
}
|
|
|
|
export function ContactsDetail(): React.ReactElement {
|
|
const sel = useStore(s => s.selectedContact);
|
|
const contact = useStore(s => s.contacts.find(c => c.address === sel));
|
|
const setSection = useStore(s => s.setSection);
|
|
const setActive = useStore(s => s.setActiveChat);
|
|
const setFeedTab = useStore(s => s.setFeedTab);
|
|
|
|
const [identity, setIdentity] = useState<IdentityInfo | null>(null);
|
|
const [balance, setBalance] = useState<number | null>(null);
|
|
const [deviceCount, setDeviceCount] = useState<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!sel) return;
|
|
let cancelled = false;
|
|
(async () => {
|
|
const [id, bal, devs] = await Promise.all([
|
|
getIdentity(sel),
|
|
getBalance(sel),
|
|
fetchDevices(sel),
|
|
]);
|
|
if (cancelled) return;
|
|
setIdentity(id);
|
|
setBalance(bal);
|
|
setDeviceCount(devs.length);
|
|
})();
|
|
return () => { cancelled = true; };
|
|
}, [sel]);
|
|
|
|
if (!sel || !contact) {
|
|
return (
|
|
<div style={{
|
|
height: '100%', display: 'flex',
|
|
alignItems: 'center', justifyContent: 'center',
|
|
color: '#6a6a6a', fontSize: 13, padding: 40, textAlign: 'center',
|
|
}}>
|
|
Pick a contact on the left to view their profile.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const displayName = contact.username
|
|
? `@${contact.username}`
|
|
: (identity?.nickname ? `@${identity.nickname}` : (contact.alias ?? shortAddr(contact.address, 8)));
|
|
|
|
const openChat = () => {
|
|
setActive(contact.address);
|
|
setSection('messages');
|
|
};
|
|
const viewPosts = () => {
|
|
setFeedTab({ kind: 'author', pub: contact.address });
|
|
setSection('feed');
|
|
};
|
|
const copy = (s: string) => navigator.clipboard.writeText(s).catch(() => {});
|
|
|
|
return (
|
|
<div style={{
|
|
height: '100%', overflowY: 'auto',
|
|
padding: '22px 26px', background: '#000',
|
|
}}>
|
|
{/* Header card */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
|
<div style={{
|
|
width: 64, height: 64, borderRadius: 32,
|
|
background: '#1a1a1a', color: '#d0d0d0',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontSize: 28, fontWeight: 700,
|
|
}}>{displayName.replace(/^@/, '').charAt(0).toUpperCase()}</div>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ color: '#fff', fontSize: 22, fontWeight: 800 }}>
|
|
{displayName}
|
|
</div>
|
|
<div className="selectable" style={{
|
|
color: '#8b8b8b', fontSize: 12, fontFamily: 'monospace',
|
|
marginTop: 4, wordBreak: 'break-all',
|
|
}}>{contact.address}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div style={{ display: 'flex', gap: 10, marginTop: 16, flexWrap: 'wrap' }}>
|
|
<Btn primary onClick={openChat}>Open chat</Btn>
|
|
<Btn onClick={viewPosts}>View posts</Btn>
|
|
<Btn onClick={() => copy(contact.address)}>Copy address</Btn>
|
|
</div>
|
|
|
|
{/* Stats grid */}
|
|
<div style={{
|
|
marginTop: 22, display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 10,
|
|
}}>
|
|
<Stat label="Balance" value={balance === null ? '…' : `${formatT(balance)} T`} />
|
|
<Stat label="Devices" value={deviceCount === null ? '…' : String(deviceCount)} />
|
|
<Stat label="Encryption" value={contact.x25519Pub ? 'E2E (NaCl)' : 'no key'} />
|
|
<Stat label="Added" value={new Date(contact.addedAt).toLocaleDateString()} />
|
|
</div>
|
|
|
|
{/* Identity details */}
|
|
{identity && (
|
|
<div style={{
|
|
marginTop: 22, padding: 14, borderRadius: 12,
|
|
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
|
}}>
|
|
<div style={{
|
|
color: '#8b8b8b', fontSize: 11, fontWeight: 700,
|
|
letterSpacing: 1, textTransform: 'uppercase', marginBottom: 8,
|
|
}}>Identity</div>
|
|
<Row k="DC address" v={identity.address} copyable onCopy={() => copy(identity.address)} />
|
|
{identity.nickname && <Row k="Username" v={`@${identity.nickname}`} />}
|
|
{identity.x25519_pub && (
|
|
<Row
|
|
k="Published X25519"
|
|
v={shortAddr(identity.x25519_pub, 8)}
|
|
copyable
|
|
onCopy={() => copy(identity.x25519_pub)}
|
|
/>
|
|
)}
|
|
{typeof identity.device_count === 'number' && (
|
|
<Row k="Device count" v={String(identity.device_count)} />
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Btn({ children, onClick, primary }: {
|
|
children: React.ReactNode; onClick: () => void; primary?: boolean;
|
|
}) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
style={{
|
|
padding: '9px 16px', borderRadius: 999,
|
|
background: primary ? '#1d9bf0' : 'transparent',
|
|
border: primary ? 'none' : '1px solid #1f1f1f',
|
|
color: '#fff', fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
|
}}
|
|
>{children}</button>
|
|
);
|
|
}
|
|
|
|
function Stat({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div style={{
|
|
padding: 12, borderRadius: 12,
|
|
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
|
}}>
|
|
<div style={{
|
|
color: '#8b8b8b', fontSize: 11, fontWeight: 700,
|
|
letterSpacing: 1, textTransform: 'uppercase',
|
|
}}>{label}</div>
|
|
<div style={{ color: '#fff', fontSize: 15, fontWeight: 700, marginTop: 4 }}>
|
|
{value}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Row({
|
|
k, v, copyable, onCopy,
|
|
}: { k: string; v: string; copyable?: boolean; onCopy?: () => void }) {
|
|
return (
|
|
<div style={{
|
|
display: 'flex', padding: '6px 0',
|
|
borderBottom: '1px solid #141414',
|
|
alignItems: 'center', gap: 10,
|
|
}}>
|
|
<div style={{ color: '#8b8b8b', fontSize: 12, flex: '0 0 140px' }}>{k}</div>
|
|
<div className="selectable" style={{
|
|
color: '#fff', fontSize: 13, fontFamily: 'monospace',
|
|
flex: 1, wordBreak: 'break-all',
|
|
}}>{v}</div>
|
|
{copyable && (
|
|
<button
|
|
onClick={onCopy}
|
|
style={{
|
|
background: 'transparent', border: 'none',
|
|
color: '#1d9bf0', fontSize: 11, fontWeight: 600,
|
|
cursor: 'pointer',
|
|
}}
|
|
>copy</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|