diff --git a/lib/community-sync.ts b/lib/community-sync.ts index c7d1319..25f6d88 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -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 */ diff --git a/lib/presence.ts b/lib/presence.ts index 1df85dd..db6771f 100644 --- a/lib/presence.ts +++ b/lib/presence.ts @@ -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 diff --git a/server/index.ts b/server/index.ts index b972e17..34de8b1 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1022,6 +1022,9 @@ interface WSData { // Track connected clients per community const communityClients = new Map>>(); +// Track announced peer info per community: slug → serverPeerId → { clientPeerId, username, color } +const peerAnnouncements = new Map>(); + function generatePeerId(): string { return `peer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } @@ -1536,6 +1539,55 @@ const server = Bun.serve({ } } } + } 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({ close(ws: ServerWebSocket) { 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); diff --git a/website/canvas.html b/website/canvas.html index 22a5750..d353061 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -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 @@
+
+
+

People Online

+ 0 +
+
+
+ +
+ + 1 online +
+ +
+ + +
+
@@ -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 = ` + ${escapeHtml(storedUsername)} (you)`; + peopleList.appendChild(selfRow); + // Remote peers + for (const [pid, peer] of onlinePeers) { + const row = document.createElement("div"); + row.className = "people-row"; + row.innerHTML = ` + ${escapeHtml(peer.username)} + `; + 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 = ` + + + + `; + 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) => {