feat(rnotes): add presence indicators on notebook cards and note items

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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-20 12:43:04 -07:00
parent ad9e54dbe9
commit 8741f5dbd5
1 changed files with 140 additions and 1 deletions

View File

@ -180,6 +180,19 @@ class FolkNotesApp extends HTMLElement {
private _offlineUnsub: (() => void) | null = null;
private _offlineNotebookUnsubs: (() => void)[] = [];
// ── Presence indicators ──
private _presencePeers: Map<string, {
peerId: string;
username: string;
color: string;
notebookId: string | null;
noteId: string | null;
lastSeen: number;
}> = new Map();
private _presenceHeartbeat: ReturnType<typeof setInterval> | null = null;
private _presenceGC: ReturnType<typeof setInterval> | 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%)</code></pre><p><em>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%)</code></pre><p><em>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%)</code></pre><p><em>Maya is tracking expenses in rF
this.renderNav();
this.renderMeta();
this.mountEditor(this.selectedNote);
this.broadcastPresence();
}
}
@ -1252,6 +1268,122 @@ Gear: EUR 400 (10%)</code></pre><p><em>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<HTMLElement>('.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<HTMLElement>('.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%)</code></pre><p><em>Maya is tracking expenses in rF
}
this.renderMeta();
this.attachListeners();
this.renderPresenceIndicators();
this._tour.renderOverlay();
}
@ -2534,6 +2667,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>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%)</code></pre><p><em>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;