feat(desktop): Contacts + Settings→Devices + expanded Profile + QR + keybinds (v2.2.0-rc1)
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.
This commit is contained in:
200
desktop/src/sections/contacts/ContactsDetail.tsx
Normal file
200
desktop/src/sections/contacts/ContactsDetail.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user