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:
parent
ad9e54dbe9
commit
8741f5dbd5
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue