Merge branch 'issue/names-cleanup'
Readable handle in messages (GET /api/directory), sidebar shows real last message + time per room, pnpm dev usable after same-origin switch, dedup growth log (v0.5.0).
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
name: uniweb
|
||||
lang: ts
|
||||
domain: infra
|
||||
version: 0.4.0
|
||||
version: 0.5.0
|
||||
description: "Cliente web browser-nativo del bus unibus: SPA de chat (React+Mantine) con wallet por usuario (BIP39) que habla DIRECTO al bus (nats.ws + control-plane HTTPS firmado), sin gateway. La clave privada nunca sale del navegador."
|
||||
tags: [messaging, web, frontend, e2e]
|
||||
uses_functions: []
|
||||
@@ -62,14 +62,18 @@ total.
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# El bus ya corre (cluster unibus con WebSocket habilitado, --ws-port). Apunta la SPA a un
|
||||
# nodo y arráncala en dev (puerto 5173, que coincide con la CORS allowlist del cluster):
|
||||
# Producción: SPA same-origin detrás de Caddy, que sirve la SPA + /api + /nats.
|
||||
# Build estático y despliegue de web/dist:
|
||||
cd web && pnpm install
|
||||
VITE_BUS_HTTP=https://<nodo>:8470 VITE_BUS_WS=wss://<nodo>:8480 pnpm dev
|
||||
# Navegador: http://localhost:5173
|
||||
pnpm build # genera web/dist (se despliega a magnus:/opt/uniweb/dist)
|
||||
|
||||
# Producción: build estático y sirve web/dist con cualquier static server.
|
||||
cd web && pnpm build # genera web/dist
|
||||
# Dev (`pnpm dev`): el dev server NO tiene el proxy de Caddy, así que /api y /nats no
|
||||
# existen en localhost. Apunta la SPA a un nodo real del cluster con las env vars
|
||||
# (overrides del default same-origin). El dev server corre en el puerto 5174:
|
||||
VITE_BUS_HTTP=https://<nodo>:8470 VITE_BUS_WS=wss://<nodo>:8480 pnpm dev
|
||||
# Navegador: http://localhost:5174
|
||||
# (Añade http://localhost:5174 a la --cors-origins del nodo, o el control-plane
|
||||
# rechazará la petición por CORS.)
|
||||
```
|
||||
|
||||
## Cuándo usarla
|
||||
@@ -87,14 +91,31 @@ programáticos) ve a `unibus`; `uniweb` es el cliente web encima.
|
||||
*mockeaba*). En `enforce`, una identidad nueva debe ser autorizada por un admin
|
||||
(`membershipd user add`) antes de poder abrir sesión; el flujo de Join muestra la clave
|
||||
pública del usuario para que un admin la autorice.
|
||||
- **CORS**: el dev server corre en `http://localhost:5173` para coincidir con la
|
||||
`--cors-origins` del cluster. Otro origen necesita añadirse a esa allowlist.
|
||||
- **CORS / same-origin**: en producción la SPA es same-origin detrás de Caddy (`/api` y
|
||||
`/nats` proxyados), así que no hay CORS. En dev (`pnpm dev`, puerto 5174) esos paths
|
||||
relativos no existen: hay que apuntar a un nodo con `VITE_BUS_HTTP`/`VITE_BUS_WS` y
|
||||
añadir `http://localhost:5174` a la `--cors-origins` del nodo. El puerto 5173 está
|
||||
reservado a otra app local; si 5174 está ocupado, Vite usa el siguiente libre.
|
||||
- La passphrase del wallet nunca se guarda ni se envía; perderla en un dispositivo sin la
|
||||
mnemónica BIP39 = identidad irrecuperable en ese dispositivo (recuperable en otro con las 12
|
||||
palabras).
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v0.5.0 (2026-06-14) — nombres legibles en mensajes + sidebar con último mensaje/hora
|
||||
reales + `pnpm dev` documentado. (1) Los mensajes muestran el **handle** del remitente en
|
||||
vez del endpoint id: `ControlPlane.fetchDirectory()` pega al control-plane
|
||||
`GET /api/directory` (firmado) y `busService` mantiene un mapa `endpoint -> handle`
|
||||
(cargado al abrir sesión, refrescado tras `createRoom`); el resolver
|
||||
`bus.displayName(endpoint)` devuelve el handle o un id corto de fallback (nunca el
|
||||
endpoint largo), usado en la cabecera y el avatar de `ChatPanel` (el endpoint queda en un
|
||||
`title` para depurar). Resiliente: si el endpoint aún no existe en el cluster (404) el
|
||||
mapa queda vacío y el chat funciona igual que antes. (2) El sidebar muestra el **último
|
||||
mensaje y la hora reales**: `busService` posee un store de rooms con una suscripción de
|
||||
metadatos por room (último mensaje/hora + unread de rooms no activas); `Sidebar` ya no
|
||||
pinta el "01:00" de epoch-0. (3) `pnpm dev` queda usable tras el cambio a same-origin:
|
||||
apunta a un nodo con `VITE_BUS_HTTP`/`VITE_BUS_WS` y el dev server corre en el puerto 5174
|
||||
(documentado en `app.md` + `vite.config.ts`). `tsc` + 19/19 unit + `pnpm build` verdes.
|
||||
- v0.3.0 (2026-06-14) — `uniweb` se vuelve **cliente browser-nativo puro** (issue 0001, Fase
|
||||
2): la SPA se cablea al SDK del bus (`busService.ts` reemplaza el módulo `api`) y se
|
||||
**elimina el gateway Go** (`cmd/webgw`, `go.mod`, `go.sum`). `uniweb` queda como solo `web/`,
|
||||
@@ -122,21 +143,3 @@ programáticos) ve a `unibus`; `uniweb` es el cliente web encima.
|
||||
respecto a lo que ya vivía en unibus 0.12.0 (wallet BIP39 + sesiones por usuario); solo
|
||||
cambia la ubicación y el módulo Go. go build/vet/test + pnpm build verdes en la nueva
|
||||
ubicación con los `replace` cross-repo.
|
||||
|
||||
- v0.2.0 (2026-06-13) — SDK del bus en TypeScript (`web/src/bus/`), issue 0001 Fase 1:
|
||||
el protocolo y el cifrado E2E del bus portados al navegador para que `uniweb` deje
|
||||
de depender del gateway Go. Módulos: `crypto.ts` (Ed25519, ChaCha20-Poly1305,
|
||||
sealed box con nonce BLAKE2b igual que Go), `frame.ts` (wire format = `encoding/json`
|
||||
de Go byte a byte), `room.ts` (Policy), `busauth.ts` (nkey NATS + firma de requests
|
||||
del control-plane), `client.ts` (envelope de room puro + `BusClient` sobre una
|
||||
interfaz de transporte + cliente HTTP firmado) y `wstransport.ts` (adaptador
|
||||
`nats.ws`). Paridad cross-language verificada contra vectores Go (`cmd/busvectors`):
|
||||
**19/19 tests verdes** — endpoint id, firma Ed25519, AEAD, sealed box, frame
|
||||
marshal/sign, nkey y canonical request. La clave privada del usuario nunca se
|
||||
serializa hacia la red. La conexión `nats.ws` + control-plane reales se validan en
|
||||
la Fase 3 (E2E) por requerir un unibus vivo con WebSocket.
|
||||
- v0.1.0 (2026-06-13) — scaffold inicial: extracción de la SPA (`web/`) y el gateway
|
||||
(`cmd/webgw`) desde `unibus` v0.13.0 a su propia app/sub-repo. Sin cambios de capacidad
|
||||
respecto a lo que ya vivía en unibus 0.12.0 (wallet BIP39 + sesiones por usuario); solo
|
||||
cambia la ubicación y el módulo Go. go build/vet/test + pnpm build verdes en la nueva
|
||||
ubicación con los `replace` cross-repo.
|
||||
|
||||
+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)}
|
||||
|
||||
+27
-14
@@ -21,24 +21,32 @@ export function ChatShell({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [modalOpen, modal] = useDisclosure(false);
|
||||
|
||||
// Inserta la room recién creada al principio de la lista y la activa, sin
|
||||
// recargar todo. Evita duplicar si el id ya estaba presente.
|
||||
const handleRoomCreated = useCallback((room: Room) => {
|
||||
setRooms((prev) =>
|
||||
prev.some((r) => r.id === room.id) ? prev : [room, ...prev],
|
||||
);
|
||||
setActiveId(room.id);
|
||||
// The room list lives in busService (it owns a per-room metadata subscription so the
|
||||
// sidebar shows the latest message/time and unread for rooms not being viewed). The
|
||||
// shell just mirrors the store into React state.
|
||||
useEffect(() => bus.watchRooms(setRooms), []);
|
||||
|
||||
// selectRoom activates a room in the UI and tells the store, which clears that room's
|
||||
// unread badge.
|
||||
const selectRoom = useCallback((id: string) => {
|
||||
setActiveId(id);
|
||||
bus.setActiveRoom(id);
|
||||
}, []);
|
||||
|
||||
// La room recién creada ya está en el store (bus.createRoom la insertó); aquí solo
|
||||
// se activa.
|
||||
const handleRoomCreated = useCallback(
|
||||
(room: Room) => {
|
||||
selectRoom(room.id);
|
||||
},
|
||||
[selectRoom],
|
||||
);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
bus
|
||||
.listRooms()
|
||||
.then((rs) => {
|
||||
setRooms(rs);
|
||||
setActiveId((cur) => cur || rs[0]?.id || "");
|
||||
setError(null);
|
||||
})
|
||||
.loadRooms()
|
||||
.then(() => setError(null))
|
||||
.catch((e) => setError(e?.message ?? "No se pudieron cargar las rooms"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
@@ -47,6 +55,11 @@ export function ChatShell({
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// Activa la primera room en cuanto la lista se puebla y aún no hay ninguna activa.
|
||||
useEffect(() => {
|
||||
if (!activeId && rooms.length > 0) selectRoom(rooms[0].id);
|
||||
}, [rooms, activeId, selectRoom]);
|
||||
|
||||
const active = rooms.find((r) => r.id === activeId);
|
||||
|
||||
// El panel derecho muestra el estado de carga/error/empty sin tocar el layout.
|
||||
@@ -106,7 +119,7 @@ export function ChatShell({
|
||||
user={user}
|
||||
rooms={rooms}
|
||||
activeId={activeId}
|
||||
onSelect={setActiveId}
|
||||
onSelect={selectRoom}
|
||||
onLogout={onLogout}
|
||||
onNewRoom={modal.open}
|
||||
/>
|
||||
|
||||
@@ -28,7 +28,10 @@ function initials(s: string) {
|
||||
return s.replace(/[^a-z0-9]/gi, "").slice(0, 2).toUpperCase() || "?";
|
||||
}
|
||||
|
||||
// timeShort renders HH:MM, or an em dash when there is no message yet (ts 0/falsy) so
|
||||
// an empty room does not show the epoch-0 "01:00".
|
||||
function timeShort(ts: number) {
|
||||
if (!ts) return "—";
|
||||
const d = new Date(ts);
|
||||
return `${String(d.getHours()).padStart(2, "0")}:${String(
|
||||
d.getMinutes(),
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
+207
-28
@@ -72,11 +72,158 @@ 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).
|
||||
}
|
||||
}
|
||||
|
||||
// displayNameOf is the resolver behind bus.displayName, kept module-level so the
|
||||
// room store can reuse it for last-message previews.
|
||||
function displayNameOf(endpoint: string): string {
|
||||
if (session && endpoint === session.endpoint) {
|
||||
return session.handle || directory.get(endpoint) || shortId(endpoint);
|
||||
}
|
||||
return directory.get(endpoint) || shortId(endpoint);
|
||||
}
|
||||
|
||||
function require_(): Session {
|
||||
if (!session) throw new SessionError("no active bus session");
|
||||
return session;
|
||||
}
|
||||
|
||||
// ---- room store (sidebar metadata) -----------------------------------------
|
||||
//
|
||||
// The sidebar needs each room's last message and time, plus an unread count for
|
||||
// rooms the user is NOT currently viewing. There is no message history on the wire
|
||||
// (NATS delivers live only), so the only way to know a room's latest message is to
|
||||
// stay subscribed to every room while the app is open. This store owns that: it holds
|
||||
// the room list, subscribes to each room for metadata, and notifies React watchers on
|
||||
// every change. ChatPanel keeps its own subscription for the open conversation; this
|
||||
// store's per-room subscription is independent and only updates sidebar metadata.
|
||||
|
||||
let roomList: Room[] = [];
|
||||
let activeRoomID = "";
|
||||
const roomListeners = new Set<(rooms: Room[]) => void>();
|
||||
const metaSubs = new Map<string, () => void>(); // roomID -> unsubscribe
|
||||
|
||||
const PREVIEW_MAX = 48; // characters of a last-message preview in the sidebar
|
||||
|
||||
// snapshotRooms returns a shallow copy so React sees a new array/objects and re-renders.
|
||||
function snapshotRooms(): Room[] {
|
||||
return roomList.map((r) => ({ ...r }));
|
||||
}
|
||||
|
||||
function notifyRooms(): void {
|
||||
const snap = snapshotRooms();
|
||||
for (const l of roomListeners) l(snap);
|
||||
}
|
||||
|
||||
// previewText builds the sidebar's last-message line: "name: body" with the body
|
||||
// truncated, reusing the directory resolver so the sender shows as a readable handle.
|
||||
function previewText(m: Message): string {
|
||||
const body =
|
||||
m.body.length > PREVIEW_MAX ? m.body.slice(0, PREVIEW_MAX - 1) + "…" : m.body;
|
||||
return `${displayNameOf(m.sender)}: ${body}`;
|
||||
}
|
||||
|
||||
// trackRoomMeta opens a metadata subscription for one room: each delivered message
|
||||
// updates the room's last message/time and bumps unread when the room is not active.
|
||||
function trackRoomMeta(roomID: string): void {
|
||||
if (metaSubs.has(roomID)) return;
|
||||
const unsub = subscribeRoomInternal(roomID, (m) => {
|
||||
const r = roomList.find((x) => x.id === roomID);
|
||||
if (!r) return;
|
||||
r.lastTs = m.ts;
|
||||
r.lastMessage = previewText(m);
|
||||
if (roomID !== activeRoomID && !m.mine) r.unread += 1;
|
||||
notifyRooms();
|
||||
});
|
||||
metaSubs.set(roomID, unsub);
|
||||
}
|
||||
|
||||
function untrackAllRooms(): void {
|
||||
for (const unsub of metaSubs.values()) {
|
||||
try {
|
||||
unsub();
|
||||
} catch {
|
||||
/* a closing transport may already be gone */
|
||||
}
|
||||
}
|
||||
metaSubs.clear();
|
||||
}
|
||||
|
||||
// retrackRooms re-establishes a metadata subscription for every room. Used after a
|
||||
// data-plane reconnect (createRoom's refresh), which drops all existing subscriptions.
|
||||
function retrackRooms(): void {
|
||||
untrackAllRooms();
|
||||
for (const r of roomList) trackRoomMeta(r.id);
|
||||
}
|
||||
|
||||
// resetRoomStore clears the store and tears down subscriptions (on logout / new
|
||||
// session), then pushes the empty snapshot so any live watcher renders an empty list.
|
||||
function resetRoomStore(): void {
|
||||
untrackAllRooms();
|
||||
roomList = [];
|
||||
activeRoomID = "";
|
||||
notifyRooms();
|
||||
}
|
||||
|
||||
// subscribeRoomInternal is the shared core behind bus.subscribeRoom and the store's
|
||||
// per-room metadata subscription: it decodes each frame into a UI Message and hands it
|
||||
// to onMessage. Returns a function that cancels the subscription.
|
||||
function subscribeRoomInternal(
|
||||
roomID: string,
|
||||
onMessage: (m: Message) => void,
|
||||
): () => void {
|
||||
const s = require_();
|
||||
let unsub: (() => void) | null = null;
|
||||
let closed = false;
|
||||
s.client
|
||||
.subscribe(roomID, (f: Frame, plaintext: Uint8Array) => {
|
||||
onMessage({
|
||||
id: f.msgID,
|
||||
sender: f.sender,
|
||||
body: new TextDecoder().decode(plaintext),
|
||||
ts: Date.now(),
|
||||
mine: f.sender === s.endpoint,
|
||||
});
|
||||
})
|
||||
.then((sub) => {
|
||||
if (closed) void sub.unsubscribe();
|
||||
else unsub = () => void sub.unsubscribe();
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
closed = true;
|
||||
if (unsub) unsub();
|
||||
};
|
||||
}
|
||||
|
||||
// connectSession opens the live bus connection (control plane + nats.ws data plane)
|
||||
// for a wallet identity, WITHOUT touching persistence. The private key is used here
|
||||
// in the browser and never leaves it.
|
||||
@@ -87,6 +234,9 @@ 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
|
||||
resetRoomStore(); // drop any prior session's room store + metadata subscriptions
|
||||
await loadDirectory(session); // best-effort; never blocks login on a directory error
|
||||
return { id: endpoint, handle: handle || endpoint.slice(0, 8) };
|
||||
}
|
||||
|
||||
@@ -128,21 +278,45 @@ 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 {
|
||||
return displayNameOf(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> {
|
||||
clearSession();
|
||||
resetRoomStore();
|
||||
directory = new Map();
|
||||
if (session) {
|
||||
await session.transport.close().catch(() => {});
|
||||
session = null;
|
||||
}
|
||||
},
|
||||
|
||||
// listRooms lists the rooms this peer belongs to.
|
||||
async listRooms(): Promise<Room[]> {
|
||||
// watchRooms subscribes a listener to the sidebar room list and returns a function
|
||||
// to detach it. The current snapshot is pushed immediately, so a component mounting
|
||||
// mid-session renders the rooms it already has. Call loadRooms() to (re)populate.
|
||||
watchRooms(listener: (rooms: Room[]) => void): () => void {
|
||||
roomListeners.add(listener);
|
||||
listener(snapshotRooms());
|
||||
return () => {
|
||||
roomListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
|
||||
// loadRooms fetches the rooms this peer belongs to, replaces the store, opens a
|
||||
// metadata subscription per room (so the sidebar shows the latest message/time and
|
||||
// unread for rooms the user is not viewing), and notifies watchers.
|
||||
async loadRooms(): Promise<void> {
|
||||
const s = require_();
|
||||
const wire = await s.control.listMemberRooms(s.endpoint);
|
||||
return wire.map((r) => ({
|
||||
untrackAllRooms();
|
||||
roomList = wire.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.subject,
|
||||
encrypted: r.policy.encrypt,
|
||||
@@ -151,18 +325,43 @@ export const bus = {
|
||||
unread: 0,
|
||||
messages: [],
|
||||
}));
|
||||
for (const r of roomList) trackRoomMeta(r.id);
|
||||
notifyRooms();
|
||||
},
|
||||
|
||||
// setActiveRoom marks the room the user is viewing: its unread count is cleared and
|
||||
// future messages to it do not bump unread (see trackRoomMeta).
|
||||
setActiveRoom(roomID: string): void {
|
||||
activeRoomID = roomID;
|
||||
const r = roomList.find((x) => x.id === roomID);
|
||||
if (r) r.unread = 0;
|
||||
notifyRooms();
|
||||
},
|
||||
|
||||
// createRoom creates an encrypted, signed room owned by this peer (the Matrix-like
|
||||
// default), then reconnects the data plane so the new room's subject enters this
|
||||
// connection's ACL grant — otherwise publish/subscribe on a just-created room would
|
||||
// silently not deliver until a reconnect/re-login. Returns the UI Room.
|
||||
// silently not deliver until a reconnect/re-login. The reconnect drops every
|
||||
// metadata subscription, so they are re-established here. Returns the UI Room.
|
||||
async createRoom(subject: string): Promise<Room> {
|
||||
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: [] };
|
||||
const room: Room = {
|
||||
id: roomID,
|
||||
name: subject,
|
||||
encrypted: true,
|
||||
lastMessage: "",
|
||||
lastTs: 0,
|
||||
unread: 0,
|
||||
messages: [],
|
||||
};
|
||||
if (!roomList.some((r) => r.id === roomID)) roomList = [room, ...roomList];
|
||||
retrackRooms(); // refresh() dropped all data-plane subs; re-subscribe every room
|
||||
notifyRooms();
|
||||
return room;
|
||||
},
|
||||
|
||||
// send publishes a plaintext message to a room; the SDK seals + signs it per the
|
||||
@@ -174,30 +373,10 @@ export const bus = {
|
||||
},
|
||||
|
||||
// subscribeRoom delivers decrypted, verified messages for a room (replaces the old
|
||||
// SSE streamRoom). Returns an unsubscribe function.
|
||||
// SSE streamRoom). Returns an unsubscribe function. ChatPanel uses this for the open
|
||||
// conversation; the sidebar metadata uses the same core (subscribeRoomInternal).
|
||||
subscribeRoom(roomID: string, onMessage: (m: Message) => void): () => void {
|
||||
const s = require_();
|
||||
let unsub: (() => void) | null = null;
|
||||
let closed = false;
|
||||
s.client
|
||||
.subscribe(roomID, (f: Frame, plaintext: Uint8Array) => {
|
||||
onMessage({
|
||||
id: f.msgID,
|
||||
sender: f.sender,
|
||||
body: new TextDecoder().decode(plaintext),
|
||||
ts: Date.now(),
|
||||
mine: f.sender === s.endpoint,
|
||||
});
|
||||
})
|
||||
.then((sub) => {
|
||||
if (closed) void sub.unsubscribe();
|
||||
else unsub = () => void sub.unsubscribe();
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
closed = true;
|
||||
if (unsub) unsub();
|
||||
};
|
||||
return subscribeRoomInternal(roomID, onMessage);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+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;
|
||||
|
||||
+9
-5
@@ -3,12 +3,16 @@ import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
// The SPA talks DIRECTLY to the bus (signed HTTPS control plane + nats.ws data
|
||||
// plane), so there is no gateway and no /api proxy. The dev server runs on 5173 to
|
||||
// match the bus CORS allowlist (--cors-origins http://localhost:5173). Point the
|
||||
// SPA at a cluster node with VITE_BUS_HTTP / VITE_BUS_WS (see busService.ts).
|
||||
// In production the SPA is served same-origin behind Caddy, which proxies /api and
|
||||
// /nats to the cluster; those relative paths do not exist on the bare dev server, so
|
||||
// `pnpm dev` must be pointed at a real cluster node with VITE_BUS_HTTP / VITE_BUS_WS
|
||||
// (busService.ts uses them as overrides of the same-origin defaults). Example:
|
||||
// VITE_BUS_HTTP=https://<node>:8470 VITE_BUS_WS=wss://<node>:8480 pnpm dev
|
||||
// The dev server runs on 5174 (5173 is reserved for an unrelated local app). Add the
|
||||
// dev origin (http://localhost:5174) to the node's --cors-origins allowlist. strictPort
|
||||
// is left off, so Vite falls back to the next free port if 5174 is busy.
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
port: 5174,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user