feat(uniweb): readable handle instead of endpoint id in messages

Resolve a message sender's endpoint id to a human handle using a new
control-plane directory endpoint.

- ControlPlane.fetchDirectory(): signed GET /api/directory, mapped to
  DirectoryEntry { signPub, endpoint, handle, role }. The server's endpoint
  matches endpointID(signPub) byte for byte.
- busService keeps an endpoint -> handle Map, loaded once after a session
  opens and refreshed after createRoom (where the ACL is already refreshed).
  Exposes a pure displayName(endpoint) resolver: handle when known, the
  session user's own handle for their messages, short id fallback otherwise.
- Resilience: loadDirectory never throws. A missing endpoint (404 on older
  clusters) or a transient error leaves the map empty and the UI falls back to
  the short id, so the chat keeps working exactly as before.
- ChatPanel renders displayName(msg.sender) in the message header and derives
  the avatar initials from the handle; the raw endpoint stays in a title
  tooltip for debugging.
- types: Message.sender comment updated (this is the "phase 2" readable name).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 15:27:15 +02:00
parent 103a7f2f05
commit 5e9bf4e777
4 changed files with 94 additions and 4 deletions
+11 -3
View File
@@ -33,15 +33,23 @@ function timeShort(ts: number) {
}
function MessageRow({ msg }: { msg: Message }) {
// Show the readable handle (resolved from the bus directory); the raw endpoint id
// stays in the title attribute as a debugging tooltip.
const name = bus.displayName(msg.sender);
return (
<Group align="flex-start" gap="sm" wrap="nowrap">
<Avatar radius="xl" size={36} color={msg.mine ? "brand" : "gray"}>
{initials(msg.sender)}
{initials(name)}
</Avatar>
<Box style={{ minWidth: 0 }}>
<Group gap={8} align="baseline">
<Text size="sm" fw={600} c={msg.mine ? "brand.4" : undefined}>
{msg.sender}
<Text
size="sm"
fw={600}
c={msg.mine ? "brand.4" : undefined}
title={msg.sender}
>
{name}
</Text>
<Text size="xs" c="dimmed">
{timeShort(msg.ts)}
+38
View File
@@ -176,6 +176,29 @@ interface MemberJSON {
sign_pub: string; // base64
}
// DirectoryMemberWire is one row of GET /directory: a cluster-wide member with its
// human handle and role. sign_pub here is 64-hex (the raw Ed25519 public key), and
// endpoint matches endpointID(signPub) byte for byte.
interface DirectoryMemberWire {
sign_pub: string; // 64-hex
endpoint: string; // base64url-nopad, == endpointID(signPub)
handle: string;
role: string;
}
interface DirectoryResp {
members: DirectoryMemberWire[];
}
// DirectoryEntry is the SDK shape of one directory member: the readable handle keyed
// by the stable endpoint id, so the UI can show a name instead of the raw id.
export interface DirectoryEntry {
signPub: string; // 64-hex
endpoint: string;
handle: string;
role: string;
}
// MemberRoomWire is one row of GET /members/{endpoint}/rooms.
interface MemberRoomWire {
room_id: string;
@@ -285,6 +308,21 @@ export class ControlPlane {
}));
}
// fetchDirectory returns the cluster-wide member directory (GET /api/directory), so
// the UI can resolve a message sender's endpoint id to a readable handle. The
// request is signed like every other control-plane call. The caller is expected to
// tolerate this endpoint being absent on older clusters (404) and fall back to the
// short id; this method only maps the wire shape and lets transport errors surface.
async fetchDirectory(): Promise<DirectoryEntry[]> {
const resp = await this.request<DirectoryResp>("GET", "/directory");
return (resp.members ?? []).map((m) => ({
signPub: m.sign_pub,
endpoint: m.endpoint,
handle: m.handle,
role: m.role,
}));
}
// listMembers returns the room's members keyed by endpoint, so a receiver can find
// a sender's signing public key to verify message signatures.
async signerKeys(roomID: string): Promise<Map<string, Uint8Array>> {
+44
View File
@@ -72,6 +72,36 @@ interface Session {
let session: Session | null = null;
// directory maps a peer's stable endpoint id to its human handle, so the UI can show
// a readable name instead of the long base64url id. Populated from the control-plane
// GET /api/directory once a session opens, and refreshed when membership changes. It
// is best-effort: a cluster without the directory endpoint leaves it empty and the UI
// falls back to a short id (see displayName), so the chat keeps working regardless.
let directory = new Map<string, string>();
// shortId is the display fallback for an endpoint with no known handle: the first 8
// characters of the id, never the full long string.
function shortId(endpoint: string): string {
return endpoint.slice(0, 8);
}
// loadDirectory (re)loads the cluster member directory into the endpoint -> handle
// map. It NEVER throws: if the endpoint is missing (older cluster, 404) or the request
// fails, the existing map is kept (empty on first load) and callers fall back to the
// short id. The new map is built locally and only swapped in on success, so a failed
// refresh never wipes a directory that loaded earlier.
async function loadDirectory(s: Session): Promise<void> {
try {
const entries = await s.control.fetchDirectory();
const next = new Map<string, string>();
for (const e of entries) if (e.handle) next.set(e.endpoint, e.handle);
directory = next;
} catch {
// No directory endpoint yet, or a transient failure: keep what we have (the chat
// must work exactly as before without readable names).
}
}
function require_(): Session {
if (!session) throw new SessionError("no active bus session");
return session;
@@ -87,6 +117,8 @@ async function connectSession(wallet: WalletIdentity, handle: string): Promise<U
const transport = await WsNatsTransport.connect([BUS_WS], identity);
const client = new BusClient(identity, transport, control);
session = { identity, handle, endpoint, control, transport, client };
directory = new Map(); // fresh identity: drop any prior session's handle map
await loadDirectory(session); // best-effort; never blocks login on a directory error
return { id: endpoint, handle: handle || endpoint.slice(0, 8) };
}
@@ -128,6 +160,17 @@ export const bus = {
return { endpoint: s.endpoint, sign_pub: "", handle: s.handle };
},
// displayName resolves a sender endpoint id to a readable name for the UI: the
// member's handle when the directory knows it, the session user's own handle for
// their own messages, and a short id fallback otherwise — NEVER the full long
// endpoint. Pure lookup over the in-memory directory; safe to call from render.
displayName(endpoint: string): string {
if (session && endpoint === session.endpoint) {
return session.handle || directory.get(endpoint) || shortId(endpoint);
}
return directory.get(endpoint) || shortId(endpoint);
},
// logout closes the data-plane connection, drops the in-memory session, and clears
// the persisted session from both stores so it cannot be restored.
async logout(): Promise<void> {
@@ -161,6 +204,7 @@ export const bus = {
const s = require_();
const { roomID } = await s.control.createRoom(subject, ModeMatrix);
await s.client.refresh(); // re-evaluate the per-subject ACL with the new room
await loadDirectory(s); // a new room may bring new members into the directory
touchSession();
return { id: roomID, name: subject, encrypted: true, lastMessage: "", lastTs: 0, unread: 0, messages: [] };
},
+1 -1
View File
@@ -8,7 +8,7 @@ export interface User {
export interface Message {
id: string;
sender: string; // endpoint id del remitente (handle legible es fase 2)
sender: string; // endpoint id del remitente; el nombre legible se resuelve con bus.displayName()
body: string;
ts: number; // epoch ms
mine?: boolean;