/** * — drop-in multiplayer presence for all rApps. * * Features: * - "N online" badge (top-right pill with colored dots) * - People panel dropdown (click badge to open) * - Solo/Share mode toggle in panel * - Remote cursors (SVG arrows with username labels, viewport-relative) * - Focus highlighting (colored outline rings on data-collab-id elements) * - Auto-discovery via rspace-doc-subscribe events from runtime * - Bridge API for canvas (external peer management via CommunitySync) * * Attributes: * module-id — current module identifier * space — current space slug * doc-id — explicit doc ID (fallback if auto-discovery misses) * mode — "badge-only" for iframe/proxy modules (no cursors) */ import type { AwarenessMessage } from '../local-first/sync'; // ── Peer color palette (same as canvas PresenceManager) ── const PEER_COLORS = [ '#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#06b6d4', '#f97316', '#14b8a6', '#6366f1', ]; interface PeerState { peerId: string; username: string; color: string; cursor: { x: number; y: number } | null; selection: string | null; lastSeen: number; module?: string; // which rApp they're in context?: string; // human-readable view label (e.g. "My Notebook > Note Title") } export class RStackCollabOverlay extends HTMLElement { #shadow: ShadowRoot; #peers = new Map(); #docId: string | null = null; #moduleId: string | null = null; #localPeerId: string | null = null; #localColor = PEER_COLORS[0]; #localUsername = 'Anonymous'; #unsubAwareness: (() => void) | null = null; #unsubPresence: (() => void) | null = null; #mouseMoveTimer: ReturnType | null = null; #lastCursor = { x: 0, y: 0 }; #gcInterval: ReturnType | null = null; #badgeOnly = false; #soloMode = false; #panelOpen = false; #connState: 'connected' | 'offline' | 'reconnecting' | 'connecting' = 'connecting'; #externalPeers = false; // true for canvas — peers fed in via public API #openActionsId: string | null = null; // which peer's actions dropdown is open constructor() { super(); this.#shadow = this.attachShadow({ mode: 'open' }); } connectedCallback() { this.#moduleId = this.getAttribute('module-id'); this.#badgeOnly = this.getAttribute('mode') === 'badge-only'; this.#soloMode = localStorage.getItem('rspace_solo_mode') === '1'; // Canvas page — peers fed externally via bridge API, skip runtime connection if (this.#moduleId === 'rspace') { this.#externalPeers = true; // Dispatch initial solo-mode event for canvas PresenceManager document.dispatchEvent(new CustomEvent('solo-mode-change', { detail: { solo: this.#soloMode } })); } // Resolve local identity this.#resolveIdentity(); // Render initial (badge always visible) this.#render(); this.#renderBadge(); if (!this.#externalPeers) { // Explicit doc-id attribute (fallback) const explicitDocId = this.getAttribute('doc-id'); if (explicitDocId) this.#docId = explicitDocId; // Listen for runtime doc subscriptions (auto-discovery) window.addEventListener('rspace-doc-subscribe', this.#onDocSubscribe); // Try connecting to runtime this.#tryConnect(); // GC stale peers every 5s this.#gcInterval = setInterval(() => this.#gcPeers(), 5000); } // Click-outside closes panel (listen on document, check composedPath for shadow DOM) document.addEventListener('click', this.#onDocumentClick); } disconnectedCallback() { document.removeEventListener('click', this.#onDocumentClick); if (!this.#externalPeers) { window.removeEventListener('rspace-doc-subscribe', this.#onDocSubscribe); this.#unsubAwareness?.(); this.#unsubPresence?.(); this.#stopMouseTracking(); this.#stopFocusTracking(); } if (this.#gcInterval) clearInterval(this.#gcInterval); this.#gcInterval = null; } // ── Public bridge API (for canvas CommunitySync) ── updatePeer(peerId: string, username: string, color: string) { const existing = this.#peers.get(peerId); this.#peers.set(peerId, { peerId, username: username || existing?.username || 'Anonymous', color: color || existing?.color || this.#colorForPeer(peerId), cursor: existing?.cursor ?? null, selection: existing?.selection ?? null, lastSeen: Date.now(), }); this.#renderBadge(); if (this.#panelOpen) this.#renderPanel(); } removePeer(peerId: string) { this.#peers.delete(peerId); if (this.#openActionsId === peerId) this.#openActionsId = null; this.#renderBadge(); if (this.#panelOpen) this.#renderPanel(); } setConnState(state: 'connected' | 'offline' | 'reconnecting' | 'connecting') { this.#connState = state; this.#renderBadge(); if (this.#panelOpen) this.#renderPanel(); } clearPeers() { this.#peers.clear(); this.#openActionsId = null; this.#renderBadge(); if (this.#panelOpen) this.#renderPanel(); } // ── Auto-discovery ── #onDocSubscribe = (e: Event) => { const { docId } = (e as CustomEvent).detail; if (!this.#docId && docId) { this.#docId = docId; this.#connectToDoc(); } }; // ── Runtime connection ── #tryConnect() { const runtime = (window as any).__rspaceOfflineRuntime; if (runtime?.isInitialized) { this.#onRuntimeReady(runtime); } else { // Retry until runtime is ready const check = setInterval(() => { const rt = (window as any).__rspaceOfflineRuntime; if (rt?.isInitialized) { clearInterval(check); this.#onRuntimeReady(rt); } }, 500); // Give up after 15s setTimeout(() => clearInterval(check), 15000); } } #onRuntimeReady(runtime: any) { this.#localPeerId = runtime.peerId; // Assign a deterministic color from peer ID this.#localColor = this.#colorForPeer(this.#localPeerId!); this.#connState = 'connected'; if (this.#docId) { this.#connectToDoc(); } // Listen for space-wide presence broadcasts (module context from all rApps) this.#unsubPresence = runtime.onCustomMessage('presence', (msg: any) => { if (!msg.username) return; // Use peerId from server relay, or fall back to a hash of username const pid = msg.peerId || `anon-${msg.username}`; if (pid === this.#localPeerId) return; // ignore self const existing = this.#peers.get(pid); this.#peers.set(pid, { peerId: pid, username: msg.username || existing?.username || 'Anonymous', color: msg.color || existing?.color || this.#colorForPeer(pid), cursor: existing?.cursor ?? null, selection: existing?.selection ?? null, lastSeen: Date.now(), module: msg.module || existing?.module, context: msg.context || existing?.context, }); this.#renderBadge(); if (this.#panelOpen) this.#renderPanel(); }); } #connectToDoc() { const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime || !this.#docId) return; // Unsubscribe from previous this.#unsubAwareness?.(); // Listen for remote awareness this.#unsubAwareness = runtime.onAwareness(this.#docId, (msg: AwarenessMessage) => { if (msg.peer === this.#localPeerId) return; // ignore self this.#handleRemoteAwareness(msg); }); // Start broadcasting local presence if (!this.#badgeOnly) { this.#startMouseTracking(); this.#startFocusTracking(); } // Send initial presence (announce we're here) this.#broadcastPresence(); } // ── Identity ── #resolveIdentity() { try { const raw = localStorage.getItem('encryptid_session'); if (raw) { const session = JSON.parse(raw); if (session?.username) this.#localUsername = session.username; else if (session?.displayName) this.#localUsername = session.displayName; } } catch { /* no session */ } } // ── Remote awareness handling ── #handleRemoteAwareness(msg: AwarenessMessage) { if (this.#soloMode) return; // suppress incoming awareness in solo mode const existing = this.#peers.get(msg.peer); const peer: PeerState = { peerId: msg.peer, username: msg.username || existing?.username || 'Anonymous', color: msg.color || existing?.color || this.#colorForPeer(msg.peer), cursor: msg.cursor ?? existing?.cursor ?? null, selection: msg.selection ?? existing?.selection ?? null, lastSeen: Date.now(), }; this.#peers.set(msg.peer, peer); this.#renderBadge(); if (!this.#badgeOnly) { this.#renderCursors(); this.#renderFocusRings(); } } // ── Local broadcasting ── #broadcastPresence(cursor?: { x: number; y: number }, selection?: string) { if (this.#soloMode) return; // suppress outgoing awareness in solo mode const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime || !this.#docId) return; runtime.sendAwareness(this.#docId, { cursor, selection, username: this.#localUsername, color: this.#localColor, }); } // ── Mouse tracking (15Hz throttle) ── #mouseHandler = (e: MouseEvent) => { this.#lastCursor = { x: e.clientX, y: e.clientY }; }; #startMouseTracking() { document.addEventListener('mousemove', this.#mouseHandler, { passive: true }); // Broadcast at 15Hz this.#mouseMoveTimer = setInterval(() => { this.#broadcastPresence(this.#lastCursor, undefined); }, 67); // ~15Hz } #stopMouseTracking() { document.removeEventListener('mousemove', this.#mouseHandler); if (this.#mouseMoveTimer) clearInterval(this.#mouseMoveTimer); this.#mouseMoveTimer = null; } // ── Focus tracking on data-collab-id elements ── #focusHandler = (e: FocusEvent) => { const real = (e.composedPath()[0] as HTMLElement); const target = real?.closest?.('[data-collab-id]'); if (target) { const collabId = target.getAttribute('data-collab-id'); if (collabId) this.#broadcastPresence(undefined, collabId); } }; #blurHandler = () => { this.#broadcastPresence(undefined, ''); }; #startFocusTracking() { document.addEventListener('focusin', this.#focusHandler, { passive: true }); document.addEventListener('click', this.#clickHandler, { passive: true }); document.addEventListener('focusout', this.#blurHandler, { passive: true }); } #stopFocusTracking() { document.removeEventListener('focusin', this.#focusHandler); document.removeEventListener('click', this.#clickHandler); document.removeEventListener('focusout', this.#blurHandler); } // Also track clicks on data-collab-id (many elements aren't focusable) #clickHandler = (e: MouseEvent) => { const real = (e.composedPath()[0] as HTMLElement); const target = real?.closest?.('[data-collab-id]'); if (target) { const collabId = target.getAttribute('data-collab-id'); if (collabId) this.#broadcastPresence(undefined, collabId); } }; // ── Click-outside closes panel ── #onDocumentClick = (e: MouseEvent) => { if (!this.#panelOpen) return; // Check if click was inside our shadow DOM const path = e.composedPath(); if (path.includes(this)) return; this.#panelOpen = false; this.#shadow.getElementById('people-panel')?.classList.remove('open'); }; // ── GC stale peers ── #gcPeers() { const now = Date.now(); let changed = false; for (const [id, peer] of this.#peers) { if (now - peer.lastSeen > 15000) { this.#peers.delete(id); changed = true; } } if (changed) { this.#renderBadge(); if (!this.#badgeOnly) { this.#renderCursors(); this.#renderFocusRings(); } if (this.#panelOpen) this.#renderPanel(); } } // ── Color assignment ── #colorForPeer(peerId: string): string { let hash = 0; for (let i = 0; i < peerId.length; i++) { hash = ((hash << 5) - hash + peerId.charCodeAt(i)) | 0; } return PEER_COLORS[Math.abs(hash) % PEER_COLORS.length]; } // ── Rendering ── #render() { this.#shadow.innerHTML = `

