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 _offlineUnsub: (() => void) | null = null;
|
||||||
private _offlineNotebookUnsubs: (() => void)[] = [];
|
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 ──
|
// ── Demo data ──
|
||||||
private demoNotebooks: (Notebook & { notes: Note[] })[] = [];
|
private demoNotebooks: (Notebook & { notes: Note[] })[] = [];
|
||||||
|
|
||||||
|
|
@ -198,7 +211,7 @@ class FolkNotesApp extends HTMLElement {
|
||||||
this.space = this.getAttribute("space") || "demo";
|
this.space = this.getAttribute("space") || "demo";
|
||||||
this.setupShadow();
|
this.setupShadow();
|
||||||
if (this.space === "demo") { this.loadDemoData(); }
|
if (this.space === "demo") { this.loadDemoData(); }
|
||||||
else { this.subscribeOfflineRuntime(); this.loadNotebooks(); }
|
else { this.subscribeOfflineRuntime(); this.loadNotebooks(); this.setupPresence(); }
|
||||||
// Auto-start tour on first visit
|
// Auto-start tour on first visit
|
||||||
if (!localStorage.getItem("rnotes_tour_done")) {
|
if (!localStorage.getItem("rnotes_tour_done")) {
|
||||||
setTimeout(() => this._tour.start(), 1200);
|
setTimeout(() => this._tour.start(), 1200);
|
||||||
|
|
@ -516,6 +529,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
this.destroyEditor();
|
this.destroyEditor();
|
||||||
|
this.cleanupPresence();
|
||||||
this._offlineUnsub?.();
|
this._offlineUnsub?.();
|
||||||
this._offlineUnsub = null;
|
this._offlineUnsub = null;
|
||||||
for (const unsub of this._offlineNotebookUnsubs) unsub();
|
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.unsubscribeNotebook();
|
||||||
this.subscribeNotebook(id);
|
this.subscribeNotebook(id);
|
||||||
|
this.broadcastPresence();
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.loading && this.view === "notebook") {
|
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.renderNav();
|
||||||
this.renderMeta();
|
this.renderMeta();
|
||||||
this.mountEditor(this.selectedNote);
|
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) {
|
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 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';
|
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.renderMeta();
|
||||||
this.attachListeners();
|
this.attachListeners();
|
||||||
|
this.renderPresenceIndicators();
|
||||||
this._tour.renderOverlay();
|
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.selectedNote = null;
|
||||||
}
|
}
|
||||||
this.render();
|
this.render();
|
||||||
|
this.broadcastPresence();
|
||||||
}
|
}
|
||||||
|
|
||||||
private demoUpdateNoteField(noteId: string, field: string, value: string) {
|
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__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; }
|
.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 Items ── */
|
||||||
.note-item {
|
.note-item {
|
||||||
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle); border-radius: 10px;
|
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle); border-radius: 10px;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue