feat(collab): unified presence system across all 27 rApps

Harmonize the two disconnected presence systems into one:
- New shared/collab-presence.ts utility (broadcastPresence, startPresenceHeartbeat)
- Collab overlay now listens to custom presence messages, shows module context in people panel
- Fixed Shadow DOM focus tracking using composedPath() for focus rings through shadow boundaries
- Replaced rNotes custom presence with shared utility (kept sidebar dots)
- Added presence heartbeat to all 27 rApp components with dynamic context strings
- Bumped cache versions in all modified mod.ts files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 16:56:06 -07:00
parent 677a69645e
commit df8631360e
56 changed files with 334 additions and 58 deletions

View File

@ -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();

View File

@ -128,7 +128,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-crowdsurf-dashboard space="${spaceSlug}"></folk-crowdsurf-dashboard>`,
scripts: `<script type="module" src="/modules/crowdsurf/folk-crowdsurf-dashboard.js?v=1"></script>`,
scripts: `<script type="module" src="/modules/crowdsurf/folk-crowdsurf-dashboard.js?v=2"></script>`,
styles: `<link rel="stylesheet" href="/modules/crowdsurf/crowdsurf.css">`,
}));
});

View File

@ -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) {

View File

@ -1201,7 +1201,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-bnb-view space="${space}"></folk-bnb-view>`,
scripts: `<script type="module" src="/modules/rbnb/folk-bnb-view.js"></script>`,
scripts: `<script type="module" src="/modules/rbnb/folk-bnb-view.js?v=2"></script>`,
styles: `<link rel="stylesheet" href="/modules/rbnb/bnb.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">`,
}));

View File

@ -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;
}

View File

@ -312,7 +312,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-book-shelf space-slug="${spaceSlug}"></folk-book-shelf>`,
scripts: `<script type="module" src="/modules/rbooks/folk-book-shelf.js"></script>`,
scripts: `<script type="module" src="/modules/rbooks/folk-book-shelf.js?v=2"></script>`,
styles: `<link rel="stylesheet" href="/modules/rbooks/books.css">`,
}));
});

View File

@ -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<string>();
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;

View File

@ -985,7 +985,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-calendar-view space="${space}"></folk-calendar-view>`,
scripts: `<script type="module" src="/modules/rcal/folk-calendar-view.js"></script>`,
scripts: `<script type="module" src="/modules/rcal/folk-calendar-view.js?v=2"></script>`,
styles: `<link rel="stylesheet" href="/modules/rcal/cal.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">`,
}));

View File

