feat: people online panel — live peer presence on canvas

- community-sync: re-announce on reconnect, handle peer-list/joined/left/ping events
- presence: export peerIdToColor helper
- server: track peer announcements, broadcast join/leave, relay ping-user
- canvas: people online badge + expandable panel with avatar dots and ping button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-02 16:45:57 -08:00
parent 4dd212ef7d
commit 5ee59f86d6
4 changed files with 607 additions and 13 deletions

View File

@ -216,6 +216,11 @@ export class CommunitySync extends EventTarget {
this.dispatchEvent(new CustomEvent("connected"));
// Re-announce presence on every (re)connect
if (this.#announceData) {
this.#send({ type: "announce", ...this.#announceData });
}
if (this.#offlineStore) {
this.#offlineStore.markSynced(this.#communitySlug);
}
@ -322,6 +327,22 @@ export class CommunitySync extends EventTarget {
// Handle presence updates (cursors, selections)
this.dispatchEvent(new CustomEvent("presence", { detail: msg }));
break;
case "peer-list":
this.dispatchEvent(new CustomEvent("peer-list", { detail: msg }));
break;
case "peer-joined":
this.dispatchEvent(new CustomEvent("peer-joined", { detail: msg }));
break;
case "peer-left":
this.dispatchEvent(new CustomEvent("peer-left", { detail: msg }));
break;
case "ping-user":
this.dispatchEvent(new CustomEvent("ping-user", { detail: msg }));
break;
}
} catch (e) {
console.error("[CommunitySync] Failed to handle message:", e);
@ -394,6 +415,27 @@ export class CommunitySync extends EventTarget {
});
}
// ── People Online (announce / ping) ──
#announceData: { peerId: string; username: string; color: string } | null = null;
/**
* Store announce payload and send immediately if already connected.
*/
setAnnounceData(data: { peerId: string; username: string; color: string }): void {
this.#announceData = data;
if (this.#ws?.readyState === WebSocket.OPEN) {
this.#send({ type: "announce", ...data });
}
}
/**
* Ask the server to relay a "come here" ping to a specific peer.
*/
sendPingUser(targetPeerId: string, viewport: { x: number; y: number }): void {
this.#send({ type: "ping-user", targetPeerId, viewport });
}
/**
* Send a keep-alive ping to prevent WebSocket idle timeout
*/

View File

