diff --git a/web/src/ChatShell.tsx b/web/src/ChatShell.tsx index a5e2cfe..a1db7b9 100644 --- a/web/src/ChatShell.tsx +++ b/web/src/ChatShell.tsx @@ -21,24 +21,32 @@ export function ChatShell({ const [error, setError] = useState(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} /> diff --git a/web/src/Sidebar.tsx b/web/src/Sidebar.tsx index 47351d7..121bc82 100644 --- a/web/src/Sidebar.tsx +++ b/web/src/Sidebar.tsx @@ -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(), diff --git a/web/src/busService.ts b/web/src/busService.ts index 8f4e28c..94640d4 100644 --- a/web/src/busService.ts +++ b/web/src/busService.ts @@ -102,11 +102,128 @@ async function loadDirectory(s: Session): Promise { } } +// 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 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. @@ -118,6 +235,7 @@ async function connectSession(wallet: WalletIdentity, handle: string): Promise { 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 { + // 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 { 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, @@ -194,19 +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 { 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 @@ -218,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); }, };