diff --git a/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts b/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts index 0448c76..0f8b9e4 100644 --- a/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts +++ b/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts @@ -7,6 +7,7 @@ import { TourEngine } from '../../../shared/tour-engine'; import type { TourStep } from '../../../shared/tour-engine'; +import { startPresenceHeartbeat } from '../../../shared/collab-presence'; import { CrowdSurfLocalFirstClient } from '../local-first-client'; import type { CrowdSurfDoc, CrowdSurfPrompt, Contribution } from '../schemas'; import { getDecayProgress, getTimeRemaining, getRightSwipeCount, isReadyToTrigger, getUrgency, parseContributions } from '../schemas'; @@ -68,6 +69,7 @@ class FolkCrowdSurfDashboard extends HTMLElement { // Multiplayer private lfClient: CrowdSurfLocalFirstClient | null = null; private _lfcUnsub: (() => void) | null = null; + private _stopPresence: (() => void) | null = null; // Expiry timer private _expiryTimer: number | null = null; @@ -90,9 +92,11 @@ class FolkCrowdSurfDashboard extends HTMLElement { } else { this.initMultiplayer(); } + this._stopPresence = startPresenceHeartbeat(() => ({ module: 'crowdsurf', context: this.prompts[this.currentPromptIndex]?.title || 'CrowdSurf' })); } disconnectedCallback() { + this._stopPresence?.(); this._lfcUnsub?.(); this._lfcUnsub = null; this.lfClient?.disconnect(); diff --git a/modules/crowdsurf/mod.ts b/modules/crowdsurf/mod.ts index cb27cf1..629064f 100644 --- a/modules/crowdsurf/mod.ts +++ b/modules/crowdsurf/mod.ts @@ -128,7 +128,7 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, + scripts: ``, styles: ``, })); }); diff --git a/modules/rbnb/components/folk-bnb-view.ts b/modules/rbnb/components/folk-bnb-view.ts index 44f850a..7e92501 100644 --- a/modules/rbnb/components/folk-bnb-view.ts +++ b/modules/rbnb/components/folk-bnb-view.ts @@ -9,6 +9,7 @@ import { LightTourEngine } from '../../../shared/tour-engine'; import type { TourStep } from '../../../shared/tour-engine'; import './folk-listing'; import './folk-stay-request'; +import { startPresenceHeartbeat } from '../../../shared/collab-presence'; const BNB_TOUR_STEPS: TourStep[] = [ { target: '.bnb-search', title: 'Search', message: 'Filter listings by location, type, or economy model.' }, @@ -65,11 +66,17 @@ class FolkBnbView 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: 'rbnb', context: 'Listings' })); + } + + disconnectedCallback() { + this._stopPresence?.(); } attributeChangedCallback(name: string, _old: string, val: string) { diff --git a/modules/rbnb/mod.ts b/modules/rbnb/mod.ts index 2b446f2..c74988f 100644 --- a/modules/rbnb/mod.ts +++ b/modules/rbnb/mod.ts @@ -1201,7 +1201,7 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, + scripts: ``, styles: ` `, })); diff --git a/modules/rbooks/components/folk-book-shelf.ts b/modules/rbooks/components/folk-book-shelf.ts index 53254be..0996859 100644 --- a/modules/rbooks/components/folk-book-shelf.ts +++ b/modules/rbooks/components/folk-book-shelf.ts @@ -9,6 +9,7 @@ import { booksCatalogSchema, booksCatalogDocId, type BooksCatalogDoc } from "../ import { makeDraggableAll } from "../../../shared/draggable"; import type { DocumentId } from "../../../shared/local-first/document"; import { TourEngine } from "../../../shared/tour-engine"; +import { startPresenceHeartbeat } from '../../../shared/collab-presence'; interface BookData { id: string; @@ -33,6 +34,7 @@ export class FolkBookShelf extends HTMLElement { private _searchTerm = ""; private _selectedTag: string | null = null; private _offlineUnsub: (() => void) | null = null; + private _stopPresence: (() => void) | null = null; private _tour!: TourEngine; private static readonly TOUR_STEPS = [ { target: '.search-input', title: "Search", message: "Search books by title, author, or description.", advanceOnClick: false }, @@ -77,9 +79,11 @@ export class FolkBookShelf extends HTMLElement { if (!localStorage.getItem("rbooks_tour_done")) { setTimeout(() => this._tour.start(), 1200); } + this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rbooks', context: 'Library' })); } disconnectedCallback() { + this._stopPresence?.(); this._offlineUnsub?.(); this._offlineUnsub = null; } diff --git a/modules/rbooks/mod.ts b/modules/rbooks/mod.ts index 416d63f..83537fc 100644 --- a/modules/rbooks/mod.ts +++ b/modules/rbooks/mod.ts @@ -312,7 +312,7 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, + scripts: ``, styles: ``, })); }); diff --git a/modules/rcal/components/folk-calendar-view.ts b/modules/rcal/components/folk-calendar-view.ts index f007d39..a083324 100644 --- a/modules/rcal/components/folk-calendar-view.ts +++ b/modules/rcal/components/folk-calendar-view.ts @@ -105,6 +105,7 @@ function leafletZoomToSpatial(zoom: number): number { import { calendarSchema, calendarDocId, type CalendarDoc } from "../schemas"; import type { DocumentId } from "../../../shared/local-first/document"; import { TourEngine } from "../../../shared/tour-engine"; +import { startPresenceHeartbeat } from '../../../shared/collab-presence'; // ── Component ── @@ -125,6 +126,7 @@ class FolkCalendarView extends HTMLElement { private filteredSources = new Set(); private boundKeyHandler: ((e: KeyboardEvent) => void) | null = null; private _offlineUnsub: (() => void) | null = null; + private _stopPresence: (() => void) | null = null; // Spatio-temporal state private temporalGranularity = 4; // MONTH @@ -192,11 +194,13 @@ class FolkCalendarView extends HTMLElement { if (!localStorage.getItem("rcal_tour_done")) { setTimeout(() => this._tour.start(), 1200); } + this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rcal', context: this.selectedEvent?.title || 'Calendar' })); } disconnectedCallback() { this._offlineUnsub?.(); this._offlineUnsub = null; + this._stopPresence?.(); if (this.boundKeyHandler) { document.removeEventListener("keydown", this.boundKeyHandler); this.boundKeyHandler = null; diff --git a/modules/rcal/mod.ts b/modules/rcal/mod.ts index 3080f2e..91badcc 100644 --- a/modules/rcal/mod.ts +++ b/modules/rcal/mod.ts @@ -985,7 +985,7 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, + scripts: ``, styles: ` `, })); diff --git a/modules/rcart/components/folk-cart-shop.ts b/modules/rcart/components/folk-cart-shop.ts index fbe9c7e..84226b8 100644 --- a/modules/rcart/components/folk-cart-shop.ts +++ b/modules/rcart/components/folk-cart-shop.ts @@ -12,6 +12,7 @@ import { 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 FolkCartShop extends HTMLElement { private shadow: ShadowRoot; @@ -38,6 +39,7 @@ class FolkCartShop extends HTMLElement { private creatingGroupBuy = false; private _offlineUnsubs: (() => void)[] = []; private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "order-detail" | "payments" | "group-buys">("carts"); + private _stopPresence: (() => void) | null = null; // Guided tour private _tour!: TourEngine; @@ -88,11 +90,13 @@ class FolkCartShop extends HTMLElement { if (!localStorage.getItem("rcart_tour_done")) { setTimeout(() => this._tour.start(), 1200); } + this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rcart', context: this.selectedCatalogItem?.title || this.view })); } disconnectedCallback() { for (const unsub of this._offlineUnsubs) unsub(); this._offlineUnsubs = []; + this._stopPresence?.(); } private async subscribeOffline() { diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts index a9b3e98..6bb2595 100644 --- a/modules/rcart/mod.ts +++ b/modules/rcart/mod.ts @@ -2472,7 +2472,7 @@ function renderShop(space: string, view?: string) { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, + scripts: ``, styles: ``, }); } diff --git a/modules/rchoices/components/folk-choices-dashboard.ts b/modules/rchoices/components/folk-choices-dashboard.ts index a14fa98..29db049 100644 --- a/modules/rchoices/components/folk-choices-dashboard.ts +++ b/modules/rchoices/components/folk-choices-dashboard.ts @@ -8,6 +8,7 @@ import { TourEngine } from "../../../shared/tour-engine"; import { ChoicesLocalFirstClient } from "../local-first-client"; import type { ChoicesDoc, ChoiceSession, ChoiceVote } from "../schemas"; +import { startPresenceHeartbeat } from '../../../shared/collab-presence'; // ── CrowdSurf types ── interface CrowdSurfOption { @@ -53,6 +54,7 @@ class FolkChoicesDashboard extends HTMLElement { /* Multiplayer state */ private lfClient: ChoicesLocalFirstClient | null = null; private _lfcUnsub: (() => void) | null = null; + private _stopPresence: (() => void) | null = null; private sessions: ChoiceSession[] = []; private activeSessionId: string | null = null; private sessionVotes: Map = new Map(); @@ -98,9 +100,11 @@ class FolkChoicesDashboard extends HTMLElement { if (!localStorage.getItem("rchoices_tour_done")) { setTimeout(() => this._tour.start(), 1200); } + this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rchoices', context: this.sessions.find(s => s.id === this.activeSessionId)?.title || 'Choices' })); } disconnectedCallback() { + this._stopPresence?.(); if (this.simTimer !== null) { clearInterval(this.simTimer); this.simTimer = null; diff --git a/modules/rchoices/mod.ts b/modules/rchoices/mod.ts index 5022206..746568e 100644 --- a/modules/rchoices/mod.ts +++ b/modules/rchoices/mod.ts @@ -56,7 +56,7 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, + scripts: ``, styles: ``, })); }); @@ -75,7 +75,7 @@ routes.get("/:tab", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, + scripts: ``, styles: ``, })); }); diff --git a/modules/rdata/components/folk-content-tree.ts b/modules/rdata/components/folk-content-tree.ts index f3908c0..c2cee64 100644 --- a/modules/rdata/components/folk-content-tree.ts +++ b/modules/rdata/components/folk-content-tree.ts @@ -8,6 +8,7 @@ import { TourEngine } from '../../../shared/tour-engine'; import type { TourStep } from '../../../shared/tour-engine'; +import { startPresenceHeartbeat } from '../../../shared/collab-presence'; interface TreeItem { docId: string; @@ -139,6 +140,7 @@ class FolkContentTree extends HTMLElement { private expanded = new Set(); private allTags: string[] = []; private loading = true; + private _stopPresence: (() => void) | null = null; constructor() { super(); @@ -154,6 +156,11 @@ class FolkContentTree extends HTMLElement { connectedCallback() { this.space = this.getAttribute("space") || "demo"; this.loadData(); + this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rdata', context: 'Data' })); + } + + disconnectedCallback() { + this._stopPresence?.(); } private async loadData() { diff --git a/modules/rdata/mod.ts b/modules/rdata/mod.ts index d4836cd..5c95a52 100644 --- a/modules/rdata/mod.ts +++ b/modules/rdata/mod.ts @@ -252,7 +252,7 @@ function renderDataPage(space: string, activeTab: string) { ? `` : ``; const scripts = isTree - ? `` + ? `` : ``; return renderShell({ diff --git a/modules/rfiles/components/folk-file-browser.ts b/modules/rfiles/components/folk-file-browser.ts index e11daa2..5adae03 100644 --- a/modules/rfiles/components/folk-file-browser.ts +++ b/modules/rfiles/components/folk-file-browser.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 { authFetch, requireAuth } from "../../../shared/auth-fetch"; +import { startPresenceHeartbeat } from '../../../shared/collab-presence'; class FolkFileBrowser extends HTMLElement { private shadow: ShadowRoot; @@ -19,6 +20,7 @@ class FolkFileBrowser extends HTMLElement { private tab: "files" | "cards" = "files"; private loading = false; private _offlineUnsubs: (() => void)[] = []; + private _stopPresence: (() => void) | null = null; private _tour!: TourEngine; private static readonly TOUR_STEPS = [ { target: '.tab-btn[data-tab="files"]', title: "Files Tab", message: "Browse uploaded files — download, share, or delete them.", advanceOnClick: true }, @@ -51,9 +53,11 @@ class FolkFileBrowser extends HTMLElement { if (!localStorage.getItem("rfiles_tour_done")) { setTimeout(() => this._tour.start(), 1200); } + this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rfiles', context: 'Files' })); } disconnectedCallback() { + this._stopPresence?.(); for (const unsub of this._offlineUnsubs) unsub(); this._offlineUnsubs = []; } diff --git a/modules/rfiles/mod.ts b/modules/rfiles/mod.ts index e7f5222..b841b73 100644 --- a/modules/rfiles/mod.ts +++ b/modules/rfiles/mod.ts @@ -625,7 +625,7 @@ routes.get("/", (c) => { theme: "dark", body: ` `, - scripts: ``, + scripts: ``, styles: ``, })); }); diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index fd425a1..47c6b5e 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -20,6 +20,7 @@ import { mapFlowToNodes } from "../lib/map-flow"; import { flowsSchema, flowsDocId, type FlowsDoc, type CanvasFlow } from "../schemas"; import type { DocumentId } from "../../../shared/local-first/document"; import { FlowsLocalFirstClient } from "../local-first-client"; +import { startPresenceHeartbeat } from '../../../shared/collab-presence'; interface FlowSummary { @@ -166,6 +167,7 @@ class FolkFlowsApp extends HTMLElement { private flowDropdownOpen = false; private flowManagerOpen = false; private _lfcUnsub: (() => void) | null = null; + private _stopPresence: (() => void) | null = null; // Mortgage state private mortgagePositions: MortgagePosition[] = []; @@ -233,6 +235,8 @@ class FolkFlowsApp extends HTMLElement { const viewAttr = this.getAttribute("view"); this.view = viewAttr === "budgets" ? "budgets" : viewAttr === "mortgage" ? "mortgage" : "detail"; + this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rflows', context: this.flowName || this.currentFlowId || 'Flow' })); + if (this.view === "budgets") { this.initBudgetView(); return; @@ -362,6 +366,7 @@ class FolkFlowsApp extends HTMLElement { this._offlineUnsub = null; this._lfcUnsub?.(); this._lfcUnsub = null; + this._stopPresence?.(); this._budgetLfcUnsub?.(); this._budgetLfcUnsub = null; if (this.saveTimer) { clearTimeout(this.saveTimer); this.saveTimer = null; } diff --git a/modules/rflows/mod.ts b/modules/rflows/mod.ts index 5a1684c..b449aae 100644 --- a/modules/rflows/mod.ts +++ b/modules/rflows/mod.ts @@ -785,7 +785,7 @@ routes.post("/api/budgets/segments", async (c) => { const flowsScripts = ` - + `; const flowsStyles = ``; diff --git a/modules/rforum/components/folk-forum-dashboard.ts b/modules/rforum/components/folk-forum-dashboard.ts index 866dc03..715a78d 100644 --- a/modules/rforum/components/folk-forum-dashboard.ts +++ b/modules/rforum/components/folk-forum-dashboard.ts @@ -9,6 +9,7 @@ import { forumSchema, FORUM_DOC_ID, type ForumDoc } 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 FolkForumDashboard extends HTMLElement { private shadow: ShadowRoot; @@ -20,6 +21,7 @@ class FolkForumDashboard extends HTMLElement { private pollTimer: number | null = null; private space = ""; private _offlineUnsub: (() => void) | null = null; + private _stopPresence: (() => void) | null = null; private _history = new ViewHistory<"list" | "detail" | "create">("list"); private _tour!: TourEngine; private static readonly TOUR_STEPS = [ @@ -50,6 +52,7 @@ class FolkForumDashboard extends HTMLElement { if (!localStorage.getItem("rforum_tour_done")) { setTimeout(() => this._tour.start(), 1200); } + this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rforum', context: this.selectedInstance?.name || 'Forum' })); } private loadDemoData() { @@ -63,6 +66,7 @@ class FolkForumDashboard extends HTMLElement { } disconnectedCallback() { + this._stopPresence?.(); if (this.pollTimer) clearInterval(this.pollTimer); this._offlineUnsub?.(); this._offlineUnsub = null; diff --git a/modules/rforum/mod.ts b/modules/rforum/mod.ts index c1a0c84..585d224 100644 --- a/modules/rforum/mod.ts +++ b/modules/rforum/mod.ts @@ -194,7 +194,7 @@ routes.get("/", (c) => { theme: "dark", body: ` `, - scripts: ``, + scripts: ``, styles: ``, })); }); diff --git a/modules/rinbox/components/folk-inbox-client.ts b/modules/rinbox/components/folk-inbox-client.ts index a08800a..1629781 100644 --- a/modules/rinbox/components/folk-inbox-client.ts +++ b/modules/rinbox/components/folk-inbox-client.ts @@ -11,6 +11,7 @@ import type { DocumentId } from "../../../shared/local-first/document"; import { TourEngine } from "../../../shared/tour-engine"; import { ViewHistory } from "../../../shared/view-history.js"; import { getAccessToken, getUsername } from "../../../lib/rspace-header"; +import { startPresenceHeartbeat } from '../../../shared/collab-presence'; type ComposeMode = 'new' | 'reply' | 'reply-all' | 'forward'; @@ -18,6 +19,7 @@ class FolkInboxClient extends HTMLElement { private shadow: ShadowRoot; private space = "demo"; private _offlineUnsubs: (() => void)[] = []; + private _stopPresence: (() => void) | null = null; private view: "mailboxes" | "threads" | "thread" | "approvals" | "personal" | "agents" = "mailboxes"; private mailboxes: any[] = []; private threads: any[] = []; @@ -120,11 +122,13 @@ class FolkInboxClient extends HTMLElement { if (!localStorage.getItem("rinbox_tour_done")) { setTimeout(() => this._tour.start(), 1200); } + this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rinbox', context: this.currentThread?.subject || 'Inbox' })); } disconnectedCallback() { for (const unsub of this._offlineUnsubs) unsub(); this._offlineUnsubs = []; + this._stopPresence?.(); } /** Extract username from EncryptID session */ diff --git a/modules/rinbox/mod.ts b/modules/rinbox/mod.ts index a40f831..8ff8221 100644 --- a/modules/rinbox/mod.ts +++ b/modules/rinbox/mod.ts @@ -1731,7 +1731,7 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, + scripts: ``, styles: ``, })); }); diff --git a/modules/rmaps/components/folk-map-viewer.ts b/modules/rmaps/components/folk-map-viewer.ts index 36a9eb9..dc4bc57 100644 --- a/modules/rmaps/components/folk-map-viewer.ts +++ b/modules/rmaps/components/folk-map-viewer.ts @@ -19,6 +19,7 @@ import { ViewHistory } from "../../../shared/view-history.js"; import { requireAuth } from "../../../shared/auth-fetch"; import { getUsername } from "../../../shared/components/rstack-identity"; import { MapsLocalFirstClient } from "../local-first-client"; +import { startPresenceHeartbeat } from '../../../shared/collab-presence'; // MapLibre loaded via CDN — use window access with type assertion @@ -114,6 +115,7 @@ class FolkMapViewer extends HTMLElement { private thumbnailTimer: ReturnType | null = null; private _themeObserver: MutationObserver | null = null; private _history = new ViewHistory<"lobby" | "map">("lobby"); + private _stopPresence: (() => void) | null = null; // Chat + Local-first state private lfClient: MapsLocalFirstClient | null = null; @@ -181,9 +183,11 @@ class FolkMapViewer extends HTMLElement { // Start with interactions disabled this.setMapInteractive(false); } + this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rmaps', context: this.room || 'Maps' })); } disconnectedCallback() { + this._stopPresence?.(); this.leaveRoom(); if (this._themeObserver) { this._themeObserver.disconnect(); diff --git a/modules/rmaps/mod.ts b/modules/rmaps/mod.ts index 21226dd..68606aa 100644 --- a/modules/rmaps/mod.ts +++ b/modules/rmaps/mod.ts @@ -274,7 +274,7 @@ routes.get("/", (c) => { spaceSlug: space, modules: getModuleInfoList(), body: ``, - scripts: ``, + scripts: ``, styles: ``, })); }); @@ -291,7 +291,7 @@ routes.get("/:room", (c) => { modules: getModuleInfoList(), styles: ``, body: ``, - scripts: ``, + scripts: ``, })); }); diff --git a/modules/rmeets/components/folk-jitsi-room.ts b/modules/rmeets/components/folk-jitsi-room.ts index 745206a..8aac782 100644 --- a/modules/rmeets/components/folk-jitsi-room.ts +++ b/modules/rmeets/components/folk-jitsi-room.ts @@ -6,6 +6,8 @@ * the local video track to a 360° perspective view. */ +import { startPresenceHeartbeat } from '../../../shared/collab-presence'; + class FolkJitsiRoom extends HTMLElement { private shadow: ShadowRoot; private api: any = null; @@ -22,6 +24,7 @@ class FolkJitsiRoom extends HTMLElement { private directorStream: MediaStream | null = null; private directorAnimFrame: number | null = null; private directorError = ""; + private _stopPresence: (() => void) | null = null; constructor() { super(); @@ -37,9 +40,11 @@ class FolkJitsiRoom extends HTMLElement { this.render(); this.loadJitsiApi(); + this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rmeets', context: this.room || 'Meeting' })); } disconnectedCallback() { + this._stopPresence?.(); this.dispose(); } diff --git a/modules/rmeets/mod.ts b/modules/rmeets/mod.ts index 81813da..73fd1fc 100644 --- a/modules/rmeets/mod.ts +++ b/modules/rmeets/mod.ts @@ -128,7 +128,7 @@ routes.get("/room/:room", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, + scripts: ``, })); }); diff --git a/modules/rnetwork/components/folk-crm-view.ts b/modules/rnetwork/components/folk-crm-view.ts index 728bc60..d164ed2 100644 --- a/modules/rnetwork/components/folk-crm-view.ts +++ b/modules/rnetwork/components/folk-crm-view.ts @@ -67,6 +67,7 @@ const STAGE_LABELS: Record = { }; import { TourEngine } from "../../../shared/tour-engine"; +import { startPresenceHeartbeat } from '../../../shared/collab-presence'; class FolkCrmView extends HTMLElement { private shadow: ShadowRoot; @@ -102,6 +103,8 @@ class FolkCrmView extends HTMLElement { private graphPanStartPanY = 0; private graphSelectedId: string | null = null; + private _stopPresence: (() => void) | null = null; + // Guided tour private _tour!: TourEngine; private static readonly TOUR_STEPS = [ @@ -155,10 +158,12 @@ class FolkCrmView extends HTMLElement { if (!localStorage.getItem("rnetwork_tour_done")) { setTimeout(() => this._tour.start(), 1200); } + this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rnetwork', context: this.graphSelectedId ? 'CRM' : `CRM - ${this.activeTab}` })); } disconnectedCallback() { document.removeEventListener("rapp-tab-change", this._onTabChange); + this._stopPresence?.(); } private getApiBase(): string { diff --git a/modules/rnetwork/components/folk-graph-viewer.ts b/modules/rnetwork/components/folk-graph-viewer.ts index 85ab17b..4d0a70b 100644 --- a/modules/rnetwork/components/folk-graph-viewer.ts +++ b/modules/rnetwork/components/folk-graph-viewer.ts @@ -6,6 +6,8 @@ * Left-drag pans, scroll zooms, right-drag orbits. */ +import { startPresenceHeartbeat } from '../../../shared/collab-presence'; + interface WeightAccounting { delegatedAway: Record; // per authority: total weight delegated out receivedWeight: Record; // per authority: total weight received from others @@ -163,6 +165,7 @@ class FolkGraphViewer extends HTMLElement { // Sprite caches — avoid recreating Canvas+Texture per node per frame private _textSpriteCache = new Map(); private _badgeSpriteCache = new Map(); + private _stopPresence: (() => void) | null = null; constructor() { super(); @@ -173,9 +176,11 @@ class FolkGraphViewer extends HTMLElement { this.space = this.getAttribute("space") || "demo"; this.renderDOM(); this.loadData(); + this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rnetwork', context: 'Network Graph' })); } disconnectedCallback() { + this._stopPresence?.(); if (this.resizeObserver) { this.resizeObserver.disconnect(); this.resizeObserver = null; diff --git a/modules/rnetwork/mod.ts b/modules/rnetwork/mod.ts index 2a592ae..5c46d46 100644 --- a/modules/rnetwork/mod.ts +++ b/modules/rnetwork/mod.ts @@ -692,7 +692,7 @@ function renderCrm(space: string, activeTab: string) { spaceSlug: space, modules: getModuleInfoList(), body: ``, - scripts: ` + scripts: ` `, styles: ``, @@ -733,7 +733,7 @@ routes.get("/", (c) => { modules: getModuleInfoList(), head: GRAPH3D_HEAD, body: ``, - scripts: ``, + scripts: ``, styles: ``, })); }); diff --git a/modules/rnotes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts index c522b87..f2b4f5b 100644 --- a/modules/rnotes/components/folk-notes-app.ts +++ b/modules/rnotes/components/folk-notes-app.ts @@ -14,6 +14,7 @@ import { makeDraggableAll } from '../../../shared/draggable'; import { notebookSchema } from '../schemas'; import type { DocumentId } from '../../../shared/local-first/document'; import { getAccessToken } from '../../../shared/components/rstack-identity'; +import { broadcastPresence as sharedBroadcastPresence, startPresenceHeartbeat } from '../../../shared/collab-presence'; import { Editor } from '@tiptap/core'; import StarterKit from '@tiptap/starter-kit'; import Link from '@tiptap/extension-link'; @@ -190,9 +191,9 @@ class FolkNotesApp extends HTMLElement { noteId: string | null; lastSeen: number; }> = new Map(); - private _presenceHeartbeat: ReturnType | null = null; - private _presenceGC: ReturnType | null = null; + private _stopPresence: (() => void) | null = null; private _presenceUnsub: (() => void) | null = null; + private _presenceGC: ReturnType | 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;