@ -16,7 +16,7 @@ interface UserPresence extends PresenceData {
}
// Generate consistent color from peer ID
function peerIdToColor(peerId: string): string {
export function peerIdToColor(peerId: string): string {
const colors = [
"#ef4444", // red
"#f97316", // orange

View File

@ -1022,6 +1022,9 @@ interface WSData {
// Track connected clients per community
const communityClients = new Map<string, Map<string, ServerWebSocket<WSData>>>();
// Track announced peer info per community: slug → serverPeerId → { clientPeerId, username, color }
const peerAnnouncements = new Map<string, Map<string, { clientPeerId: string; username: string; color: string }>>();
function generatePeerId(): string {
return `peer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
@ -1536,6 +1539,55 @@ const server = Bun.serve<WSData>({
}
}
}
} else if (msg.type === "announce") {
// Client announcing its identity for the people-online panel
const { peerId: clientPeerId, username, color } = msg;
if (!clientPeerId || !username) return;
let slugAnnouncements = peerAnnouncements.get(communitySlug);
if (!slugAnnouncements) {
slugAnnouncements = new Map();
peerAnnouncements.set(communitySlug, slugAnnouncements);
}
slugAnnouncements.set(peerId, { clientPeerId, username, color });
// Send full peer list back to the announcing client
const peerList = Array.from(slugAnnouncements.values());
ws.send(JSON.stringify({ type: "peer-list", peers: peerList }));
// Broadcast peer-joined to all other clients
const joinedMsg = JSON.stringify({ type: "peer-joined", peerId: clientPeerId, username, color });
const clients2 = communityClients.get(communitySlug);
if (clients2) {
for (const [cPeerId, client] of clients2) {
if (cPeerId !== peerId && client.readyState === WebSocket.OPEN) {
client.send(joinedMsg);
}
}
}
} else if (msg.type === "ping-user") {
// Relay a "come here" ping to a specific peer
const { targetPeerId: targetClientPeerId, viewport } = msg;
const slugAnnouncements = peerAnnouncements.get(communitySlug);
const senderInfo = slugAnnouncements?.get(peerId);
if (!slugAnnouncements || !senderInfo) return;
// Find the server peerId that matches the target client peerId
const clients3 = communityClients.get(communitySlug);
if (clients3) {
for (const [serverPid, _client] of clients3) {
const ann = slugAnnouncements.get(serverPid);
if (ann && ann.clientPeerId === targetClientPeerId && _client.readyState === WebSocket.OPEN) {
_client.send(JSON.stringify({
type: "ping-user",
fromPeerId: senderInfo.clientPeerId,
fromUsername: senderInfo.username,
viewport,
}));
break;
}
}
}
} else if (msg.type === "update" && msg.id && msg.data) {
if (ws.data.readOnly) {
ws.send(JSON.stringify({ type: "error", message: "Authentication required to edit" }));
@ -1593,6 +1645,26 @@ const server = Bun.serve<WSData>({
close(ws: ServerWebSocket<WSData>) {
const { communitySlug, peerId } = ws.data;
// Broadcast peer-left before cleanup
const slugAnnouncements = peerAnnouncements.get(communitySlug);
if (slugAnnouncements) {
const ann = slugAnnouncements.get(peerId);
if (ann) {
const leftMsg = JSON.stringify({ type: "peer-left", peerId: ann.clientPeerId });
const clients = communityClients.get(communitySlug);
if (clients) {
for (const [cPeerId, client] of clients) {
if (cPeerId !== peerId && client.readyState === WebSocket.OPEN) {
client.send(leftMsg);
}
}
}
slugAnnouncements.delete(peerId);
if (slugAnnouncements.size === 0) peerAnnouncements.delete(communitySlug);
}
}
const clients = communityClients.get(communitySlug);
if (clients) {
clients.delete(peerId);

View File

@ -757,6 +757,278 @@
color: #64748b;
}
/* ── People Online badge ── */
#people-online-badge {
position: fixed;
bottom: 16px;
right: 170px;
padding: 6px 12px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
font-size: 12px;
color: #64748b;
z-index: 1000;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
transition: box-shadow 0.15s;
}
#people-online-badge:hover {
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.18);
}
#people-dots {
display: flex;
gap: 3px;
align-items: center;
}
#people-dots .dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
/* ── People Panel ── */
#people-panel {
position: fixed;
top: 72px;
right: 16px;
width: 280px;
max-height: calc(100vh - 120px);
background: white;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.18);
z-index: 1001;
display: none;
overflow: hidden;
}
#people-panel.open {
display: flex;
flex-direction: column;
}
#people-panel-header {
padding: 12px 16px;
border-bottom: 1px solid #e2e8f0;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
#people-panel-header h3 {
font-size: 14px;
color: #0f172a;
margin: 0;
}
#people-panel-header .count {
font-size: 12px;
color: #94a3b8;
background: #f1f5f9;
padding: 2px 8px;
border-radius: 10px;
}
#people-list {
overflow-y: auto;
flex: 1;
padding: 8px;
}
.people-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
transition: background 0.15s;
}
.people-row:hover {
background: #f1f5f9;
}
.people-row .dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.people-row .name {
flex: 1;
font-size: 13px;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.people-row .name .you-tag {
font-size: 11px;
color: #94a3b8;
font-weight: normal;
}
.people-row .actions-btn {
padding: 2px 8px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: white;
cursor: pointer;
font-size: 12px;
color: #64748b;
transition: background 0.15s;
}
.people-row .actions-btn:hover {
background: #f1f5f9;
}
.people-actions {
padding: 4px 8px 8px 30px;
}
.people-actions button {
display: block;
width: 100%;
padding: 6px 10px;
border: none;
background: none;
text-align: left;
font-size: 12px;
color: #334155;
cursor: pointer;
border-radius: 6px;
transition: background 0.15s;
}
.people-actions button:hover:not(:disabled) {
background: #f1f5f9;
}
.people-actions button:disabled {
color: #cbd5e1;
cursor: default;
}
/* ── Ping toast ── */
#ping-toast {
position: fixed;
top: 24px;
left: 50%;
transform: translateX(-50%) translateY(-60px);
padding: 10px 20px;
background: #3b82f6;
border-radius: 10px;
font-size: 13px;
color: white;
z-index: 100001;
opacity: 0;
transition: opacity 0.3s, transform 0.3s;
pointer-events: none;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.4);
}
#ping-toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
pointer-events: auto;
}
#ping-toast-go {
padding: 4px 12px;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.25);
color: white;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
#ping-toast-go:hover {
background: rgba(255, 255, 255, 0.4);
}
/* ── People panel dark mode ── */
body[data-theme="dark"] #people-online-badge {
background: #1e293b;
color: #94a3b8;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
body[data-theme="dark"] #people-panel {
background: #1e293b;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
body[data-theme="dark"] #people-panel-header {
border-bottom-color: #334155;
}
body[data-theme="dark"] #people-panel-header h3 {
color: #e2e8f0;
}
body[data-theme="dark"] #people-panel-header .count {
color: #cbd5e1;
background: #0f172a;
}
body[data-theme="dark"] .people-row:hover {
background: #334155;
}
body[data-theme="dark"] .people-row .name {
color: #e2e8f0;
}
body[data-theme="dark"] .people-row .actions-btn {
background: #334155;
border-color: #475569;
color: #cbd5e1;
}
body[data-theme="dark"] .people-row .actions-btn:hover {
background: #475569;
}
body[data-theme="dark"] .people-actions button {
color: #cbd5e1;
}
body[data-theme="dark"] .people-actions button:hover:not(:disabled) {
background: #334155;
}
body[data-theme="dark"] .people-actions button:disabled {
color: #475569;
}
/* ── People panel mobile ── */
@media (max-width: 640px) {
#people-online-badge {
right: 100px;
bottom: 12px;
}
#people-panel {
max-width: calc(100vw - 32px);
}
}
#canvas-content {
position: absolute;
top: 0;
@ -1268,6 +1540,24 @@
<div id="memory-list"></div>
</div>
<div id="people-panel">
<div id="people-panel-header">
<h3>People Online</h3>
<span class="count" id="people-count">0</span>
</div>
<div id="people-list"></div>
</div>
<div id="people-online-badge">
<span class="dots" id="people-dots"></span>
<span id="people-badge-text">1 online</span>
</div>
<div id="ping-toast">
<span id="ping-toast-text"></span>
<button id="ping-toast-go">Go</button>
</div>
<div id="shape-context-menu"></div>
<div id="copy-toast"></div>
@ -1321,6 +1611,7 @@
CommunitySync,
PresenceManager,
generatePeerId,
peerIdToColor,
OfflineStore,
MiCanvasBridge,
installSelectionTransforms
@ -1682,21 +1973,26 @@
// Initialize offline store and CommunitySync
const offlineStore = new OfflineStore();
await offlineStore.open();
const sync = new CommunitySync(communitySlug, offlineStore);
window.__communitySync = sync;
// Try to load from cache immediately (shows content before WebSocket connects)
const hadCache = await sync.initFromCache();
if (hadCache) {
status.className = "offline";
statusText.textContent = "Offline (cached)";
}
// Notify the shell tab bar that CommunitySync is ready
document.dispatchEvent(new CustomEvent("community-sync-ready", {
detail: { sync, communitySlug }
}));
// Non-blocking: open IndexedDB + load cache in background
// UI handlers below register immediately regardless of this outcome
(async () => {
try {
await offlineStore.open();
const hadCache = await sync.initFromCache();
if (hadCache) {
status.className = "offline";
statusText.textContent = "Offline (cached)";
}
} catch (e) {
console.warn("[Canvas] Offline cache init failed:", e);
}
document.dispatchEvent(new CustomEvent("community-sync-ready", {
detail: { sync, communitySlug }
}));
})();
// Initialize Presence for real-time cursors
const peerId = generatePeerId();
@ -1770,8 +2066,187 @@
// Handle presence updates from other users
sync.addEventListener("presence", (e) => {
presence.updatePresence(e.detail);
// Update last cursor for people panel "Navigate to"
const pid = e.detail.peerId;
if (pid && e.detail.cursor && onlinePeers.has(pid)) {
onlinePeers.get(pid).lastCursor = e.detail.cursor;
}
});
// ── People Online panel ──
const onlinePeers = new Map(); // clientPeerId → { username, color, lastCursor? }
const localColor = peerIdToColor(peerId);
const peoplePanel = document.getElementById("people-panel");
const peopleBadge = document.getElementById("people-online-badge");
const peopleDots = document.getElementById("people-dots");
const peopleBadgeText = document.getElementById("people-badge-text");
const peopleCount = document.getElementById("people-count");
const peopleList = document.getElementById("people-list");
const pingToast = document.getElementById("ping-toast");
const pingToastText = document.getElementById("ping-toast-text");
const pingToastGo = document.getElementById("ping-toast-go");
let pingToastTimer = null;
let openActionsId = null; // which peer's actions dropdown is open
// Announce ourselves
sync.setAnnounceData({ peerId, username: storedUsername, color: localColor });
function renderPeopleBadge() {
const totalCount = onlinePeers.size + 1; // +1 for self
peopleBadgeText.textContent = totalCount === 1 ? "1 online" : `${totalCount} online`;
peopleDots.innerHTML = "";
// Self dot
const selfDot = document.createElement("span");
selfDot.className = "dot";
selfDot.style.background = localColor;
peopleDots.appendChild(selfDot);
// Remote dots (up to 4)
let dotCount = 0;
for (const [, peer] of onlinePeers) {
if (dotCount >= 4) break;
const dot = document.createElement("span");
dot.className = "dot";
dot.style.background = peer.color || "#94a3b8";
peopleDots.appendChild(dot);
dotCount++;
}
peopleCount.textContent = totalCount;
}
function renderPeoplePanel() {
peopleList.innerHTML = "";
// Self row (no actions)
const selfRow = document.createElement("div");
selfRow.className = "people-row";
selfRow.innerHTML = `<span class="dot" style="background:${escapeHtml(localColor)}"></span>
<span class="name">${escapeHtml(storedUsername)} <span class="you-tag">(you)</span></span>`;
peopleList.appendChild(selfRow);
// Remote peers
for (const [pid, peer] of onlinePeers) {
const row = document.createElement("div");
row.className = "people-row";
row.innerHTML = `<span class="dot" style="background:${escapeHtml(peer.color || '#94a3b8')}"></span>
<span class="name">${escapeHtml(peer.username)}</span>
<button class="actions-btn" data-pid="${escapeHtml(pid)}">&gt;</button>`;
peopleList.appendChild(row);
// If this peer's actions are open, render them
if (openActionsId === pid) {
const actions = document.createElement("div");
actions.className = "people-actions";
const hasPos = !!peer.lastCursor;
actions.innerHTML = `
<button data-action="navigate" data-pid="${escapeHtml(pid)}" ${hasPos ? "" : "disabled"}>Navigate to${hasPos ? "" : " (no position)"}</button>
<button data-action="ping" data-pid="${escapeHtml(pid)}">Ping to join you</button>
<button disabled>Delegate to (coming soon)</button>
<button disabled>Add to space (coming soon)</button>`;
peopleList.appendChild(actions);
}
}
}
// Badge click toggles panel
peopleBadge.addEventListener("click", () => {
const isOpen = peoplePanel.classList.toggle("open");
if (isOpen) {
// Close memory panel if open (shared screen region)
const memPanel = document.getElementById("memory-panel");
if (memPanel) memPanel.classList.remove("open");
const memBtn = document.getElementById("toggle-memory");
if (memBtn) memBtn.classList.remove("active");
renderPeoplePanel();
}
});
// Delegate clicks inside people-list
peopleList.addEventListener("click", (e) => {
const btn = e.target.closest("button");
if (!btn) return;
const pid = btn.dataset.pid;
if (btn.classList.contains("actions-btn")) {
openActionsId = openActionsId === pid ? null : pid;
renderPeoplePanel();
return;
}
const action = btn.dataset.action;
if (action === "navigate" && pid) {
const peer = onlinePeers.get(pid);
if (peer?.lastCursor) navigateToPeer(peer.lastCursor);
} else if (action === "ping" && pid) {
const rect = canvas.getBoundingClientRect();
const vx = (rect.width / 2 - panX) / scale;
const vy = (rect.height / 2 - panY) / scale;
sync.sendPingUser(pid, { x: vx, y: vy });
const peer = onlinePeers.get(pid);
showToast(`Pinged ${peer?.username || "user"}`);
}
});
// Click-outside closes panel
document.addEventListener("click", (e) => {
if (peoplePanel.classList.contains("open") &&
!peoplePanel.contains(e.target) &&
!peopleBadge.contains(e.target)) {
peoplePanel.classList.remove("open");
}
});
function navigateToPeer(cursor) {
const rect = canvas.getBoundingClientRect();
panX = rect.width / 2 - cursor.x * scale;
panY = rect.height / 2 - cursor.y * scale;
updateCanvasTransform();
}
// ── People Online event handlers ──
sync.addEventListener("peer-list", (e) => {
onlinePeers.clear();
const peers = e.detail.peers || [];
for (const p of peers) {
if (p.clientPeerId !== peerId) {
onlinePeers.set(p.clientPeerId, { username: p.username, color: p.color });
}
}
renderPeopleBadge();
if (peoplePanel.classList.contains("open")) renderPeoplePanel();
});
sync.addEventListener("peer-joined", (e) => {
const d = e.detail;
if (d.peerId !== peerId) {
onlinePeers.set(d.peerId, { username: d.username, color: d.color });
renderPeopleBadge();
if (peoplePanel.classList.contains("open")) renderPeoplePanel();
}
});
sync.addEventListener("peer-left", (e) => {
const leftId = e.detail.peerId;
onlinePeers.delete(leftId);
presence.removeUser(leftId);
if (openActionsId === leftId) openActionsId = null;
renderPeopleBadge();
if (peoplePanel.classList.contains("open")) renderPeoplePanel();
});
sync.addEventListener("ping-user", (e) => {
const d = e.detail;
pingToastText.textContent = `${d.fromUsername || "Someone"} wants you to join them`;
pingToast.classList.add("show");
if (pingToastTimer) clearTimeout(pingToastTimer);
// Store viewport target for Go button
pingToastGo.onclick = () => {
if (d.viewport) navigateToPeer(d.viewport);
pingToast.classList.remove("show");
if (pingToastTimer) clearTimeout(pingToastTimer);
};
pingToastTimer = setTimeout(() => {
pingToast.classList.remove("show");
}, 8000);
});
// Initial render
renderPeopleBadge();
// Track if we're processing remote changes to avoid feedback loops
let isProcessingRemote = false;
@ -1789,6 +2264,11 @@
status.className = "offline";
statusText.textContent = "Offline (changes saved locally)";
}
// Clear online peers on disconnect (they'll re-announce on reconnect)
onlinePeers.clear();
openActionsId = null;
renderPeopleBadge();
if (peoplePanel.classList.contains("open")) renderPeoplePanel();
});
sync.addEventListener("synced", (e) => {