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 }) {
|
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 (
|
return (
|
||||||
<Group align="flex-start" gap="sm" wrap="nowrap">
|
<Group align="flex-start" gap="sm" wrap="nowrap">
|
||||||
<Avatar radius="xl" size={36} color={msg.mine ? "brand" : "gray"}>
|
<Avatar radius="xl" size={36} color={msg.mine ? "brand" : "gray"}>
|
||||||
{initials(msg.sender)}
|
{initials(name)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Box style={{ minWidth: 0 }}>
|
<Box style={{ minWidth: 0 }}>
|
||||||
<Group gap={8} align="baseline">
|
<Group gap={8} align="baseline">
|
||||||
<Text size="sm" fw={600} c={msg.mine ? "brand.4" : undefined}>
|
<Text
|
||||||
{msg.sender}
|
size="sm"
|
||||||
|
fw={600}
|
||||||
|
c={msg.mine ? "brand.4" : undefined}
|
||||||
|
title={msg.sender}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{timeShort(msg.ts)}
|
{timeShort(msg.ts)}
|
||||||
|
|||||||
@@ -176,6 +176,29 @@ interface MemberJSON {
|
|||||||
sign_pub: string; // base64
|
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.
|
// MemberRoomWire is one row of GET /members/{endpoint}/rooms.
|
||||||
interface MemberRoomWire {
|
interface MemberRoomWire {
|
||||||
room_id: string;
|
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
|
// listMembers returns the room's members keyed by endpoint, so a receiver can find
|
||||||
// a sender's signing public key to verify message signatures.
|
// a sender's signing public key to verify message signatures.
|
||||||
async signerKeys(roomID: string): Promise<Map<string, Uint8Array>> {
|
async signerKeys(roomID: string): Promise<Map<string, Uint8Array>> {
|
||||||
|
|||||||
@@ -72,6 +72,36 @@ interface Session {
|
|||||||
|
|
||||||
let session: Session | null = null;
|
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 {
|
function require_(): Session {
|
||||||
if (!session) throw new SessionError("no active bus session");
|
if (!session) throw new SessionError("no active bus session");
|
||||||
return 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 transport = await WsNatsTransport.connect([BUS_WS], identity);
|
||||||
const client = new BusClient(identity, transport, control);
|
const client = new BusClient(identity, transport, control);
|
||||||
session = { identity, handle, endpoint, control, transport, client };
|
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) };
|
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 };
|
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
|
// logout closes the data-plane connection, drops the in-memory session, and clears
|
||||||
// the persisted session from both stores so it cannot be restored.
|
// the persisted session from both stores so it cannot be restored.
|
||||||
async logout(): Promise<void> {
|
async logout(): Promise<void> {
|
||||||
@@ -161,6 +204,7 @@ export const bus = {
|
|||||||
const s = require_();
|
const s = require_();
|
||||||
const { roomID } = await s.control.createRoom(subject, ModeMatrix);
|
const { roomID } = await s.control.createRoom(subject, ModeMatrix);
|
||||||
await s.client.refresh(); // re-evaluate the per-subject ACL with the new room
|
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();
|
touchSession();
|
||||||
return { id: roomID, name: subject, encrypted: true, lastMessage: "", lastTs: 0, unread: 0, messages: [] };
|
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 {
|
export interface Message {
|
||||||
id: string;
|
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;
|
body: string;
|
||||||
ts: number; // epoch ms
|
ts: number; // epoch ms
|
||||||
mine?: boolean;
|
mine?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user