@ -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() {

View File

@ -2472,7 +2472,7 @@ function renderShop(space: string, view?: string) {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-cart-shop space="${space}"${viewAttr}></folk-cart-shop>`,
scripts: `<script type="module" src="/modules/rcart/folk-cart-shop.js"></script>`,
scripts: `<script type="module" src="/modules/rcart/folk-cart-shop.js?v=2"></script>`,
styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
});
}

View File

@ -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<string, ChoiceVote[]> = 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;

View File

@ -56,7 +56,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-choices-dashboard space="${spaceSlug}" tab="spider"></folk-choices-dashboard>`,
scripts: `<script type="module" src="/modules/rchoices/folk-choices-dashboard.js?v=6"></script>`,
scripts: `<script type="module" src="/modules/rchoices/folk-choices-dashboard.js?v=7"></script>`,
styles: `<link rel="stylesheet" href="/modules/rchoices/choices.css">`,
}));
});
@ -75,7 +75,7 @@ routes.get("/:tab", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-choices-dashboard space="${spaceSlug}" tab="${tab}"></folk-choices-dashboard>`,
scripts: `<script type="module" src="/modules/rchoices/folk-choices-dashboard.js?v=6"></script>`,
scripts: `<script type="module" src="/modules/rchoices/folk-choices-dashboard.js?v=7"></script>`,
styles: `<link rel="stylesheet" href="/modules/rchoices/choices.css">`,
}));
});

View File

@ -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<string>();
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() {

View File

@ -252,7 +252,7 @@ function renderDataPage(space: string, activeTab: string) {
? `<folk-content-tree space="${space}"></folk-content-tree>`
: `<folk-analytics-view space="${space}"></folk-analytics-view>`;
const scripts = isTree
? `<script type="module" src="/modules/rdata/folk-content-tree.js"></script>`
? `<script type="module" src="/modules/rdata/folk-content-tree.js?v=2"></script>`
: `<script type="module" src="/modules/rdata/folk-analytics-view.js"></script>`;
return renderShell({

View File

@ -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 = [];
}

View File

@ -625,7 +625,7 @@ routes.get("/", (c) => {
theme: "dark",
body: `<div class="rapp-nav" style="padding:0 1rem;margin-top:8px"><span class="rapp-nav__title"></span><a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a></div>
<folk-file-browser space="${spaceSlug}"></folk-file-browser>`,
scripts: `<script type="module" src="/modules/rfiles/folk-file-browser.js"></script>`,
scripts: `<script type="module" src="/modules/rfiles/folk-file-browser.js?v=2"></script>`,
styles: `<link rel="stylesheet" href="/modules/rfiles/files.css">`,
}));
});

View File

@ -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; }

View File

@ -785,7 +785,7 @@ routes.post("/api/budgets/segments", async (c) => {
const flowsScripts = `
<script src="https://cdn.jsdelivr.net/npm/lz-string@1.5.0/libs/lz-string.min.js"></script>
<script type="module" src="/modules/rflows/folk-flows-app.js?v=3"></script>
<script type="module" src="/modules/rflows/folk-flows-app.js?v=4"></script>
<script type="module" src="/modules/rflows/folk-flow-river.js?v=3"></script>`;
const flowsStyles = `<link rel="stylesheet" href="/modules/rflows/flows.css">`;

View File

@ -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;

View File

@ -194,7 +194,7 @@ routes.get("/", (c) => {
theme: "dark",
body: `<div class="rapp-nav" style="padding:0 1rem;margin-top:8px"><span class="rapp-nav__title"></span><a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a></div>
<folk-forum-dashboard space="${spaceSlug}"></folk-forum-dashboard>`,
scripts: `<script type="module" src="/modules/rforum/folk-forum-dashboard.js"></script>`,
scripts: `<script type="module" src="/modules/rforum/folk-forum-dashboard.js?v=2"></script>`,
styles: `<link rel="stylesheet" href="/modules/rforum/forum.css">`,
}));
});

View File

@ -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 */

View File

@ -1731,7 +1731,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-inbox-client space="${space}"></folk-inbox-client>`,
scripts: `<script type="module" src="/modules/rinbox/folk-inbox-client.js"></script>`,
scripts: `<script type="module" src="/modules/rinbox/folk-inbox-client.js?v=2"></script>`,
styles: `<link rel="stylesheet" href="/modules/rinbox/inbox.css">`,
}));
});

View File

@ -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<typeof setTimeout> | 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();

View File

@ -274,7 +274,7 @@ routes.get("/", (c) => {
spaceSlug: space,
modules: getModuleInfoList(),
body: `<folk-map-viewer space="${space}"></folk-map-viewer>`,
scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js?v=3"></script>`,
scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js?v=4"></script>`,
styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css?v=3">`,
}));
});
@ -291,7 +291,7 @@ routes.get("/:room", (c) => {
modules: getModuleInfoList(),
styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css?v=3">`,
body: `<folk-map-viewer space="${space}" room="${room}"></folk-map-viewer>`,
scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js?v=3"></script>`,
scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js?v=4"></script>`,
}));
});

View File

@ -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();
}

View File

