feat(api+web): member sidebar in topic chat with live presence
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

GET /v1/members lists every non-revoked member of the api key's
mesh, decorated with online state from presence rows. Distinct from
/v1/peers (active sessions) — sidebars want roster + live dot, not
just whoever is currently connected.

Chat panel splits into a 2-column layout (>=lg) with a 180px
sidebar that polls the roster every 20s. Online members go up top
with status-coloured dots (idle=green, working=clay, dnd=fig);
offline members fade below at 50% opacity. Bots get a "bot" tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 19:10:26 +01:00
parent 541440c357
commit a75483b3c2
2 changed files with 192 additions and 0 deletions

View File

@@ -444,6 +444,69 @@ export const v1Router = new Hono<Env>()
});
})
// GET /v1/members — every (non-revoked) member of the key's mesh,
// decorated with online status from presence. Unlike /v1/peers this
// includes humans/agents that haven't opened a WS session yet —
// useful for Discord-style member sidebars where roster matters more
// than live activity.
.get("/members", async (c) => {
const key = c.var.apiKey;
requireCapability(key, "read");
const rows = await db
.select({
memberId: meshMember.id,
pubkey: meshMember.peerPubkey,
displayName: meshMember.displayName,
role: meshMember.role,
joinedAt: meshMember.joinedAt,
userId: meshMember.userId,
})
.from(meshMember)
.where(
and(eq(meshMember.meshId, key.meshId), isNull(meshMember.revokedAt)),
)
.orderBy(asc(meshMember.joinedAt));
const onlineRows = await db
.select({
memberId: presence.memberId,
status: presence.status,
summary: presence.summary,
})
.from(presence)
.innerJoin(meshMember, eq(presence.memberId, meshMember.id))
.where(
and(eq(meshMember.meshId, key.meshId), isNull(presence.disconnectedAt)),
)
.orderBy(desc(presence.connectedAt));
const onlineByMember = new Map<
string,
{ status: string; summary: string | null }
>();
for (const r of onlineRows) {
if (onlineByMember.has(r.memberId)) continue;
onlineByMember.set(r.memberId, {
status: r.status,
summary: r.summary,
});
}
return c.json({
members: rows.map((r) => {
const live = onlineByMember.get(r.memberId);
return {
memberId: r.memberId,
pubkey: r.pubkey,
displayName: r.displayName,
role: r.role,
isHuman: r.userId !== null,
joinedAt: r.joinedAt.toISOString(),
online: !!live,
status: live?.status ?? "offline",
summary: live?.summary ?? null,
};
}),
});
})
// GET /v1/peers — connected peers in the key's mesh
// Dedupe by memberId — a member can have multiple active presence
// rows (one per session). Status reflects the most recent presence;