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:
+11
-3
@@ -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)}
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user