diff --git a/web/src/ChatPanel.tsx b/web/src/ChatPanel.tsx index 228a065..a75a447 100644 --- a/web/src/ChatPanel.tsx +++ b/web/src/ChatPanel.tsx @@ -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 ( - {initials(msg.sender)} + {initials(name)} - - {msg.sender} + + {name} {timeShort(msg.ts)} diff --git a/web/src/bus/client.ts b/web/src/bus/client.ts index fd4823a..e7df65a 100644 --- a/web/src/bus/client.ts +++ b/web/src/bus/client.ts @@ -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 { + const resp = await this.request("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> { diff --git a/web/src/busService.ts b/web/src/busService.ts index ed1d4fd..8f4e28c 100644 --- a/web/src/busService.ts +++ b/web/src/busService.ts @@ -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(); + +// 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 { + try { + const entries = await s.control.fetchDirectory(); + const next = new Map(); + 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 { @@ -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: [] }; }, diff --git a/web/src/types.ts b/web/src/types.ts index 3a5a802..218d299 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -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;