People Online

1
`; this.#shadow.getElementById('badge')?.addEventListener('click', (e) => { e.stopPropagation(); this.#togglePanel(); }); } // ── Panel toggle ── #togglePanel() { this.#panelOpen = !this.#panelOpen; const panel = this.#shadow.getElementById('people-panel'); if (this.#panelOpen) { panel?.classList.add('open'); this.#renderPanel(); } else { panel?.classList.remove('open'); } } // ── Panel rendering ── #renderPanel() { const list = this.#shadow.getElementById('people-list'); const countEl = this.#shadow.getElementById('panel-count'); if (!list) return; const count = this.#peers.size + 1; if (countEl) countEl.textContent = this.#connState === 'connected' ? String(count) : '\u2014'; const fragments: string[] = []; // Connection state notice if (this.#connState === 'offline' || this.#connState === 'reconnecting' || this.#connState === 'connecting') { const msg = this.#connState === 'offline' ? '\u26a0 You\u2019re offline. Changes are saved locally and will resync when you reconnect.' : 'Reconnecting to server\u2026'; fragments.push(`
${this.#escHtml(msg)}
`); } // Self row with Solo/Share toggle const isSolo = this.#soloMode; const selfModule = this.#moduleId || ''; fragments.push(`
${this.#escHtml(this.#localUsername)} (you) ${selfModule ? `${this.#escHtml(selfModule)}` : ''}
`); // Remote peer rows const isCanvas = this.#moduleId === 'rspace'; for (const [pid, peer] of this.#peers) { const ctxParts: string[] = []; if (peer.module) ctxParts.push(peer.module); if (peer.context) ctxParts.push(peer.context); const ctxStr = ctxParts.join(' · '); fragments.push(`
${this.#escHtml(peer.username)} ${ctxStr ? `${this.#escHtml(ctxStr)}` : ''}
${isCanvas ? `` : ''}
`); // Expanded actions for canvas if (isCanvas && this.#openActionsId === pid) { fragments.push(`
`); } } list.innerHTML = fragments.join(''); // Wire up event delegation on the list list.onclick = (e) => { const btn = (e.target as HTMLElement).closest('button'); if (!btn) return; const action = btn.dataset.action; const pid = btn.dataset.pid; if (action === 'solo') { this.#setSoloMode(true); } else if (action === 'share') { this.#setSoloMode(false); } else if (btn.classList.contains('actions-btn') && pid) { this.#openActionsId = this.#openActionsId === pid ? null : pid; this.#renderPanel(); } else if ((action === 'navigate' || action === 'ping') && pid) { this.dispatchEvent(new CustomEvent('collab-peer-action', { bubbles: true, composed: true, detail: { action, peerId: pid }, })); } }; } // ── Solo mode ── #setSoloMode(solo: boolean) { this.#soloMode = solo; localStorage.setItem('rspace_solo_mode', solo ? '1' : '0'); if (solo) { // Clear remote peers and their visual artifacts (for non-canvas modules) if (!this.#externalPeers) { this.#peers.clear(); if (!this.#badgeOnly) { this.#renderCursors(); this.#renderFocusRings(); } } } this.#renderBadge(); if (this.#panelOpen) this.#renderPanel(); // Notify canvas PresenceManager and any other listeners document.dispatchEvent(new CustomEvent('solo-mode-change', { detail: { solo } })); } #renderBadge() { const badge = this.#shadow.getElementById('badge'); if (!badge) return; if (this.#soloMode) { badge.innerHTML = `\u{1F4F4} Offline`; badge.classList.add('visible', 'solo'); badge.title = 'Offline \u2014 your presence is hidden. Click to go online.'; return; } badge.classList.remove('solo'); if (this.#connState === 'offline') { badge.innerHTML = ` Offline `; badge.classList.add('visible'); badge.title = 'You\u2019re offline \u2014 changes saved locally.'; return; } if (this.#connState === 'reconnecting' || this.#connState === 'connecting') { badge.innerHTML = ` Reconnecting\u2026 `; badge.classList.add('visible'); badge.title = 'Reconnecting to server\u2026'; return; } const count = this.#peers.size + 1; // +1 for self const dots = Array.from(this.#peers.values()) .slice(0, 5) // show max 5 dots .map(p => ``) .join(''); badge.innerHTML = ` ${dots} \u{1F465} ${count} online `; badge.classList.add('visible'); badge.title = `${count} online \u2014 click to see people`; } #renderCursors() { const container = this.#shadow.getElementById('cursors'); if (!container) return; const now = Date.now(); const fragments: string[] = []; for (const peer of this.#peers.values()) { if (!peer.cursor) continue; const age = now - peer.lastSeen; const opacity = age > 5000 ? 0.3 : 1; fragments.push(`
${this.#escHtml(peer.username)}
`); } container.innerHTML = fragments.join(''); } /** Find a data-collab-id element, searching into shadow roots. */ #findCollabEl(id: string): Element | null { const sel = `[data-collab-id="${CSS.escape(id)}"]`; const found = document.querySelector(sel); if (found) return found; // Walk into shadow roots (one level deep — rApp components) for (const el of document.querySelectorAll('*')) { if (el.shadowRoot) { const inner = el.shadowRoot.querySelector(sel); if (inner) return inner; } } return null; } #renderFocusRings() { // Remove all existing focus rings from the document document.querySelectorAll('.rstack-collab-focus-ring').forEach(el => el.remove()); for (const peer of this.#peers.values()) { if (!peer.selection) continue; const target = this.#findCollabEl(peer.selection); if (!target) continue; const rect = target.getBoundingClientRect(); const ring = document.createElement('div'); ring.className = 'rstack-collab-focus-ring'; ring.style.cssText = ` position: fixed; left: ${rect.left - 3}px; top: ${rect.top - 3}px; width: ${rect.width + 6}px; height: ${rect.height + 6}px; border: 2px solid ${peer.color}; border-radius: 6px; pointer-events: none; z-index: 9998; box-shadow: 0 0 0 1px ${peer.color}33; transition: all 0.15s ease; `; // Username label on the ring const label = document.createElement('span'); label.textContent = peer.username; label.style.cssText = ` position: absolute; top: -18px; left: 4px; font-size: 10px; color: white; background: ${peer.color}; padding: 1px 5px; border-radius: 3px; white-space: nowrap; line-height: 14px; `; ring.appendChild(label); document.body.appendChild(ring); } } #escHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } static define() { if (!customElements.get('rstack-collab-overlay')) { customElements.define('rstack-collab-overlay', RStackCollabOverlay); } } } // ── Styles (inside shadow DOM) ── const OVERLAY_CSS = ` :host { display: inline-flex; align-items: center; pointer-events: none; position: relative; } .collab-badge { display: none; align-items: center; gap: 4px; padding: 4px 10px 4px 6px; border-radius: 16px; background: var(--rs-glass-bg, rgba(15, 23, 42, 0.85)); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); font-size: 11px; color: var(--rs-text-secondary, #94a3b8); pointer-events: auto; cursor: pointer; user-select: none; border: 1px solid var(--rs-glass-border, rgba(255,255,255,0.08)); transition: opacity 0.2s, border-color 0.2s; white-space: nowrap; } .collab-badge:hover { border-color: var(--rs-border-strong, rgba(255,255,255,0.2)); } .collab-badge.visible { display: inline-flex; } .collab-badge.solo { opacity: 0.7; } .count.solo { color: var(--rs-text-muted, #888); } .dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; } .count { margin-left: 2px; font-weight: 500; } /* ── People panel dropdown ── */ .people-panel { display: none; position: absolute; top: calc(100% + 4px); right: 0; width: 280px; max-height: calc(100vh - 120px); background: var(--rs-bg-surface, #1e1e1e); border-radius: 12px; box-shadow: var(--rs-shadow-lg, 0 8px 30px rgba(0,0,0,0.4)), 0 0 0 1px var(--rs-glass-border, rgba(255,255,255,0.08)); z-index: 10001; overflow: hidden; pointer-events: auto; flex-direction: column; } .people-panel.open { display: flex; } .people-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--rs-border-subtle, rgba(0,0,0,0.06)); display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; } .people-panel-header h3 { font-size: 14px; color: var(--rs-text-primary, #0f172a); margin: 0; font-weight: 600; } .panel-count { font-size: 12px; color: var(--rs-text-secondary, #374151); background: var(--rs-bg-surface-raised, #f0efe9); padding: 2px 8px; border-radius: 10px; } .people-list { overflow-y: auto; flex: 1; padding: 8px; } .conn-notice { padding: 8px 16px; font-size: 12px; color: var(--rs-text-muted, #888); background: var(--rs-bg-surface-raised, rgba(255,255,255,0.04)); border-bottom: 1px solid var(--rs-border-subtle, rgba(255,255,255,0.06)); } .people-row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; transition: background 0.15s; } .people-row:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.08)); } .people-row .dot { width: 10px; height: 10px; } .people-row .name-block { flex: 1; min-width: 0; display: flex; flex-direction: column; } .people-row .name { font-size: 13px; color: var(--rs-text-primary, #fff); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .peer-context { font-size: 11px; color: var(--rs-text-muted, #888); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.2; } .you-tag { font-size: 11px; color: #94a3b8; font-weight: normal; } /* ── Mode toggle (Solo/Share) ── */ .mode-toggle { display: flex; align-items: center; gap: 0; border: 1px solid var(--rs-border, rgba(255,255,255,0.12)); border-radius: 6px; overflow: hidden; flex-shrink: 0; } .mode-toggle button { padding: 3px 8px; border: none; background: none; font-size: 11px; color: var(--rs-text-muted, #888); cursor: pointer; transition: background 0.15s, color 0.15s; white-space: nowrap; } .mode-toggle button.active { background: var(--rs-accent, #3b82f6); color: #fff; } .mode-toggle button:not(.active):hover { background: var(--rs-bg-hover, rgba(255,255,255,0.08)); } /* ── Peer action buttons (canvas only) ── */ .actions-btn { padding: 2px 8px; border: 1px solid var(--rs-border, rgba(0,0,0,0.1)); border-radius: 6px; background: var(--rs-bg-surface-raised, #334155); cursor: pointer; font-size: 12px; color: var(--rs-text-muted, #64748b); transition: background 0.15s; } .actions-btn:hover { background: var(--rs-bg-hover, rgba(0,0,0,0.04)); } .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: var(--rs-text-secondary, #ccc); cursor: pointer; border-radius: 6px; transition: background 0.15s; } .people-actions button:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.08)); } /* ── Cursors ── */ .collab-cursors { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; pointer-events: none; z-index: 9999; overflow: hidden; } .cursor { position: fixed; pointer-events: none; transition: left 0.1s linear, top 0.1s linear, opacity 0.3s ease; z-index: 9999; } .cursor-label { position: absolute; left: 14px; top: 14px; font-size: 10px; color: white; padding: 1px 5px; border-radius: 3px; white-space: nowrap; line-height: 14px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } /* ── Mobile ── */ @media (max-width: 640px) { .count { display: none; } .people-panel { max-width: calc(100vw - 32px); } } `;