@ -128,7 +128,7 @@ routes.get("/room/:room", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-jitsi-room room="${escapeHtml(room)}" jitsi-url="${escapeHtml(JITSI_URL)}" space="${escapeHtml(space)}"${director ? ` director="1" session="${escapeHtml(sessionId)}"` : ""}></folk-jitsi-room>`,
scripts: `<script type="module" src="/modules/rmeets/components/folk-jitsi-room.js"></script>`,
scripts: `<script type="module" src="/modules/rmeets/components/folk-jitsi-room.js?v=2"></script>`,
}));
});

View File

@ -67,6 +67,7 @@ const STAGE_LABELS: Record<string, string> = {
};
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 {

View File

@ -6,6 +6,8 @@
* Left-drag pans, scroll zooms, right-drag orbits.
*/
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
interface WeightAccounting {
delegatedAway: Record<string, number>; // per authority: total weight delegated out
receivedWeight: Record<string, number>; // 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<string, any>();
private _badgeSpriteCache = new Map<string, any>();
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;

View File

@ -692,7 +692,7 @@ function renderCrm(space: string, activeTab: string) {
spaceSlug: space,
modules: getModuleInfoList(),
body: `<folk-crm-view space="${space}"></folk-crm-view>`,
scripts: `<script type="module" src="/modules/rnetwork/folk-crm-view.js"></script>
scripts: `<script type="module" src="/modules/rnetwork/folk-crm-view.js?v=2"></script>
<script type="module" src="/modules/rnetwork/folk-delegation-manager.js"></script>
<script type="module" src="/modules/rnetwork/folk-trust-sankey.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
@ -733,7 +733,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
head: GRAPH3D_HEAD,
body: `<folk-graph-viewer space="${space}"></folk-graph-viewer>`,
scripts: `<script type="module" src="/modules/rnetwork/folk-graph-viewer.js"></script>`,
scripts: `<script type="module" src="/modules/rnetwork/folk-graph-viewer.js?v=2"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
}));
});

View File

@ -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<typeof setInterval> | null = null;
private _presenceGC: ReturnType<typeof setInterval> | null = null;
private _stopPresence: (() => void) | null = null;
private _presenceUnsub: (() => void) | null = null;
private _presenceGC: ReturnType<typeof setInterval> | null = null;
// ── Demo data ──
private demoNotebooks: (Notebook & { notes: Note[] })[] = [];
@ -1452,12 +1453,11 @@ Gear: EUR 400 (10%)</code></pre><p><em>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%)</code></pre><p><em>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%)</code></pre><p><em>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%)</code></pre><p><em>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();
}

View File

@ -1615,7 +1615,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-notes-app space="${space}"></folk-notes-app>`,
scripts: `<script type="module" src="/modules/rnotes/folk-notes-app.js?v=5"></script>`,
scripts: `<script type="module" src="/modules/rnotes/folk-notes-app.js?v=6"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnotes/notes.css?v=5">`,
}));
});

View File

@ -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() {

View File

@ -133,7 +133,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-photo-gallery space="${spaceSlug}"></folk-photo-gallery>`,
scripts: `<script type="module" src="/modules/rphotos/folk-photo-gallery.js"></script>`,
scripts: `<script type="module" src="/modules/rphotos/folk-photo-gallery.js?v=2"></script>`,
styles: `<link rel="stylesheet" href="/modules/rphotos/photos.css">`,
}));
});

View File

@ -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<typeof setTimeout> | 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() {

View File

@ -668,7 +668,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-pubs-editor space="${spaceSlug}"></folk-pubs-editor>`,
scripts: `<script type="module" src="/modules/rpubs/folk-pubs-editor.js"></script>
scripts: `<script type="module" src="/modules/rpubs/folk-pubs-editor.js?v=2"></script>
<script type="module" src="/modules/rpubs/folk-pubs-flipbook.js"></script>
<script type="module" src="/modules/rpubs/folk-pubs-publish-panel.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rpubs/pubs.css">`,

View File

@ -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() {

View File

@ -809,7 +809,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-schedule-app space="${space}"></folk-schedule-app>`,
scripts: `<script type="module" src="/modules/rschedule/folk-schedule-app.js"></script>`,
scripts: `<script type="module" src="/modules/rschedule/folk-schedule-app.js?v=2"></script>`,
styles: `<link rel="stylesheet" href="/modules/rschedule/schedule.css">`,
}),
);

View File

@ -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<typeof setTimeout> | 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);

View File

@ -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) {

View File

@ -725,7 +725,7 @@ routes.get("/", async (c) => {
`,
scripts: `
<script type="module">
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=10';
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=11';
const gallery = document.getElementById('gallery');
gallery.splats = ${splatsJSON};
gallery.spaceSlug = '${spaceSlug}';
@ -786,7 +786,7 @@ function renderViewerPage(spaceSlug: string, dataSpace: string, idOrSlug: string
`,
scripts: `
<script type="module">
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=10';
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=11';
</script>
`,
});

View File

@ -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();

View File

@ -968,7 +968,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-swag-designer space="${space}"></folk-swag-designer>`,
scripts: `<script type="module" src="/modules/rswag/folk-swag-designer.js?v=3"></script>
scripts: `<script type="module" src="/modules/rswag/folk-swag-designer.js?v=4"></script>
<script type="module" src="/modules/rswag/folk-revenue-sankey.js?v=1"></script>`,
styles: `<link rel="stylesheet" href="/modules/rswag/swag.css">`,
}));

View File

@ -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() {

View File

@ -469,7 +469,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-tasks-board space="${space}"></folk-tasks-board>`,
scripts: `<script type="module" src="/modules/rtasks/folk-tasks-board.js?v=2"></script>`,
scripts: `<script type="module" src="/modules/rtasks/folk-tasks-board.js?v=3"></script>`,
styles: `<link rel="stylesheet" href="/modules/rtasks/tasks.css">`,
}));
});

