From 8741f5dbd54bcd985e4f7bdb0d44491a79772c7d Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 20 Mar 2026 12:43:04 -0700 Subject: [PATCH] feat(rnotes): add presence indicators on notebook cards and note items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show colored dots on notebook cards and note list items indicating which peers are currently viewing/editing. Uses existing presence message relay with zero server changes — heartbeat every 10s, stale peer GC at 20s. Co-Authored-By: Claude Opus 4.6 --- modules/rnotes/components/folk-notes-app.ts | 141 +++++++++++++++++++- 1 file changed, 140 insertions(+), 1 deletion(-) diff --git a/modules/rnotes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts index 50465c1..6362d62 100644 --- a/modules/rnotes/components/folk-notes-app.ts +++ b/modules/rnotes/components/folk-notes-app.ts @@ -180,6 +180,19 @@ class FolkNotesApp extends HTMLElement { private _offlineUnsub: (() => void) | null = null; private _offlineNotebookUnsubs: (() => void)[] = []; + // ── Presence indicators ── + private _presencePeers: Map = new Map(); + private _presenceHeartbeat: ReturnType | null = null; + private _presenceGC: ReturnType | null = null; + private _presenceUnsub: (() => void) | null = null; + // ── Demo data ── private demoNotebooks: (Notebook & { notes: Note[] })[] = []; @@ -198,7 +211,7 @@ class FolkNotesApp extends HTMLElement { this.space = this.getAttribute("space") || "demo"; this.setupShadow(); if (this.space === "demo") { this.loadDemoData(); } - else { this.subscribeOfflineRuntime(); this.loadNotebooks(); } + else { this.subscribeOfflineRuntime(); this.loadNotebooks(); this.setupPresence(); } // Auto-start tour on first visit if (!localStorage.getItem("rnotes_tour_done")) { setTimeout(() => this._tour.start(), 1200); @@ -516,6 +529,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF disconnectedCallback() { this.destroyEditor(); + this.cleanupPresence(); this._offlineUnsub?.(); this._offlineUnsub = null; for (const unsub of this._offlineNotebookUnsubs) unsub(); @@ -918,6 +932,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF this.unsubscribeNotebook(); this.subscribeNotebook(id); + this.broadcastPresence(); setTimeout(() => { if (this.loading && this.view === "notebook") { @@ -967,6 +982,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF this.renderNav(); this.renderMeta(); this.mountEditor(this.selectedNote); + this.broadcastPresence(); } } @@ -1252,6 +1268,122 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF } } + // ── Presence indicators ── + + /** Start presence broadcasting and listening. Called once when runtime is available. */ + private setupPresence() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (this._presenceUnsub) return; + if (!runtime?.isInitialized) { + // Runtime not ready yet — retry shortly + setTimeout(() => this.setupPresence(), 2000); + return; + } + + // Listen for presence messages from peers + this._presenceUnsub = runtime.onCustomMessage('presence', (msg: any) => { + if (msg.module !== 'rnotes' || !msg.peerId) return; + this._presencePeers.set(msg.peerId, { + peerId: msg.peerId, + username: msg.username || 'Anonymous', + color: msg.color || '#888', + notebookId: msg.notebookId || null, + noteId: msg.noteId || null, + lastSeen: Date.now(), + }); + this.renderPresenceIndicators(); + }); + + // Heartbeat: broadcast own position every 10s + this._presenceHeartbeat = setInterval(() => this.broadcastPresence(), 10_000); + + // GC: remove stale peers every 15s + this._presenceGC = setInterval(() => { + const cutoff = Date.now() - 20_000; + let changed = false; + const entries = Array.from(this._presencePeers.entries()); + for (const [id, peer] of entries) { + if (peer.lastSeen < cutoff) { + this._presencePeers.delete(id); + changed = true; + } + } + if (changed) this.renderPresenceIndicators(); + }, 15_000); + + // Send initial position + this.broadcastPresence(); + } + + /** Broadcast current user position to peers. */ + private broadcastPresence() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized || !runtime.isOnline) return; + const session = this.getSessionInfo(); + runtime.sendCustom({ + type: 'presence', + module: 'rnotes', + notebookId: this.selectedNotebook?.id || null, + noteId: this.selectedNote?.id || null, + username: session.username, + color: this.userColor(session.userId), + }); + } + + /** Patch presence dots onto notebook cards and note items in the DOM. */ + private renderPresenceIndicators() { + // Remove all existing presence dots + this.shadow.querySelectorAll('.presence-dots').forEach(el => el.remove()); + + // Notebook cards + this.shadow.querySelectorAll('.notebook-card[data-notebook]').forEach(card => { + const nbId = card.dataset.notebook; + const peers = Array.from(this._presencePeers.values()).filter(p => p.notebookId === nbId); + if (peers.length === 0) return; + const footer = card.querySelector('.notebook-card__footer'); + if (footer) footer.appendChild(this.buildPresenceDots(peers)); + }); + + // Note items + this.shadow.querySelectorAll('.note-item[data-note]').forEach(item => { + const noteId = item.dataset.note; + const peers = Array.from(this._presencePeers.values()).filter(p => p.noteId === noteId); + if (peers.length === 0) return; + const meta = item.querySelector('.note-item__meta'); + if (meta) meta.appendChild(this.buildPresenceDots(peers)); + }); + } + + /** Build a presence-dots container for a set of peers. */ + private buildPresenceDots(peers: { username: string; color: string }[]): HTMLSpanElement { + const container = document.createElement('span'); + container.className = 'presence-dots'; + const show = peers.slice(0, 3); + for (const p of show) { + const dot = document.createElement('span'); + dot.className = 'presence-dot'; + dot.style.background = p.color; + dot.title = p.username; + container.appendChild(dot); + } + if (peers.length > 3) { + const more = document.createElement('span'); + more.className = 'presence-dot-more'; + more.textContent = `+${peers.length - 3}`; + container.appendChild(more); + } + return container; + } + + /** Tear down presence listeners and timers. */ + private cleanupPresence() { + this._presenceUnsub?.(); + this._presenceUnsub = null; + if (this._presenceHeartbeat) { clearInterval(this._presenceHeartbeat); this._presenceHeartbeat = null; } + if (this._presenceGC) { clearInterval(this._presenceGC); this._presenceGC = null; } + this._presencePeers.clear(); + } + private mountCodeEditor(note: Note, isEditable: boolean, isDemo: boolean) { const languages = ['javascript', 'typescript', 'python', 'rust', 'go', 'html', 'css', 'json', 'sql', 'bash', 'c', 'cpp', 'java', 'ruby', 'php', 'markdown', 'yaml', 'toml', 'other']; const currentLang = note.language || 'javascript'; @@ -2164,6 +2296,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF } this.renderMeta(); this.attachListeners(); + this.renderPresenceIndicators(); this._tour.renderOverlay(); } @@ -2534,6 +2667,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF this.selectedNote = null; } this.render(); + this.broadcastPresence(); } private demoUpdateNoteField(noteId: string, field: string, value: string) { @@ -2645,6 +2779,11 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF .notebook-card__desc { font-size: 12px; color: var(--rs-text-muted); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .notebook-card__footer { display: flex; justify-content: space-between; font-size: 11px; color: var(--rs-text-muted); margin-top: 8px; } + /* ── Presence Indicators ── */ + .presence-dots { display: inline-flex; gap: 2px; align-items: center; margin-left: auto; } + .presence-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; border: 1px solid var(--rs-bg-surface, #fff); flex-shrink: 0; } + .presence-dot-more { font-size: 10px; color: var(--rs-text-muted); margin-left: 2px; } + /* ── Note Items ── */ .note-item { background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle); border-radius: 10px;