feat(uniweb): sidebar shows the real last message and time per room
The sidebar showed room.lastTs/lastMessage that busService created as 0/"" on every load, so the time rendered as the epoch-0 "01:00" and there was no preview. busService now owns a room store that keeps these fields live. - busService gains a room store: the room list plus a per-room metadata subscription. Since the wire has no message history (NATS delivers live only), staying subscribed to every room is the only way to know each room's latest message and to count unread for rooms the user is not viewing. Each delivered message updates the room's lastTs, lastMessage (a "name: body" preview, truncated, reusing the directory resolver) and bumps unread when the room is not active. - New surface: watchRooms(listener) to mirror the store into React, loadRooms() to (re)populate and subscribe, setActiveRoom(id) to clear a room's unread. createRoom adds the new room to the store and, because its data-plane refresh() drops all subscriptions, re-subscribes every room. subscribeRoom now shares the same decode core (subscribeRoomInternal) used by the metadata subscription. The store is reset on login/logout. - ChatShell mirrors the store via watchRooms instead of a one-shot listRooms, selects a room through setActiveRoom (clearing its unread), and auto-selects the first room once the list loads. - Sidebar: timeShort renders an em dash for a room with no message yet instead of the epoch-0 "01:00". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+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(),
|
||||
|
||||
+167
-32
@@ -102,11 +102,128 @@ async function loadDirectory(s: Session): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -118,6 +235,7 @@ async function connectSession(wallet: WalletIdentity, handle: string): Promise<U
|
||||
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) };
|
||||
}
|
||||
@@ -165,27 +283,40 @@ export const bus = {
|
||||
// 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);
|
||||
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,
|
||||
@@ -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<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
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user