View File

@ -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() {

View File

@ -594,7 +594,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-trips-planner space="${space}"></folk-trips-planner>`,
scripts: `<script type="module" src="/modules/rtrips/folk-trips-planner.js"></script>`,
scripts: `<script type="module" src="/modules/rtrips/folk-trips-planner.js?v=2"></script>`,
styles: `<link rel="stylesheet" href="/modules/rtrips/trips.css">`,
}));
});

View File

@ -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<typeof setInterval> | 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() {

View File

@ -418,7 +418,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-video-player space="${space}"></folk-video-player>`,
scripts: `<script type="module" src="/modules/rtube/folk-video-player.js"></script>`,
scripts: `<script type="module" src="/modules/rtube/folk-video-player.js?v=2"></script>`,
styles: `<link rel="stylesheet" href="/modules/rtube/tube.css">`,
}));
});

View File

@ -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) {

View File

@ -1282,7 +1282,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-vnb-view space="${space}"></folk-vnb-view>`,
scripts: `<script type="module" src="/modules/rvnb/folk-vnb-view.js"></script>`,
scripts: `<script type="module" src="/modules/rvnb/folk-vnb-view.js?v=2"></script>`,
styles: `<link rel="stylesheet" href="/modules/rvnb/rvnb.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">`,
}));

View File

@ -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() {

View File

@ -761,7 +761,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-vote-dashboard space="${space}"></folk-vote-dashboard>`,
scripts: `<script type="module" src="/modules/rvote/folk-vote-dashboard.js"></script>`,
scripts: `<script type="module" src="/modules/rvote/folk-vote-dashboard.js?v=2"></script>`,
styles: `<link rel="stylesheet" href="/modules/rvote/vote.css">`,
}));
});

View File

@ -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() {

View File

@ -1239,7 +1239,7 @@ function renderWallet(spaceSlug: string, initialView?: string) {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-wallet-viewer space="${spaceSlug}"${viewAttr}></folk-wallet-viewer>`,
scripts: `<script type="module" src="/modules/rwallet/folk-wallet-viewer.js?v=16"></script>`,
scripts: `<script type="module" src="/modules/rwallet/folk-wallet-viewer.js?v=17"></script>`,
styles: `<link rel="stylesheet" href="/modules/rwallet/wallet.css">`,
});
}

73
shared/collab-presence.ts Normal file
View File

@ -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);
}

View File

@ -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<typeof setInterval> | null = null;
#lastCursor = { x: 0, y: 0 };
#gcInterval: ReturnType<typeof setInterval> | 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(`
<div class="people-row">
<span class="dot" style="background:${this.#localColor}"></span>
<div class="name-block">
<span class="name">${this.#escHtml(this.#localUsername)} <span class="you-tag">(you)</span></span>
${selfModule ? `<span class="peer-context">${this.#escHtml(selfModule)}</span>` : ''}
</div>
<span class="mode-toggle" title="Toggle cursor sharing with other users">
<button class="mode-solo ${isSolo ? 'active' : ''}" data-action="solo">Solo</button>
<button class="mode-multi ${isSolo ? '' : 'active'}" data-action="share">Share</button>
@ -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(`
<div class="people-row">
<span class="dot" style="background:${peer.color}"></span>
<div class="name-block">
<span class="name">${this.#escHtml(peer.username)}</span>
${ctxStr ? `<span class="peer-context">${this.#escHtml(ctxStr)}</span>` : ''}
</div>
${isCanvas ? `<button class="actions-btn" data-pid="${this.#escHtml(pid)}">&gt;</button>` : ''}
</div>
`);
@ -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;