| null = null;
// ── Demo data ──
private demoNotebooks: (Notebook & { notes: Note[] })[] = [];
@@ -1452,12 +1453,11 @@ Gear: EUR 400 (10%)Maya is tracking expenses in rF
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
+ // Listen for presence messages from peers (for sidebar notebook/note dots)
this._presenceUnsub = runtime.onCustomMessage('presence', (msg: any) => {
if (msg.module !== 'rnotes' || !msg.peerId) return;
this._presencePeers.set(msg.peerId, {
@@ -1471,15 +1471,21 @@ Gear: EUR 400 (10%)Maya is tracking expenses in rF
this.renderPresenceIndicators();
});
- // Heartbeat: broadcast own position every 10s
- this._presenceHeartbeat = setInterval(() => this.broadcastPresence(), 10_000);
+ // Use shared heartbeat for broadcasting
+ this._stopPresence = startPresenceHeartbeat(() => ({
+ module: 'rnotes',
+ context: this.selectedNote
+ ? `${this.selectedNotebook?.name || 'Notebook'} > ${this.selectedNote.title}`
+ : this.selectedNotebook?.name || '',
+ notebookId: this.selectedNotebook?.id,
+ noteId: this.selectedNote?.id,
+ }));
- // GC: remove stale peers every 15s
+ // GC: remove stale peers every 15s (for sidebar dots)
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) {
+ for (const [id, peer] of this._presencePeers) {
if (peer.lastSeen < cutoff) {
this._presencePeers.delete(id);
changed = true;
@@ -1487,23 +1493,17 @@ Gear: EUR 400 (10%)Maya is tracking expenses in rF
}
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',
+ sharedBroadcastPresence({
module: 'rnotes',
- notebookId: this.selectedNotebook?.id || null,
- noteId: this.selectedNote?.id || null,
- username: session.username,
- color: this.userColor(session.userId),
+ context: this.selectedNote
+ ? `${this.selectedNotebook?.name || 'Notebook'} > ${this.selectedNote.title}`
+ : this.selectedNotebook?.name || '',
+ notebookId: this.selectedNotebook?.id,
+ noteId: this.selectedNote?.id,
});
}
@@ -1552,9 +1552,10 @@ Gear: EUR 400 (10%)Maya is tracking expenses in rF
/** Tear down presence listeners and timers. */
private cleanupPresence() {
+ this._stopPresence?.();
+ this._stopPresence = null;
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();
}
diff --git a/modules/rnotes/mod.ts b/modules/rnotes/mod.ts
index ed9504d..6cbad74 100644
--- a/modules/rnotes/mod.ts
+++ b/modules/rnotes/mod.ts
@@ -1615,7 +1615,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: ``,
- scripts: ``,
+ scripts: ``,
styles: ``,
}));
});
diff --git a/modules/rphotos/components/folk-photo-gallery.ts b/modules/rphotos/components/folk-photo-gallery.ts
index 877d8e8..4032f84 100644
--- a/modules/rphotos/components/folk-photo-gallery.ts
+++ b/modules/rphotos/components/folk-photo-gallery.ts
@@ -11,6 +11,7 @@
import { makeDraggableAll } from "../../../shared/draggable";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
+import { startPresenceHeartbeat } from '../../../shared/collab-presence';
interface Album {
id: string;
@@ -49,6 +50,7 @@ class FolkPhotoGallery extends HTMLElement {
private error = "";
private showingSampleData = false;
private _tour!: TourEngine;
+ private _stopPresence: (() => void) | null = null;
private _history = new ViewHistory<"gallery" | "album" | "lightbox">("gallery");
private static readonly TOUR_STEPS = [
{ target: '.album-card', title: "Albums", message: "Browse shared photo albums — click one to see its photos.", advanceOnClick: false },
@@ -77,6 +79,11 @@ class FolkPhotoGallery extends HTMLElement {
if (!localStorage.getItem("rphotos_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
+ this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rphotos', context: this.selectedAlbum?.albumName || 'Photos' }));
+ }
+
+ disconnectedCallback() {
+ this._stopPresence?.();
}
private loadDemoData() {
diff --git a/modules/rphotos/mod.ts b/modules/rphotos/mod.ts
index e19a207..8fd8c3e 100644
--- a/modules/rphotos/mod.ts
+++ b/modules/rphotos/mod.ts
@@ -133,7 +133,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: ``,
- scripts: ``,
+ scripts: ``,
styles: ``,
}));
});
diff --git a/modules/rpubs/components/folk-pubs-editor.ts b/modules/rpubs/components/folk-pubs-editor.ts
index 42a6243..38cc606 100644
--- a/modules/rpubs/components/folk-pubs-editor.ts
+++ b/modules/rpubs/components/folk-pubs-editor.ts
@@ -13,6 +13,7 @@ import { pubsDraftSchema, pubsDocId } from '../schemas';
import type { PubsDoc } from '../schemas';
import type { DocumentId } from '../../../shared/local-first/document';
import { TourEngine } from '../../../shared/tour-engine';
+import { startPresenceHeartbeat } from '../../../shared/collab-presence';
interface BookFormat {
id: string;
@@ -98,6 +99,7 @@ export class FolkPubsEditor extends HTMLElement {
private _isRemoteUpdate = false;
private _syncTimer: ReturnType | null = null;
private _syncConnected = false;
+ private _stopPresence: (() => void) | null = null;
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '.content-area', title: "Editor", message: "Write or paste markdown content here. Drag-and-drop text files also works.", advanceOnClick: false },
@@ -160,6 +162,10 @@ export class FolkPubsEditor extends HTMLElement {
}
document.addEventListener("click", this._outsideClickHandler);
+ this._stopPresence = startPresenceHeartbeat(() => {
+ const activeDraft = this._drafts.find(d => d.draftId === this._activeDraftId);
+ return { module: 'rpubs', context: activeDraft?.title || 'Editor' };
+ });
}
private async initRuntime() {
@@ -356,6 +362,7 @@ export class FolkPubsEditor extends HTMLElement {
if (this._unsubChange) this._unsubChange();
if (this._syncTimer) clearTimeout(this._syncTimer);
document.removeEventListener("click", this._outsideClickHandler);
+ this._stopPresence?.();
}
private renderDropdowns() {
diff --git a/modules/rpubs/mod.ts b/modules/rpubs/mod.ts
index 6a15baf..63a3dd7 100644
--- a/modules/rpubs/mod.ts
+++ b/modules/rpubs/mod.ts
@@ -668,7 +668,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: ``,
- scripts: `
+ scripts: `
`,
styles: ``,
diff --git a/modules/rschedule/components/folk-schedule-app.ts b/modules/rschedule/components/folk-schedule-app.ts
index a0321b7..796e06b 100644
--- a/modules/rschedule/components/folk-schedule-app.ts
+++ b/modules/rschedule/components/folk-schedule-app.ts
@@ -9,6 +9,7 @@ import { scheduleSchema, scheduleDocId, type ScheduleDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
+import { startPresenceHeartbeat } from '../../../shared/collab-presence';
interface JobData {
id: string;
@@ -90,6 +91,7 @@ class FolkScheduleApp extends HTMLElement {
private loading = false;
private runningJobId: string | null = null;
private _offlineUnsub: (() => void) | null = null;
+ private _stopPresence: (() => void) | null = null;
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '[data-view="jobs"]', title: "Scheduled Jobs", message: "View and manage automated jobs that run on a cron schedule — email alerts, webhooks, and more.", advanceOnClick: true },
@@ -134,6 +136,7 @@ class FolkScheduleApp extends HTMLElement {
if (!localStorage.getItem("rschedule_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
+ this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rschedule', context: 'Schedule' }));
}
disconnectedCallback() {
@@ -141,6 +144,7 @@ class FolkScheduleApp extends HTMLElement {
this._offlineUnsub();
this._offlineUnsub = null;
}
+ this._stopPresence?.();
}
private async subscribeOffline() {
diff --git a/modules/rschedule/mod.ts b/modules/rschedule/mod.ts
index a85ec1b..c0bd8cf 100644
--- a/modules/rschedule/mod.ts
+++ b/modules/rschedule/mod.ts
@@ -809,7 +809,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: ``,
- scripts: ``,
+ scripts: ``,
styles: ``,
}),
);
diff --git a/modules/rsocials/components/folk-campaign-planner.ts b/modules/rsocials/components/folk-campaign-planner.ts
index 8b00658..91e1204 100644
--- a/modules/rsocials/components/folk-campaign-planner.ts
+++ b/modules/rsocials/components/folk-campaign-planner.ts
@@ -23,6 +23,7 @@ import type {
} from '../schemas';
import { SocialsLocalFirstClient } from '../local-first-client';
import { buildDemoCampaignFlow, PLATFORM_ICONS, PLATFORM_COLORS } from '../campaign-data';
+import { startPresenceHeartbeat } from '../../../shared/collab-presence';
// ── Port definitions ──
@@ -182,6 +183,7 @@ class FolkCampaignPlanner extends HTMLElement {
private localFirstClient: SocialsLocalFirstClient | null = null;
private saveTimer: ReturnType | null = null;
private _lfcUnsub: (() => void) | null = null;
+ private _stopPresence: (() => void) | null = null;
// Context menu
private contextMenuX = 0;
@@ -204,9 +206,11 @@ class FolkCampaignPlanner extends HTMLElement {
connectedCallback() {
this.space = this.getAttribute('space') || 'demo';
this.initData();
+ this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rsocials', context: this.flowName || 'Social Campaigns' }));
}
disconnectedCallback() {
+ this._stopPresence?.();
if (this._lfcUnsub) this._lfcUnsub();
if (this.saveTimer) clearTimeout(this.saveTimer);
if (this._boundPointerMove) document.removeEventListener('pointermove', this._boundPointerMove);
diff --git a/modules/rsplat/components/folk-splat-viewer.ts b/modules/rsplat/components/folk-splat-viewer.ts
index 3a5bf1c..119e7b7 100644
--- a/modules/rsplat/components/folk-splat-viewer.ts
+++ b/modules/rsplat/components/folk-splat-viewer.ts
@@ -9,6 +9,7 @@
import { LightTourEngine } from "../../../shared/tour-engine";
import type { TourStep } from "../../../shared/tour-engine";
+import { startPresenceHeartbeat } from '../../../shared/collab-presence';
import { makeDraggableAll } from "../../../shared/draggable";
import { splatScenesSchema, splatScenesDocId, type SplatScenesDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
@@ -48,6 +49,7 @@ export class FolkSplatViewer extends HTMLElement {
private _uploadMode: "splat" | "generate" = "generate";
private _inlineViewer = false;
private _offlineUnsub: (() => void) | null = null;
+ private _stopPresence: (() => void) | null = null;
private _generatedUrl = "";
private _generatedTitle = "";
private _savedSlug = "";
@@ -84,6 +86,7 @@ export class FolkSplatViewer extends HTMLElement {
this.loadMyHistory();
this.renderGallery();
}
+ this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rsplat', context: this._splatTitle || '3D Viewer' }));
}
private async subscribeOffline() {
@@ -145,6 +148,7 @@ export class FolkSplatViewer extends HTMLElement {
}
disconnectedCallback() {
+ this._stopPresence?.();
this._offlineUnsub?.();
this._offlineUnsub = null;
if (this._viewer) {
diff --git a/modules/rsplat/mod.ts b/modules/rsplat/mod.ts
index 8de0eb4..c0db301 100644
--- a/modules/rsplat/mod.ts
+++ b/modules/rsplat/mod.ts
@@ -725,7 +725,7 @@ routes.get("/", async (c) => {
`,
scripts: `
`,
});
diff --git a/modules/rswag/components/folk-swag-designer.ts b/modules/rswag/components/folk-swag-designer.ts
index 1d416da..d28bf32 100644
--- a/modules/rswag/components/folk-swag-designer.ts
+++ b/modules/rswag/components/folk-swag-designer.ts
@@ -153,6 +153,7 @@ function posterMockupSvg(): string {
import { TourEngine } from "../../../shared/tour-engine";
import { SwagLocalFirstClient } from "../local-first-client";
import type { SwagDoc, SwagDesign } from "../schemas";
+import { startPresenceHeartbeat } from '../../../shared/collab-presence';
// Auth helpers
function getSession(): { accessToken: string; claims: { sub: string; did?: string; username?: string } } | null {
@@ -240,6 +241,7 @@ class FolkSwagDesigner extends HTMLElement {
// Multiplayer state
private lfClient: SwagLocalFirstClient | null = null;
private _lfcUnsub: (() => void) | null = null;
+ private _stopPresence: (() => void) | null = null;
private sharedDesigns: SwagDesign[] = [];
private _tour!: TourEngine;
@@ -279,9 +281,11 @@ class FolkSwagDesigner extends HTMLElement {
if (!localStorage.getItem("rswag_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
+ this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rswag', context: 'Swag Designer' }));
}
disconnectedCallback() {
+ this._stopPresence?.();
this._lfcUnsub?.();
this._lfcUnsub = null;
this.lfClient?.disconnect();
diff --git a/modules/rswag/mod.ts b/modules/rswag/mod.ts
index b41c522..3569926 100644
--- a/modules/rswag/mod.ts
+++ b/modules/rswag/mod.ts
@@ -968,7 +968,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: ``,
- scripts: `
+ scripts: `
`,
styles: ``,
}));
diff --git a/modules/rtasks/components/folk-tasks-board.ts b/modules/rtasks/components/folk-tasks-board.ts
index eb89cbe..aad422c 100644
--- a/modules/rtasks/components/folk-tasks-board.ts
+++ b/modules/rtasks/components/folk-tasks-board.ts
@@ -10,6 +10,7 @@ import { makeDraggableAll } from "../../../shared/draggable";
import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
+import { startPresenceHeartbeat } from '../../../shared/collab-presence';
class FolkTasksBoard extends HTMLElement {
private shadow: ShadowRoot;
@@ -32,6 +33,7 @@ class FolkTasksBoard extends HTMLElement {
private _offlineUnsubs: (() => void)[] = [];
private _history = new ViewHistory<"list" | "board">("list");
private _backlogTaskId: string | null = null;
+ private _stopPresence: (() => void) | null = null;
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '.workspace-card', title: "Workspaces", message: "Select a workspace to open its kanban board.", advanceOnClick: true },
@@ -65,11 +67,13 @@ class FolkTasksBoard extends HTMLElement {
if (!localStorage.getItem("rtasks_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
+ this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rtasks', context: this.workspaceSlug || 'Workspaces' }));
}
disconnectedCallback() {
for (const unsub of this._offlineUnsubs) unsub();
this._offlineUnsubs = [];
+ this._stopPresence?.();
}
private async subscribeOffline() {
diff --git a/modules/rtasks/mod.ts b/modules/rtasks/mod.ts
index 6521e35..68b0044 100644
--- a/modules/rtasks/mod.ts
+++ b/modules/rtasks/mod.ts
@@ -469,7 +469,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: ``,
- scripts: ``,
+ scripts: ``,
styles: ``,
}));
});
diff --git a/modules/rtrips/components/folk-trips-planner.ts b/modules/rtrips/components/folk-trips-planner.ts
index 644c216..363821d 100644
--- a/modules/rtrips/components/folk-trips-planner.ts
+++ b/modules/rtrips/components/folk-trips-planner.ts
@@ -10,6 +10,7 @@ import { tripSchema, type TripDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
+import { startPresenceHeartbeat } from '../../../shared/collab-presence';
class FolkTripsPlanner extends HTMLElement {
private shadow: ShadowRoot;
@@ -20,6 +21,7 @@ class FolkTripsPlanner extends HTMLElement {
private tab: "overview" | "destinations" | "itinerary" | "bookings" | "expenses" | "packing" = "overview";
private error = "";
private _offlineUnsubs: (() => void)[] = [];
+ private _stopPresence: (() => void) | null = null;
private _history = new ViewHistory<"list" | "detail">("list");
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
@@ -50,11 +52,13 @@ class FolkTripsPlanner extends HTMLElement {
if (!localStorage.getItem("rtrips_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
+ this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rtrips', context: this.trip?.name || 'Trips' }));
}
disconnectedCallback() {
for (const unsub of this._offlineUnsubs) unsub();
this._offlineUnsubs = [];
+ this._stopPresence?.();
}
private async subscribeOffline() {
diff --git a/modules/rtrips/mod.ts b/modules/rtrips/mod.ts
index 08a7c67..6ee7ae3 100644
--- a/modules/rtrips/mod.ts
+++ b/modules/rtrips/mod.ts
@@ -594,7 +594,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: ``,
- scripts: ``,
+ scripts: ``,
styles: ``,
}));
});
diff --git a/modules/rtube/components/folk-video-player.ts b/modules/rtube/components/folk-video-player.ts
index 54c93f6..16c04e9 100644
--- a/modules/rtube/components/folk-video-player.ts
+++ b/modules/rtube/components/folk-video-player.ts
@@ -7,6 +7,7 @@
import { TourEngine } from "../../../shared/tour-engine";
import { authFetch, requireAuth } from "../../../shared/auth-fetch";
+import { startPresenceHeartbeat } from '../../../shared/collab-presence';
class FolkVideoPlayer extends HTMLElement {
private shadow: ShadowRoot;
@@ -32,6 +33,7 @@ class FolkVideoPlayer extends HTMLElement {
private hlsPlayers: any[] = [];
private liveSplitStatusInterval: ReturnType | null = null;
private expandedView: number | null = null;
+ private _stopPresence: (() => void) | null = null;
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '[data-mode="library"]', title: "Video Library", message: "Browse your recorded videos — search, select, and play.", advanceOnClick: false },
@@ -57,6 +59,11 @@ class FolkVideoPlayer extends HTMLElement {
if (!localStorage.getItem("rtube_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
+ this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rtube', context: this.currentVideo || 'Video' }));
+ }
+
+ disconnectedCallback() {
+ this._stopPresence?.();
}
private loadDemoData() {
diff --git a/modules/rtube/mod.ts b/modules/rtube/mod.ts
index ea76eb8..3859dee 100644
--- a/modules/rtube/mod.ts
+++ b/modules/rtube/mod.ts
@@ -418,7 +418,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: ``,
- scripts: ``,
+ scripts: ``,
styles: ``,
}));
});
diff --git a/modules/rvnb/components/folk-vnb-view.ts b/modules/rvnb/components/folk-vnb-view.ts
index 528bf4d..31c182c 100644
--- a/modules/rvnb/components/folk-vnb-view.ts
+++ b/modules/rvnb/components/folk-vnb-view.ts
@@ -9,6 +9,7 @@ import { LightTourEngine } from '../../../shared/tour-engine';
import type { TourStep } from '../../../shared/tour-engine';
import './folk-vehicle-card';
import './folk-rental-request';
+import { startPresenceHeartbeat } from '../../../shared/collab-presence';
const VNB_TOUR_STEPS: TourStep[] = [
{ target: '.vnb-search', title: 'Search', message: 'Filter by vehicle type, dates, or economy model.' },
@@ -70,11 +71,17 @@ class FolkVnbView extends HTMLElement {
#map: any = null;
#mapContainer: HTMLElement | null = null;
#tour: LightTourEngine | null = null;
+ private _stopPresence: (() => void) | null = null;
connectedCallback() {
this.#space = this.getAttribute('space') || 'demo';
this.#render();
this.#loadData();
+ this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rvnb', context: 'Venues' }));
+ }
+
+ disconnectedCallback() {
+ this._stopPresence?.();
}
attributeChangedCallback(name: string, _old: string, val: string) {
diff --git a/modules/rvnb/mod.ts b/modules/rvnb/mod.ts
index 7348ca7..99a98bb 100644
--- a/modules/rvnb/mod.ts
+++ b/modules/rvnb/mod.ts
@@ -1282,7 +1282,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: ``,
- scripts: ``,
+ scripts: ``,
styles: `
`,
}));
diff --git a/modules/rvote/components/folk-vote-dashboard.ts b/modules/rvote/components/folk-vote-dashboard.ts
index dd9259d..600dd6e 100644
--- a/modules/rvote/components/folk-vote-dashboard.ts
+++ b/modules/rvote/components/folk-vote-dashboard.ts
@@ -10,6 +10,7 @@ import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
import { authFetch, requireAuth } from "../../../shared/auth-fetch";
+import { startPresenceHeartbeat } from '../../../shared/collab-presence';
interface VoteSpace {
slug: string;
@@ -51,6 +52,7 @@ class FolkVoteDashboard extends HTMLElement {
private space = "";
private view: "spaces" | "proposals" | "proposal" | "rank" = "spaces";
private spaces: VoteSpace[] = [];
+ private _stopPresence: (() => void) | null = null;
private selectedSpace: VoteSpace | null = null;
private proposals: Proposal[] = [];
private selectedProposal: Proposal | null = null;
@@ -96,11 +98,13 @@ class FolkVoteDashboard extends HTMLElement {
if (!localStorage.getItem("rvote_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
+ this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rvote', context: this.selectedProposal?.title || 'Voting' }));
}
disconnectedCallback() {
for (const unsub of this._offlineUnsubs) unsub();
this._offlineUnsubs = [];
+ this._stopPresence?.();
}
private async subscribeOffline() {
diff --git a/modules/rvote/mod.ts b/modules/rvote/mod.ts
index a01b37a..a55c82d 100644
--- a/modules/rvote/mod.ts
+++ b/modules/rvote/mod.ts
@@ -761,7 +761,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: ``,
- scripts: ``,
+ scripts: ``,
styles: ``,
}));
});
diff --git a/modules/rwallet/components/folk-wallet-viewer.ts b/modules/rwallet/components/folk-wallet-viewer.ts
index 8669a0e..3bbd11e 100644
--- a/modules/rwallet/components/folk-wallet-viewer.ts
+++ b/modules/rwallet/components/folk-wallet-viewer.ts
@@ -15,6 +15,7 @@ import type { ProtocolComparison, SandboxAsset } from "../lib/yield-sandbox";
import { TourEngine } from "../../../shared/tour-engine";
import { WalletLocalFirstClient } from "../local-first-client";
import type { WalletDoc, WatchedAddress } from "../schemas";
+import { startPresenceHeartbeat } from '../../../shared/collab-presence';
interface ChainInfo {
chainId: string;
@@ -210,6 +211,7 @@ class FolkWalletViewer extends HTMLElement {
private lfClient: WalletLocalFirstClient | null = null;
private _lfcUnsub: (() => void) | null = null;
private watchedAddresses: WatchedAddress[] = [];
+ private _stopPresence: (() => void) | null = null;
constructor() {
super();
@@ -254,6 +256,11 @@ class FolkWalletViewer extends HTMLElement {
if (!localStorage.getItem("rwallet_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
+ this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rwallet', context: 'Wallet' }));
+ }
+
+ disconnectedCallback() {
+ this._stopPresence?.();
}
private checkAuthState() {
diff --git a/modules/rwallet/mod.ts b/modules/rwallet/mod.ts
index c01987e..bec4af3 100644
--- a/modules/rwallet/mod.ts
+++ b/modules/rwallet/mod.ts
@@ -1239,7 +1239,7 @@ function renderWallet(spaceSlug: string, initialView?: string) {
modules: getModuleInfoList(),
theme: "dark",
body: ``,
- scripts: ``,
+ scripts: ``,
styles: ``,
});
}
diff --git a/shared/collab-presence.ts b/shared/collab-presence.ts
new file mode 100644
index 0000000..74d1f21
--- /dev/null
+++ b/shared/collab-presence.ts
@@ -0,0 +1,73 @@
+/**
+ * Shared presence broadcaster for rApps.
+ *
+ * Any rApp component can call broadcastPresence() or startPresenceHeartbeat()
+ * to announce the user's current module and context to all peers in the space.
+ * The collab overlay listens for these messages and displays module context
+ * in the people panel.
+ */
+
+// ── Identity helpers ──
+
+function getSessionInfo(): { username: string; userId: string } {
+ try {
+ const sess = JSON.parse(localStorage.getItem('encryptid_session') || '{}');
+ return {
+ username: sess?.username || sess?.displayName || 'Anonymous',
+ userId: sess?.userId || sess?.sub || 'anon',
+ };
+ } catch {
+ return { username: 'Anonymous', userId: 'anon' };
+ }
+}
+
+function userColor(id: string): string {
+ let hash = 0;
+ for (let i = 0; i < id.length; i++) {
+ hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0;
+ }
+ const hue = Math.abs(hash) % 360;
+ return `hsl(${hue}, 70%, 50%)`;
+}
+
+// ── Public API ──
+
+export interface PresenceOpts {
+ module: string;
+ context?: string; // human-readable label (e.g. "Notebook > Note Title")
+ notebookId?: string; // module-specific metadata
+ noteId?: string; // module-specific metadata
+ itemId?: string; // generic item ID
+}
+
+/**
+ * Broadcast current user's presence to all peers in the space.
+ * Safe to call even if runtime isn't ready — silently no-ops.
+ */
+export function broadcastPresence(opts: PresenceOpts): void {
+ const runtime = (window as any).__rspaceOfflineRuntime;
+ if (!runtime?.isInitialized || !runtime.isOnline) return;
+ const session = getSessionInfo();
+ runtime.sendCustom({
+ type: 'presence',
+ module: opts.module,
+ context: opts.context || '',
+ notebookId: opts.notebookId,
+ noteId: opts.noteId,
+ itemId: opts.itemId,
+ username: session.username,
+ color: userColor(session.userId),
+ });
+}
+
+/**
+ * Start a 10-second heartbeat that broadcasts presence.
+ * Returns a cleanup function to stop the heartbeat.
+ *
+ * @param getOpts - Called each heartbeat to get current presence state
+ */
+export function startPresenceHeartbeat(getOpts: () => PresenceOpts): () => void {
+ broadcastPresence(getOpts());
+ const timer = setInterval(() => broadcastPresence(getOpts()), 10_000);
+ return () => clearInterval(timer);
+}
diff --git a/shared/components/rstack-collab-overlay.ts b/shared/components/rstack-collab-overlay.ts
index 04f4716..620d99d 100644
--- a/shared/components/rstack-collab-overlay.ts
+++ b/shared/components/rstack-collab-overlay.ts
@@ -32,6 +32,8 @@ interface PeerState {
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 {
@@ -43,6 +45,7 @@ export class RStackCollabOverlay extends HTMLElement {
#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;
@@ -101,6 +104,7 @@ export class RStackCollabOverlay extends HTMLElement {
if (!this.#externalPeers) {
window.removeEventListener('rspace-doc-subscribe', this.#onDocSubscribe);
this.#unsubAwareness?.();
+ this.#unsubPresence?.();
this.#stopMouseTracking();
this.#stopFocusTracking();
}
@@ -183,6 +187,28 @@ export class RStackCollabOverlay extends HTMLElement {
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() {
@@ -282,7 +308,8 @@ export class RStackCollabOverlay extends HTMLElement {
// ── Focus tracking on data-collab-id elements ──
#focusHandler = (e: FocusEvent) => {
- const target = (e.target as HTMLElement)?.closest?.('[data-collab-id]');
+ 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);
@@ -307,7 +334,8 @@ export class RStackCollabOverlay extends HTMLElement {
// Also track clicks on data-collab-id (many elements aren't focusable)
#clickHandler = (e: MouseEvent) => {
- const target = (e.target as HTMLElement)?.closest?.('[data-collab-id]');
+ 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);
@@ -412,10 +440,14 @@ export class RStackCollabOverlay extends HTMLElement {
// Self row with Solo/Share toggle
const isSolo = this.#soloMode;
+ const selfModule = this.#moduleId || '';
fragments.push(`
-
${this.#escHtml(this.#localUsername)} (you)
+
+ ${this.#escHtml(this.#localUsername)} (you)
+ ${selfModule ? `${this.#escHtml(selfModule)}` : ''}
+
@@ -426,10 +458,17 @@ export class RStackCollabOverlay extends HTMLElement {
// 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)}
+
+ ${this.#escHtml(peer.username)}
+ ${ctxStr ? `${this.#escHtml(ctxStr)}` : ''}
+
${isCanvas ? `
` : ''}
`);
@@ -568,13 +607,28 @@ export class RStackCollabOverlay extends HTMLElement {
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 = document.querySelector(`[data-collab-id="${CSS.escape(peer.selection)}"]`);
+ const target = this.#findCollabEl(peer.selection);
if (!target) continue;
const rect = target.getBoundingClientRect();
@@ -761,8 +815,14 @@ const OVERLAY_CSS = `
height: 10px;
}
- .people-row .name {
+ .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;
@@ -770,6 +830,15 @@ const OVERLAY_CSS = `
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;