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 { TourEngine } from '../../../shared/tour-engine';
import type { TourStep } from '../../../shared/tour-engine'; import type { TourStep } from '../../../shared/tour-engine';
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
import { CrowdSurfLocalFirstClient } from '../local-first-client'; import { CrowdSurfLocalFirstClient } from '../local-first-client';
import type { CrowdSurfDoc, CrowdSurfPrompt, Contribution } from '../schemas'; import type { CrowdSurfDoc, CrowdSurfPrompt, Contribution } from '../schemas';
import { getDecayProgress, getTimeRemaining, getRightSwipeCount, isReadyToTrigger, getUrgency, parseContributions } from '../schemas'; import { getDecayProgress, getTimeRemaining, getRightSwipeCount, isReadyToTrigger, getUrgency, parseContributions } from '../schemas';
@ -68,6 +69,7 @@ class FolkCrowdSurfDashboard extends HTMLElement {
// Multiplayer // Multiplayer
private lfClient: CrowdSurfLocalFirstClient | null = null; private lfClient: CrowdSurfLocalFirstClient | null = null;
private _lfcUnsub: (() => void) | null = null; private _lfcUnsub: (() => void) | null = null;
private _stopPresence: (() => void) | null = null;
// Expiry timer // Expiry timer
private _expiryTimer: number | null = null; private _expiryTimer: number | null = null;
@ -90,9 +92,11 @@ class FolkCrowdSurfDashboard extends HTMLElement {
} else { } else {
this.initMultiplayer(); this.initMultiplayer();
} }
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'crowdsurf', context: this.prompts[this.currentPromptIndex]?.title || 'CrowdSurf' }));
} }
disconnectedCallback() { disconnectedCallback() {
this._stopPresence?.();
this._lfcUnsub?.(); this._lfcUnsub?.();
this._lfcUnsub = null; this._lfcUnsub = null;
this.lfClient?.disconnect(); this.lfClient?.disconnect();

View File

@ -128,7 +128,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-crowdsurf-dashboard space="${spaceSlug}"></folk-crowdsurf-dashboard>`, 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">`, 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 type { TourStep } from '../../../shared/tour-engine';
import './folk-listing'; import './folk-listing';
import './folk-stay-request'; import './folk-stay-request';
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
const BNB_TOUR_STEPS: TourStep[] = [ const BNB_TOUR_STEPS: TourStep[] = [
{ target: '.bnb-search', title: 'Search', message: 'Filter listings by location, type, or economy model.' }, { 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; #map: any = null;
#mapContainer: HTMLElement | null = null; #mapContainer: HTMLElement | null = null;
#tour: LightTourEngine | null = null; #tour: LightTourEngine | null = null;
private _stopPresence: (() => void) | null = null;
connectedCallback() { connectedCallback() {
this.#space = this.getAttribute('space') || 'demo'; this.#space = this.getAttribute('space') || 'demo';
this.#render(); this.#render();
this.#loadData(); this.#loadData();
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rbnb', context: 'Listings' }));
}
disconnectedCallback() {
this._stopPresence?.();
} }
attributeChangedCallback(name: string, _old: string, val: string) { attributeChangedCallback(name: string, _old: string, val: string) {

View File

@ -1201,7 +1201,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-bnb-view space="${space}"></folk-bnb-view>`, 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"> styles: `<link rel="stylesheet" href="/modules/rbnb/bnb.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">`, <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 { makeDraggableAll } from "../../../shared/draggable";
import type { DocumentId } from "../../../shared/local-first/document"; import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine"; import { TourEngine } from "../../../shared/tour-engine";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
interface BookData { interface BookData {
id: string; id: string;
@ -33,6 +34,7 @@ export class FolkBookShelf extends HTMLElement {
private _searchTerm = ""; private _searchTerm = "";
private _selectedTag: string | null = null; private _selectedTag: string | null = null;
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _stopPresence: (() => void) | null = null;
private _tour!: TourEngine; private _tour!: TourEngine;
private static readonly TOUR_STEPS = [ private static readonly TOUR_STEPS = [
{ target: '.search-input', title: "Search", message: "Search books by title, author, or description.", advanceOnClick: false }, { 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")) { if (!localStorage.getItem("rbooks_tour_done")) {
setTimeout(() => this._tour.start(), 1200); setTimeout(() => this._tour.start(), 1200);
} }
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rbooks', context: 'Library' }));
} }
disconnectedCallback() { disconnectedCallback() {
this._stopPresence?.();
this._offlineUnsub?.(); this._offlineUnsub?.();
this._offlineUnsub = null; this._offlineUnsub = null;
} }

View File

@ -312,7 +312,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-book-shelf space-slug="${spaceSlug}"></folk-book-shelf>`, 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">`, 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 { calendarSchema, calendarDocId, type CalendarDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document"; import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine"; import { TourEngine } from "../../../shared/tour-engine";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
// ── Component ── // ── Component ──
@ -125,6 +126,7 @@ class FolkCalendarView extends HTMLElement {
private filteredSources = new Set<string>(); private filteredSources = new Set<string>();
private boundKeyHandler: ((e: KeyboardEvent) => void) | null = null; private boundKeyHandler: ((e: KeyboardEvent) => void) | null = null;
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _stopPresence: (() => void) | null = null;
// Spatio-temporal state // Spatio-temporal state
private temporalGranularity = 4; // MONTH private temporalGranularity = 4; // MONTH
@ -192,11 +194,13 @@ class FolkCalendarView extends HTMLElement {
if (!localStorage.getItem("rcal_tour_done")) { if (!localStorage.getItem("rcal_tour_done")) {
setTimeout(() => this._tour.start(), 1200); setTimeout(() => this._tour.start(), 1200);
} }
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rcal', context: this.selectedEvent?.title || 'Calendar' }));
} }
disconnectedCallback() { disconnectedCallback() {
this._offlineUnsub?.(); this._offlineUnsub?.();
this._offlineUnsub = null; this._offlineUnsub = null;
this._stopPresence?.();
if (this.boundKeyHandler) { if (this.boundKeyHandler) {
document.removeEventListener("keydown", this.boundKeyHandler); document.removeEventListener("keydown", this.boundKeyHandler);
this.boundKeyHandler = null; this.boundKeyHandler = null;

View File

@ -985,7 +985,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-calendar-view space="${space}"></folk-calendar-view>`, 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"> styles: `<link rel="stylesheet" href="/modules/rcal/cal.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">`, <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 type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine"; import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js"; import { ViewHistory } from "../../../shared/view-history.js";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
class FolkCartShop extends HTMLElement { class FolkCartShop extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
@ -38,6 +39,7 @@ class FolkCartShop extends HTMLElement {
private creatingGroupBuy = false; private creatingGroupBuy = false;
private _offlineUnsubs: (() => void)[] = []; private _offlineUnsubs: (() => void)[] = [];
private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "order-detail" | "payments" | "group-buys">("carts"); private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "order-detail" | "payments" | "group-buys">("carts");
private _stopPresence: (() => void) | null = null;
// Guided tour // Guided tour
private _tour!: TourEngine; private _tour!: TourEngine;
@ -88,11 +90,13 @@ class FolkCartShop extends HTMLElement {
if (!localStorage.getItem("rcart_tour_done")) { if (!localStorage.getItem("rcart_tour_done")) {
setTimeout(() => this._tour.start(), 1200); setTimeout(() => this._tour.start(), 1200);
} }
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rcart', context: this.selectedCatalogItem?.title || this.view }));
} }
disconnectedCallback() { disconnectedCallback() {
for (const unsub of this._offlineUnsubs) unsub(); for (const unsub of this._offlineUnsubs) unsub();
this._offlineUnsubs = []; this._offlineUnsubs = [];
this._stopPresence?.();
} }
private async subscribeOffline() { private async subscribeOffline() {

View File

@ -2472,7 +2472,7 @@ function renderShop(space: string, view?: string) {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-cart-shop space="${space}"${viewAttr}></folk-cart-shop>`, 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">`, styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
}); });
} }

View File

@ -8,6 +8,7 @@
import { TourEngine } from "../../../shared/tour-engine"; import { TourEngine } from "../../../shared/tour-engine";
import { ChoicesLocalFirstClient } from "../local-first-client"; import { ChoicesLocalFirstClient } from "../local-first-client";
import type { ChoicesDoc, ChoiceSession, ChoiceVote } from "../schemas"; import type { ChoicesDoc, ChoiceSession, ChoiceVote } from "../schemas";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
// ── CrowdSurf types ── // ── CrowdSurf types ──
interface CrowdSurfOption { interface CrowdSurfOption {
@ -53,6 +54,7 @@ class FolkChoicesDashboard extends HTMLElement {
/* Multiplayer state */ /* Multiplayer state */
private lfClient: ChoicesLocalFirstClient | null = null; private lfClient: ChoicesLocalFirstClient | null = null;
private _lfcUnsub: (() => void) | null = null; private _lfcUnsub: (() => void) | null = null;
private _stopPresence: (() => void) | null = null;
private sessions: ChoiceSession[] = []; private sessions: ChoiceSession[] = [];
private activeSessionId: string | null = null; private activeSessionId: string | null = null;
private sessionVotes: Map<string, ChoiceVote[]> = new Map(); private sessionVotes: Map<string, ChoiceVote[]> = new Map();
@ -98,9 +100,11 @@ class FolkChoicesDashboard extends HTMLElement {
if (!localStorage.getItem("rchoices_tour_done")) { if (!localStorage.getItem("rchoices_tour_done")) {
setTimeout(() => this._tour.start(), 1200); setTimeout(() => this._tour.start(), 1200);
} }
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rchoices', context: this.sessions.find(s => s.id === this.activeSessionId)?.title || 'Choices' }));
} }
disconnectedCallback() { disconnectedCallback() {
this._stopPresence?.();
if (this.simTimer !== null) { if (this.simTimer !== null) {
clearInterval(this.simTimer); clearInterval(this.simTimer);
this.simTimer = null; this.simTimer = null;

View File

@ -56,7 +56,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-choices-dashboard space="${spaceSlug}" tab="spider"></folk-choices-dashboard>`, 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">`, styles: `<link rel="stylesheet" href="/modules/rchoices/choices.css">`,
})); }));
}); });
@ -75,7 +75,7 @@ routes.get("/:tab", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-choices-dashboard space="${spaceSlug}" tab="${tab}"></folk-choices-dashboard>`, 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">`, styles: `<link rel="stylesheet" href="/modules/rchoices/choices.css">`,
})); }));
}); });

View File

@ -8,6 +8,7 @@
import { TourEngine } from '../../../shared/tour-engine'; import { TourEngine } from '../../../shared/tour-engine';
import type { TourStep } from '../../../shared/tour-engine'; import type { TourStep } from '../../../shared/tour-engine';
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
interface TreeItem { interface TreeItem {
docId: string; docId: string;
@ -139,6 +140,7 @@ class FolkContentTree extends HTMLElement {
private expanded = new Set<string>(); private expanded = new Set<string>();
private allTags: string[] = []; private allTags: string[] = [];
private loading = true; private loading = true;
private _stopPresence: (() => void) | null = null;
constructor() { constructor() {
super(); super();
@ -154,6 +156,11 @@ class FolkContentTree extends HTMLElement {
connectedCallback() { connectedCallback() {
this.space = this.getAttribute("space") || "demo"; this.space = this.getAttribute("space") || "demo";
this.loadData(); this.loadData();
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rdata', context: 'Data' }));
}
disconnectedCallback() {
this._stopPresence?.();
} }
private async loadData() { private async loadData() {

View File

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

View File

@ -10,6 +10,7 @@ import { makeDraggableAll } from "../../../shared/draggable";
import type { DocumentId } from "../../../shared/local-first/document"; import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine"; import { TourEngine } from "../../../shared/tour-engine";
import { authFetch, requireAuth } from "../../../shared/auth-fetch"; import { authFetch, requireAuth } from "../../../shared/auth-fetch";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
class FolkFileBrowser extends HTMLElement { class FolkFileBrowser extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
@ -19,6 +20,7 @@ class FolkFileBrowser extends HTMLElement {
private tab: "files" | "cards" = "files"; private tab: "files" | "cards" = "files";
private loading = false; private loading = false;
private _offlineUnsubs: (() => void)[] = []; private _offlineUnsubs: (() => void)[] = [];
private _stopPresence: (() => void) | null = null;
private _tour!: TourEngine; private _tour!: TourEngine;
private static readonly TOUR_STEPS = [ 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 }, { 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")) { if (!localStorage.getItem("rfiles_tour_done")) {
setTimeout(() => this._tour.start(), 1200); setTimeout(() => this._tour.start(), 1200);
} }
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rfiles', context: 'Files' }));
} }
disconnectedCallback() { disconnectedCallback() {
this._stopPresence?.();
for (const unsub of this._offlineUnsubs) unsub(); for (const unsub of this._offlineUnsubs) unsub();
this._offlineUnsubs = []; this._offlineUnsubs = [];
} }

View File

@ -625,7 +625,7 @@ routes.get("/", (c) => {
theme: "dark", 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> 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>`, <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">`, 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 { flowsSchema, flowsDocId, type FlowsDoc, type CanvasFlow } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document"; import type { DocumentId } from "../../../shared/local-first/document";
import { FlowsLocalFirstClient } from "../local-first-client"; import { FlowsLocalFirstClient } from "../local-first-client";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
interface FlowSummary { interface FlowSummary {
@ -166,6 +167,7 @@ class FolkFlowsApp extends HTMLElement {
private flowDropdownOpen = false; private flowDropdownOpen = false;
private flowManagerOpen = false; private flowManagerOpen = false;
private _lfcUnsub: (() => void) | null = null; private _lfcUnsub: (() => void) | null = null;
private _stopPresence: (() => void) | null = null;
// Mortgage state // Mortgage state
private mortgagePositions: MortgagePosition[] = []; private mortgagePositions: MortgagePosition[] = [];
@ -233,6 +235,8 @@ class FolkFlowsApp extends HTMLElement {
const viewAttr = this.getAttribute("view"); const viewAttr = this.getAttribute("view");
this.view = viewAttr === "budgets" ? "budgets" : viewAttr === "mortgage" ? "mortgage" : "detail"; this.view = viewAttr === "budgets" ? "budgets" : viewAttr === "mortgage" ? "mortgage" : "detail";
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rflows', context: this.flowName || this.currentFlowId || 'Flow' }));
if (this.view === "budgets") { if (this.view === "budgets") {
this.initBudgetView(); this.initBudgetView();
return; return;
@ -362,6 +366,7 @@ class FolkFlowsApp extends HTMLElement {
this._offlineUnsub = null; this._offlineUnsub = null;
this._lfcUnsub?.(); this._lfcUnsub?.();
this._lfcUnsub = null; this._lfcUnsub = null;
this._stopPresence?.();
this._budgetLfcUnsub?.(); this._budgetLfcUnsub?.();
this._budgetLfcUnsub = null; this._budgetLfcUnsub = null;
if (this.saveTimer) { clearTimeout(this.saveTimer); this.saveTimer = 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 = ` const flowsScripts = `
<script src="https://cdn.jsdelivr.net/npm/lz-string@1.5.0/libs/lz-string.min.js"></script> <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>`; <script type="module" src="/modules/rflows/folk-flow-river.js?v=3"></script>`;
const flowsStyles = `<link rel="stylesheet" href="/modules/rflows/flows.css">`; 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 type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine"; import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js"; import { ViewHistory } from "../../../shared/view-history.js";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
class FolkForumDashboard extends HTMLElement { class FolkForumDashboard extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
@ -20,6 +21,7 @@ class FolkForumDashboard extends HTMLElement {
private pollTimer: number | null = null; private pollTimer: number | null = null;
private space = ""; private space = "";
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _stopPresence: (() => void) | null = null;
private _history = new ViewHistory<"list" | "detail" | "create">("list"); private _history = new ViewHistory<"list" | "detail" | "create">("list");
private _tour!: TourEngine; private _tour!: TourEngine;
private static readonly TOUR_STEPS = [ private static readonly TOUR_STEPS = [
@ -50,6 +52,7 @@ class FolkForumDashboard extends HTMLElement {
if (!localStorage.getItem("rforum_tour_done")) { if (!localStorage.getItem("rforum_tour_done")) {
setTimeout(() => this._tour.start(), 1200); setTimeout(() => this._tour.start(), 1200);
} }
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rforum', context: this.selectedInstance?.name || 'Forum' }));
} }
private loadDemoData() { private loadDemoData() {
@ -63,6 +66,7 @@ class FolkForumDashboard extends HTMLElement {
} }
disconnectedCallback() { disconnectedCallback() {
this._stopPresence?.();
if (this.pollTimer) clearInterval(this.pollTimer); if (this.pollTimer) clearInterval(this.pollTimer);
this._offlineUnsub?.(); this._offlineUnsub?.();
this._offlineUnsub = null; this._offlineUnsub = null;

View File

@ -194,7 +194,7 @@ routes.get("/", (c) => {
theme: "dark", 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> 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>`, <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">`, 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 { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js"; import { ViewHistory } from "../../../shared/view-history.js";
import { getAccessToken, getUsername } from "../../../lib/rspace-header"; import { getAccessToken, getUsername } from "../../../lib/rspace-header";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
type ComposeMode = 'new' | 'reply' | 'reply-all' | 'forward'; type ComposeMode = 'new' | 'reply' | 'reply-all' | 'forward';
@ -18,6 +19,7 @@ class FolkInboxClient extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
private space = "demo"; private space = "demo";
private _offlineUnsubs: (() => void)[] = []; private _offlineUnsubs: (() => void)[] = [];
private _stopPresence: (() => void) | null = null;
private view: "mailboxes" | "threads" | "thread" | "approvals" | "personal" | "agents" = "mailboxes"; private view: "mailboxes" | "threads" | "thread" | "approvals" | "personal" | "agents" = "mailboxes";
private mailboxes: any[] = []; private mailboxes: any[] = [];
private threads: any[] = []; private threads: any[] = [];
@ -120,11 +122,13 @@ class FolkInboxClient extends HTMLElement {
if (!localStorage.getItem("rinbox_tour_done")) { if (!localStorage.getItem("rinbox_tour_done")) {
setTimeout(() => this._tour.start(), 1200); setTimeout(() => this._tour.start(), 1200);
} }
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rinbox', context: this.currentThread?.subject || 'Inbox' }));
} }
disconnectedCallback() { disconnectedCallback() {
for (const unsub of this._offlineUnsubs) unsub(); for (const unsub of this._offlineUnsubs) unsub();
this._offlineUnsubs = []; this._offlineUnsubs = [];
this._stopPresence?.();
} }
/** Extract username from EncryptID session */ /** Extract username from EncryptID session */

View File

@ -1731,7 +1731,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-inbox-client space="${space}"></folk-inbox-client>`, 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">`, 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 { requireAuth } from "../../../shared/auth-fetch";
import { getUsername } from "../../../shared/components/rstack-identity"; import { getUsername } from "../../../shared/components/rstack-identity";
import { MapsLocalFirstClient } from "../local-first-client"; import { MapsLocalFirstClient } from "../local-first-client";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
// MapLibre loaded via CDN — use window access with type assertion // 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 thumbnailTimer: ReturnType<typeof setTimeout> | null = null;
private _themeObserver: MutationObserver | null = null; private _themeObserver: MutationObserver | null = null;
private _history = new ViewHistory<"lobby" | "map">("lobby"); private _history = new ViewHistory<"lobby" | "map">("lobby");
private _stopPresence: (() => void) | null = null;
// Chat + Local-first state // Chat + Local-first state
private lfClient: MapsLocalFirstClient | null = null; private lfClient: MapsLocalFirstClient | null = null;
@ -181,9 +183,11 @@ class FolkMapViewer extends HTMLElement {
// Start with interactions disabled // Start with interactions disabled
this.setMapInteractive(false); this.setMapInteractive(false);
} }
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rmaps', context: this.room || 'Maps' }));
} }
disconnectedCallback() { disconnectedCallback() {
this._stopPresence?.();
this.leaveRoom(); this.leaveRoom();
if (this._themeObserver) { if (this._themeObserver) {
this._themeObserver.disconnect(); this._themeObserver.disconnect();

View File

@ -274,7 +274,7 @@ routes.get("/", (c) => {
spaceSlug: space, spaceSlug: space,
modules: getModuleInfoList(), modules: getModuleInfoList(),
body: `<folk-map-viewer space="${space}"></folk-map-viewer>`, 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">`, styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css?v=3">`,
})); }));
}); });
@ -291,7 +291,7 @@ routes.get("/:room", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css?v=3">`, styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css?v=3">`,
body: `<folk-map-viewer space="${space}" room="${room}"></folk-map-viewer>`, 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. * the local video track to a 360° perspective view.
*/ */
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
class FolkJitsiRoom extends HTMLElement { class FolkJitsiRoom extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
private api: any = null; private api: any = null;
@ -22,6 +24,7 @@ class FolkJitsiRoom extends HTMLElement {
private directorStream: MediaStream | null = null; private directorStream: MediaStream | null = null;
private directorAnimFrame: number | null = null; private directorAnimFrame: number | null = null;
private directorError = ""; private directorError = "";
private _stopPresence: (() => void) | null = null;
constructor() { constructor() {
super(); super();
@ -37,9 +40,11 @@ class FolkJitsiRoom extends HTMLElement {
this.render(); this.render();
this.loadJitsiApi(); this.loadJitsiApi();
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rmeets', context: this.room || 'Meeting' }));
} }
disconnectedCallback() { disconnectedCallback() {
this._stopPresence?.();
this.dispose(); this.dispose();
} }

View File

@ -128,7 +128,7 @@ routes.get("/room/:room", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", 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>`, 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 { TourEngine } from "../../../shared/tour-engine";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
class FolkCrmView extends HTMLElement { class FolkCrmView extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
@ -102,6 +103,8 @@ class FolkCrmView extends HTMLElement {
private graphPanStartPanY = 0; private graphPanStartPanY = 0;
private graphSelectedId: string | null = null; private graphSelectedId: string | null = null;
private _stopPresence: (() => void) | null = null;
// Guided tour // Guided tour
private _tour!: TourEngine; private _tour!: TourEngine;
private static readonly TOUR_STEPS = [ private static readonly TOUR_STEPS = [
@ -155,10 +158,12 @@ class FolkCrmView extends HTMLElement {
if (!localStorage.getItem("rnetwork_tour_done")) { if (!localStorage.getItem("rnetwork_tour_done")) {
setTimeout(() => this._tour.start(), 1200); setTimeout(() => this._tour.start(), 1200);
} }
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rnetwork', context: this.graphSelectedId ? 'CRM' : `CRM - ${this.activeTab}` }));
} }
disconnectedCallback() { disconnectedCallback() {
document.removeEventListener("rapp-tab-change", this._onTabChange); document.removeEventListener("rapp-tab-change", this._onTabChange);
this._stopPresence?.();
} }
private getApiBase(): string { private getApiBase(): string {

View File

@ -6,6 +6,8 @@
* Left-drag pans, scroll zooms, right-drag orbits. * Left-drag pans, scroll zooms, right-drag orbits.
*/ */
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
interface WeightAccounting { interface WeightAccounting {
delegatedAway: Record<string, number>; // per authority: total weight delegated out delegatedAway: Record<string, number>; // per authority: total weight delegated out
receivedWeight: Record<string, number>; // per authority: total weight received from others 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 // Sprite caches — avoid recreating Canvas+Texture per node per frame
private _textSpriteCache = new Map<string, any>(); private _textSpriteCache = new Map<string, any>();
private _badgeSpriteCache = new Map<string, any>(); private _badgeSpriteCache = new Map<string, any>();
private _stopPresence: (() => void) | null = null;
constructor() { constructor() {
super(); super();
@ -173,9 +176,11 @@ class FolkGraphViewer extends HTMLElement {
this.space = this.getAttribute("space") || "demo"; this.space = this.getAttribute("space") || "demo";
this.renderDOM(); this.renderDOM();
this.loadData(); this.loadData();
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rnetwork', context: 'Network Graph' }));
} }
disconnectedCallback() { disconnectedCallback() {
this._stopPresence?.();
if (this.resizeObserver) { if (this.resizeObserver) {
this.resizeObserver.disconnect(); this.resizeObserver.disconnect();
this.resizeObserver = null; this.resizeObserver = null;

View File

@ -692,7 +692,7 @@ function renderCrm(space: string, activeTab: string) {
spaceSlug: space, spaceSlug: space,
modules: getModuleInfoList(), modules: getModuleInfoList(),
body: `<folk-crm-view space="${space}"></folk-crm-view>`, 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-delegation-manager.js"></script>
<script type="module" src="/modules/rnetwork/folk-trust-sankey.js"></script>`, <script type="module" src="/modules/rnetwork/folk-trust-sankey.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`, styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
@ -733,7 +733,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
head: GRAPH3D_HEAD, head: GRAPH3D_HEAD,
body: `<folk-graph-viewer space="${space}"></folk-graph-viewer>`, 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">`, 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 { notebookSchema } from '../schemas';
import type { DocumentId } from '../../../shared/local-first/document'; import type { DocumentId } from '../../../shared/local-first/document';
import { getAccessToken } from '../../../shared/components/rstack-identity'; import { getAccessToken } from '../../../shared/components/rstack-identity';
import { broadcastPresence as sharedBroadcastPresence, startPresenceHeartbeat } from '../../../shared/collab-presence';
import { Editor } from '@tiptap/core'; import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit'; import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link'; import Link from '@tiptap/extension-link';
@ -190,9 +191,9 @@ class FolkNotesApp extends HTMLElement {
noteId: string | null; noteId: string | null;
lastSeen: number; lastSeen: number;
}> = new Map(); }> = new Map();
private _presenceHeartbeat: ReturnType<typeof setInterval> | null = null; private _stopPresence: (() => void) | null = null;
private _presenceGC: ReturnType<typeof setInterval> | null = null;
private _presenceUnsub: (() => void) | null = null; private _presenceUnsub: (() => void) | null = null;
private _presenceGC: ReturnType<typeof setInterval> | null = null;
// ── Demo data ── // ── Demo data ──
private demoNotebooks: (Notebook & { notes: Note[] })[] = []; 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; const runtime = (window as any).__rspaceOfflineRuntime;
if (this._presenceUnsub) return; if (this._presenceUnsub) return;
if (!runtime?.isInitialized) { if (!runtime?.isInitialized) {
// Runtime not ready yet — retry shortly
setTimeout(() => this.setupPresence(), 2000); setTimeout(() => this.setupPresence(), 2000);
return; 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) => { this._presenceUnsub = runtime.onCustomMessage('presence', (msg: any) => {
if (msg.module !== 'rnotes' || !msg.peerId) return; if (msg.module !== 'rnotes' || !msg.peerId) return;
this._presencePeers.set(msg.peerId, { 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(); this.renderPresenceIndicators();
}); });
// Heartbeat: broadcast own position every 10s // Use shared heartbeat for broadcasting
this._presenceHeartbeat = setInterval(() => this.broadcastPresence(), 10_000); 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(() => { this._presenceGC = setInterval(() => {
const cutoff = Date.now() - 20_000; const cutoff = Date.now() - 20_000;
let changed = false; let changed = false;
const entries = Array.from(this._presencePeers.entries()); for (const [id, peer] of this._presencePeers) {
for (const [id, peer] of entries) {
if (peer.lastSeen < cutoff) { if (peer.lastSeen < cutoff) {
this._presencePeers.delete(id); this._presencePeers.delete(id);
changed = true; changed = true;
@ -1487,23 +1493,17 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
} }
if (changed) this.renderPresenceIndicators(); if (changed) this.renderPresenceIndicators();
}, 15_000); }, 15_000);
// Send initial position
this.broadcastPresence();
} }
/** Broadcast current user position to peers. */ /** Broadcast current user position to peers. */
private broadcastPresence() { private broadcastPresence() {
const runtime = (window as any).__rspaceOfflineRuntime; sharedBroadcastPresence({
if (!runtime?.isInitialized || !runtime.isOnline) return;
const session = this.getSessionInfo();
runtime.sendCustom({
type: 'presence',
module: 'rnotes', module: 'rnotes',
notebookId: this.selectedNotebook?.id || null, context: this.selectedNote
noteId: this.selectedNote?.id || null, ? `${this.selectedNotebook?.name || 'Notebook'} > ${this.selectedNote.title}`
username: session.username, : this.selectedNotebook?.name || '',
color: this.userColor(session.userId), 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. */ /** Tear down presence listeners and timers. */
private cleanupPresence() { private cleanupPresence() {
this._stopPresence?.();
this._stopPresence = null;
this._presenceUnsub?.(); this._presenceUnsub?.();
this._presenceUnsub = null; this._presenceUnsub = null;
if (this._presenceHeartbeat) { clearInterval(this._presenceHeartbeat); this._presenceHeartbeat = null; }
if (this._presenceGC) { clearInterval(this._presenceGC); this._presenceGC = null; } if (this._presenceGC) { clearInterval(this._presenceGC); this._presenceGC = null; }
this._presencePeers.clear(); this._presencePeers.clear();
} }

View File

@ -1615,7 +1615,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-notes-app space="${space}"></folk-notes-app>`, 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">`, styles: `<link rel="stylesheet" href="/modules/rnotes/notes.css?v=5">`,
})); }));
}); });

View File

@ -11,6 +11,7 @@
import { makeDraggableAll } from "../../../shared/draggable"; import { makeDraggableAll } from "../../../shared/draggable";
import { TourEngine } from "../../../shared/tour-engine"; import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js"; import { ViewHistory } from "../../../shared/view-history.js";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
interface Album { interface Album {
id: string; id: string;
@ -49,6 +50,7 @@ class FolkPhotoGallery extends HTMLElement {
private error = ""; private error = "";
private showingSampleData = false; private showingSampleData = false;
private _tour!: TourEngine; private _tour!: TourEngine;
private _stopPresence: (() => void) | null = null;
private _history = new ViewHistory<"gallery" | "album" | "lightbox">("gallery"); private _history = new ViewHistory<"gallery" | "album" | "lightbox">("gallery");
private static readonly TOUR_STEPS = [ private static readonly TOUR_STEPS = [
{ target: '.album-card', title: "Albums", message: "Browse shared photo albums — click one to see its photos.", advanceOnClick: false }, { 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")) { if (!localStorage.getItem("rphotos_tour_done")) {
setTimeout(() => this._tour.start(), 1200); setTimeout(() => this._tour.start(), 1200);
} }
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rphotos', context: this.selectedAlbum?.albumName || 'Photos' }));
}
disconnectedCallback() {
this._stopPresence?.();
} }
private loadDemoData() { private loadDemoData() {

View File

@ -133,7 +133,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-photo-gallery space="${spaceSlug}"></folk-photo-gallery>`, 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">`, 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 { PubsDoc } from '../schemas';
import type { DocumentId } from '../../../shared/local-first/document'; import type { DocumentId } from '../../../shared/local-first/document';
import { TourEngine } from '../../../shared/tour-engine'; import { TourEngine } from '../../../shared/tour-engine';
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
interface BookFormat { interface BookFormat {
id: string; id: string;
@ -98,6 +99,7 @@ export class FolkPubsEditor extends HTMLElement {
private _isRemoteUpdate = false; private _isRemoteUpdate = false;
private _syncTimer: ReturnType<typeof setTimeout> | null = null; private _syncTimer: ReturnType<typeof setTimeout> | null = null;
private _syncConnected = false; private _syncConnected = false;
private _stopPresence: (() => void) | null = null;
private _tour!: TourEngine; private _tour!: TourEngine;
private static readonly TOUR_STEPS = [ 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 }, { 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); 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() { private async initRuntime() {
@ -356,6 +362,7 @@ export class FolkPubsEditor extends HTMLElement {
if (this._unsubChange) this._unsubChange(); if (this._unsubChange) this._unsubChange();
if (this._syncTimer) clearTimeout(this._syncTimer); if (this._syncTimer) clearTimeout(this._syncTimer);
document.removeEventListener("click", this._outsideClickHandler); document.removeEventListener("click", this._outsideClickHandler);
this._stopPresence?.();
} }
private renderDropdowns() { private renderDropdowns() {

View File

@ -668,7 +668,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-pubs-editor space="${spaceSlug}"></folk-pubs-editor>`, 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-flipbook.js"></script>
<script type="module" src="/modules/rpubs/folk-pubs-publish-panel.js"></script>`, <script type="module" src="/modules/rpubs/folk-pubs-publish-panel.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rpubs/pubs.css">`, 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 type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine"; import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js"; import { ViewHistory } from "../../../shared/view-history.js";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
interface JobData { interface JobData {
id: string; id: string;
@ -90,6 +91,7 @@ class FolkScheduleApp extends HTMLElement {
private loading = false; private loading = false;
private runningJobId: string | null = null; private runningJobId: string | null = null;
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _stopPresence: (() => void) | null = null;
private _tour!: TourEngine; private _tour!: TourEngine;
private static readonly TOUR_STEPS = [ 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 }, { 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")) { if (!localStorage.getItem("rschedule_tour_done")) {
setTimeout(() => this._tour.start(), 1200); setTimeout(() => this._tour.start(), 1200);
} }
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rschedule', context: 'Schedule' }));
} }
disconnectedCallback() { disconnectedCallback() {
@ -141,6 +144,7 @@ class FolkScheduleApp extends HTMLElement {
this._offlineUnsub(); this._offlineUnsub();
this._offlineUnsub = null; this._offlineUnsub = null;
} }
this._stopPresence?.();
} }
private async subscribeOffline() { private async subscribeOffline() {

View File

@ -809,7 +809,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-schedule-app space="${space}"></folk-schedule-app>`, 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">`, styles: `<link rel="stylesheet" href="/modules/rschedule/schedule.css">`,
}), }),
); );

View File

@ -23,6 +23,7 @@ import type {
} from '../schemas'; } from '../schemas';
import { SocialsLocalFirstClient } from '../local-first-client'; import { SocialsLocalFirstClient } from '../local-first-client';
import { buildDemoCampaignFlow, PLATFORM_ICONS, PLATFORM_COLORS } from '../campaign-data'; import { buildDemoCampaignFlow, PLATFORM_ICONS, PLATFORM_COLORS } from '../campaign-data';
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
// ── Port definitions ── // ── Port definitions ──
@ -182,6 +183,7 @@ class FolkCampaignPlanner extends HTMLElement {
private localFirstClient: SocialsLocalFirstClient | null = null; private localFirstClient: SocialsLocalFirstClient | null = null;
private saveTimer: ReturnType<typeof setTimeout> | null = null; private saveTimer: ReturnType<typeof setTimeout> | null = null;
private _lfcUnsub: (() => void) | null = null; private _lfcUnsub: (() => void) | null = null;
private _stopPresence: (() => void) | null = null;
// Context menu // Context menu
private contextMenuX = 0; private contextMenuX = 0;
@ -204,9 +206,11 @@ class FolkCampaignPlanner extends HTMLElement {
connectedCallback() { connectedCallback() {
this.space = this.getAttribute('space') || 'demo'; this.space = this.getAttribute('space') || 'demo';
this.initData(); this.initData();
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rsocials', context: this.flowName || 'Social Campaigns' }));
} }
disconnectedCallback() { disconnectedCallback() {
this._stopPresence?.();
if (this._lfcUnsub) this._lfcUnsub(); if (this._lfcUnsub) this._lfcUnsub();
if (this.saveTimer) clearTimeout(this.saveTimer); if (this.saveTimer) clearTimeout(this.saveTimer);
if (this._boundPointerMove) document.removeEventListener('pointermove', this._boundPointerMove); if (this._boundPointerMove) document.removeEventListener('pointermove', this._boundPointerMove);

View File

@ -9,6 +9,7 @@
import { LightTourEngine } from "../../../shared/tour-engine"; import { LightTourEngine } from "../../../shared/tour-engine";
import type { TourStep } from "../../../shared/tour-engine"; import type { TourStep } from "../../../shared/tour-engine";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
import { makeDraggableAll } from "../../../shared/draggable"; import { makeDraggableAll } from "../../../shared/draggable";
import { splatScenesSchema, splatScenesDocId, type SplatScenesDoc } from "../schemas"; import { splatScenesSchema, splatScenesDocId, type SplatScenesDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document"; import type { DocumentId } from "../../../shared/local-first/document";
@ -48,6 +49,7 @@ export class FolkSplatViewer extends HTMLElement {
private _uploadMode: "splat" | "generate" = "generate"; private _uploadMode: "splat" | "generate" = "generate";
private _inlineViewer = false; private _inlineViewer = false;
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _stopPresence: (() => void) | null = null;
private _generatedUrl = ""; private _generatedUrl = "";
private _generatedTitle = ""; private _generatedTitle = "";
private _savedSlug = ""; private _savedSlug = "";
@ -84,6 +86,7 @@ export class FolkSplatViewer extends HTMLElement {
this.loadMyHistory(); this.loadMyHistory();
this.renderGallery(); this.renderGallery();
} }
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rsplat', context: this._splatTitle || '3D Viewer' }));
} }
private async subscribeOffline() { private async subscribeOffline() {
@ -145,6 +148,7 @@ export class FolkSplatViewer extends HTMLElement {
} }
disconnectedCallback() { disconnectedCallback() {
this._stopPresence?.();
this._offlineUnsub?.(); this._offlineUnsub?.();
this._offlineUnsub = null; this._offlineUnsub = null;
if (this._viewer) { if (this._viewer) {

View File

@ -725,7 +725,7 @@ routes.get("/", async (c) => {
`, `,
scripts: ` scripts: `
<script type="module"> <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'); const gallery = document.getElementById('gallery');
gallery.splats = ${splatsJSON}; gallery.splats = ${splatsJSON};
gallery.spaceSlug = '${spaceSlug}'; gallery.spaceSlug = '${spaceSlug}';
@ -786,7 +786,7 @@ function renderViewerPage(spaceSlug: string, dataSpace: string, idOrSlug: string
`, `,
scripts: ` scripts: `
<script type="module"> <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> </script>
`, `,
}); });

View File

@ -153,6 +153,7 @@ function posterMockupSvg(): string {
import { TourEngine } from "../../../shared/tour-engine"; import { TourEngine } from "../../../shared/tour-engine";
import { SwagLocalFirstClient } from "../local-first-client"; import { SwagLocalFirstClient } from "../local-first-client";
import type { SwagDoc, SwagDesign } from "../schemas"; import type { SwagDoc, SwagDesign } from "../schemas";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
// Auth helpers // Auth helpers
function getSession(): { accessToken: string; claims: { sub: string; did?: string; username?: string } } | null { function getSession(): { accessToken: string; claims: { sub: string; did?: string; username?: string } } | null {
@ -240,6 +241,7 @@ class FolkSwagDesigner extends HTMLElement {
// Multiplayer state // Multiplayer state
private lfClient: SwagLocalFirstClient | null = null; private lfClient: SwagLocalFirstClient | null = null;
private _lfcUnsub: (() => void) | null = null; private _lfcUnsub: (() => void) | null = null;
private _stopPresence: (() => void) | null = null;
private sharedDesigns: SwagDesign[] = []; private sharedDesigns: SwagDesign[] = [];
private _tour!: TourEngine; private _tour!: TourEngine;
@ -279,9 +281,11 @@ class FolkSwagDesigner extends HTMLElement {
if (!localStorage.getItem("rswag_tour_done")) { if (!localStorage.getItem("rswag_tour_done")) {
setTimeout(() => this._tour.start(), 1200); setTimeout(() => this._tour.start(), 1200);
} }
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rswag', context: 'Swag Designer' }));
} }
disconnectedCallback() { disconnectedCallback() {
this._stopPresence?.();
this._lfcUnsub?.(); this._lfcUnsub?.();
this._lfcUnsub = null; this._lfcUnsub = null;
this.lfClient?.disconnect(); this.lfClient?.disconnect();

View File

@ -968,7 +968,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-swag-designer space="${space}"></folk-swag-designer>`, 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>`, <script type="module" src="/modules/rswag/folk-revenue-sankey.js?v=1"></script>`,
styles: `<link rel="stylesheet" href="/modules/rswag/swag.css">`, 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 type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine"; import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js"; import { ViewHistory } from "../../../shared/view-history.js";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
class FolkTasksBoard extends HTMLElement { class FolkTasksBoard extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
@ -32,6 +33,7 @@ class FolkTasksBoard extends HTMLElement {
private _offlineUnsubs: (() => void)[] = []; private _offlineUnsubs: (() => void)[] = [];
private _history = new ViewHistory<"list" | "board">("list"); private _history = new ViewHistory<"list" | "board">("list");
private _backlogTaskId: string | null = null; private _backlogTaskId: string | null = null;
private _stopPresence: (() => void) | null = null;
private _tour!: TourEngine; private _tour!: TourEngine;
private static readonly TOUR_STEPS = [ private static readonly TOUR_STEPS = [
{ target: '.workspace-card', title: "Workspaces", message: "Select a workspace to open its kanban board.", advanceOnClick: true }, { 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")) { if (!localStorage.getItem("rtasks_tour_done")) {
setTimeout(() => this._tour.start(), 1200); setTimeout(() => this._tour.start(), 1200);
} }
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rtasks', context: this.workspaceSlug || 'Workspaces' }));
} }
disconnectedCallback() { disconnectedCallback() {
for (const unsub of this._offlineUnsubs) unsub(); for (const unsub of this._offlineUnsubs) unsub();
this._offlineUnsubs = []; this._offlineUnsubs = [];
this._stopPresence?.();
} }
private async subscribeOffline() { private async subscribeOffline() {

View File

@ -469,7 +469,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-tasks-board space="${space}"></folk-tasks-board>`, 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">`, 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 type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine"; import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js"; import { ViewHistory } from "../../../shared/view-history.js";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
class FolkTripsPlanner extends HTMLElement { class FolkTripsPlanner extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
@ -20,6 +21,7 @@ class FolkTripsPlanner extends HTMLElement {
private tab: "overview" | "destinations" | "itinerary" | "bookings" | "expenses" | "packing" = "overview"; private tab: "overview" | "destinations" | "itinerary" | "bookings" | "expenses" | "packing" = "overview";
private error = ""; private error = "";
private _offlineUnsubs: (() => void)[] = []; private _offlineUnsubs: (() => void)[] = [];
private _stopPresence: (() => void) | null = null;
private _history = new ViewHistory<"list" | "detail">("list"); private _history = new ViewHistory<"list" | "detail">("list");
private _tour!: TourEngine; private _tour!: TourEngine;
private static readonly TOUR_STEPS = [ private static readonly TOUR_STEPS = [
@ -50,11 +52,13 @@ class FolkTripsPlanner extends HTMLElement {
if (!localStorage.getItem("rtrips_tour_done")) { if (!localStorage.getItem("rtrips_tour_done")) {
setTimeout(() => this._tour.start(), 1200); setTimeout(() => this._tour.start(), 1200);
} }
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rtrips', context: this.trip?.name || 'Trips' }));
} }
disconnectedCallback() { disconnectedCallback() {
for (const unsub of this._offlineUnsubs) unsub(); for (const unsub of this._offlineUnsubs) unsub();
this._offlineUnsubs = []; this._offlineUnsubs = [];
this._stopPresence?.();
} }
private async subscribeOffline() { private async subscribeOffline() {

View File

@ -594,7 +594,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-trips-planner space="${space}"></folk-trips-planner>`, 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">`, styles: `<link rel="stylesheet" href="/modules/rtrips/trips.css">`,
})); }));
}); });

View File

@ -7,6 +7,7 @@
import { TourEngine } from "../../../shared/tour-engine"; import { TourEngine } from "../../../shared/tour-engine";
import { authFetch, requireAuth } from "../../../shared/auth-fetch"; import { authFetch, requireAuth } from "../../../shared/auth-fetch";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
class FolkVideoPlayer extends HTMLElement { class FolkVideoPlayer extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
@ -32,6 +33,7 @@ class FolkVideoPlayer extends HTMLElement {
private hlsPlayers: any[] = []; private hlsPlayers: any[] = [];
private liveSplitStatusInterval: ReturnType<typeof setInterval> | null = null; private liveSplitStatusInterval: ReturnType<typeof setInterval> | null = null;
private expandedView: number | null = null; private expandedView: number | null = null;
private _stopPresence: (() => void) | null = null;
private _tour!: TourEngine; private _tour!: TourEngine;
private static readonly TOUR_STEPS = [ private static readonly TOUR_STEPS = [
{ target: '[data-mode="library"]', title: "Video Library", message: "Browse your recorded videos — search, select, and play.", advanceOnClick: false }, { 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")) { if (!localStorage.getItem("rtube_tour_done")) {
setTimeout(() => this._tour.start(), 1200); setTimeout(() => this._tour.start(), 1200);
} }
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rtube', context: this.currentVideo || 'Video' }));
}
disconnectedCallback() {
this._stopPresence?.();
} }
private loadDemoData() { private loadDemoData() {

View File

@ -418,7 +418,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-video-player space="${space}"></folk-video-player>`, 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">`, 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 type { TourStep } from '../../../shared/tour-engine';
import './folk-vehicle-card'; import './folk-vehicle-card';
import './folk-rental-request'; import './folk-rental-request';
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
const VNB_TOUR_STEPS: TourStep[] = [ const VNB_TOUR_STEPS: TourStep[] = [
{ target: '.vnb-search', title: 'Search', message: 'Filter by vehicle type, dates, or economy model.' }, { 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; #map: any = null;
#mapContainer: HTMLElement | null = null; #mapContainer: HTMLElement | null = null;
#tour: LightTourEngine | null = null; #tour: LightTourEngine | null = null;
private _stopPresence: (() => void) | null = null;
connectedCallback() { connectedCallback() {
this.#space = this.getAttribute('space') || 'demo'; this.#space = this.getAttribute('space') || 'demo';
this.#render(); this.#render();
this.#loadData(); this.#loadData();
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rvnb', context: 'Venues' }));
}
disconnectedCallback() {
this._stopPresence?.();
} }
attributeChangedCallback(name: string, _old: string, val: string) { attributeChangedCallback(name: string, _old: string, val: string) {

View File

@ -1282,7 +1282,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-vnb-view space="${space}"></folk-vnb-view>`, 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"> styles: `<link rel="stylesheet" href="/modules/rvnb/rvnb.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">`, <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 { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js"; import { ViewHistory } from "../../../shared/view-history.js";
import { authFetch, requireAuth } from "../../../shared/auth-fetch"; import { authFetch, requireAuth } from "../../../shared/auth-fetch";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
interface VoteSpace { interface VoteSpace {
slug: string; slug: string;
@ -51,6 +52,7 @@ class FolkVoteDashboard extends HTMLElement {
private space = ""; private space = "";
private view: "spaces" | "proposals" | "proposal" | "rank" = "spaces"; private view: "spaces" | "proposals" | "proposal" | "rank" = "spaces";
private spaces: VoteSpace[] = []; private spaces: VoteSpace[] = [];
private _stopPresence: (() => void) | null = null;
private selectedSpace: VoteSpace | null = null; private selectedSpace: VoteSpace | null = null;
private proposals: Proposal[] = []; private proposals: Proposal[] = [];
private selectedProposal: Proposal | null = null; private selectedProposal: Proposal | null = null;
@ -96,11 +98,13 @@ class FolkVoteDashboard extends HTMLElement {
if (!localStorage.getItem("rvote_tour_done")) { if (!localStorage.getItem("rvote_tour_done")) {
setTimeout(() => this._tour.start(), 1200); setTimeout(() => this._tour.start(), 1200);
} }
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rvote', context: this.selectedProposal?.title || 'Voting' }));
} }
disconnectedCallback() { disconnectedCallback() {
for (const unsub of this._offlineUnsubs) unsub(); for (const unsub of this._offlineUnsubs) unsub();
this._offlineUnsubs = []; this._offlineUnsubs = [];
this._stopPresence?.();
} }
private async subscribeOffline() { private async subscribeOffline() {

View File

@ -761,7 +761,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-vote-dashboard space="${space}"></folk-vote-dashboard>`, 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">`, 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 { TourEngine } from "../../../shared/tour-engine";
import { WalletLocalFirstClient } from "../local-first-client"; import { WalletLocalFirstClient } from "../local-first-client";
import type { WalletDoc, WatchedAddress } from "../schemas"; import type { WalletDoc, WatchedAddress } from "../schemas";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
interface ChainInfo { interface ChainInfo {
chainId: string; chainId: string;
@ -210,6 +211,7 @@ class FolkWalletViewer extends HTMLElement {
private lfClient: WalletLocalFirstClient | null = null; private lfClient: WalletLocalFirstClient | null = null;
private _lfcUnsub: (() => void) | null = null; private _lfcUnsub: (() => void) | null = null;
private watchedAddresses: WatchedAddress[] = []; private watchedAddresses: WatchedAddress[] = [];
private _stopPresence: (() => void) | null = null;
constructor() { constructor() {
super(); super();
@ -254,6 +256,11 @@ class FolkWalletViewer extends HTMLElement {
if (!localStorage.getItem("rwallet_tour_done")) { if (!localStorage.getItem("rwallet_tour_done")) {
setTimeout(() => this._tour.start(), 1200); setTimeout(() => this._tour.start(), 1200);
} }
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rwallet', context: 'Wallet' }));
}
disconnectedCallback() {
this._stopPresence?.();
} }
private checkAuthState() { private checkAuthState() {

View File

@ -1239,7 +1239,7 @@ function renderWallet(spaceSlug: string, initialView?: string) {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-wallet-viewer space="${spaceSlug}"${viewAttr}></folk-wallet-viewer>`, 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">`, 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; cursor: { x: number; y: number } | null;
selection: string | null; selection: string | null;
lastSeen: number; 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 { export class RStackCollabOverlay extends HTMLElement {
@ -43,6 +45,7 @@ export class RStackCollabOverlay extends HTMLElement {
#localColor = PEER_COLORS[0]; #localColor = PEER_COLORS[0];
#localUsername = 'Anonymous'; #localUsername = 'Anonymous';
#unsubAwareness: (() => void) | null = null; #unsubAwareness: (() => void) | null = null;
#unsubPresence: (() => void) | null = null;
#mouseMoveTimer: ReturnType<typeof setInterval> | null = null; #mouseMoveTimer: ReturnType<typeof setInterval> | null = null;
#lastCursor = { x: 0, y: 0 }; #lastCursor = { x: 0, y: 0 };
#gcInterval: ReturnType<typeof setInterval> | null = null; #gcInterval: ReturnType<typeof setInterval> | null = null;
@ -101,6 +104,7 @@ export class RStackCollabOverlay extends HTMLElement {
if (!this.#externalPeers) { if (!this.#externalPeers) {
window.removeEventListener('rspace-doc-subscribe', this.#onDocSubscribe); window.removeEventListener('rspace-doc-subscribe', this.#onDocSubscribe);
this.#unsubAwareness?.(); this.#unsubAwareness?.();
this.#unsubPresence?.();
this.#stopMouseTracking(); this.#stopMouseTracking();
this.#stopFocusTracking(); this.#stopFocusTracking();
} }
@ -183,6 +187,28 @@ export class RStackCollabOverlay extends HTMLElement {
if (this.#docId) { if (this.#docId) {
this.#connectToDoc(); 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() { #connectToDoc() {
@ -282,7 +308,8 @@ export class RStackCollabOverlay extends HTMLElement {
// ── Focus tracking on data-collab-id elements ── // ── Focus tracking on data-collab-id elements ──
#focusHandler = (e: FocusEvent) => { #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) { if (target) {
const collabId = target.getAttribute('data-collab-id'); const collabId = target.getAttribute('data-collab-id');
if (collabId) this.#broadcastPresence(undefined, collabId); 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) // Also track clicks on data-collab-id (many elements aren't focusable)
#clickHandler = (e: MouseEvent) => { #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) { if (target) {
const collabId = target.getAttribute('data-collab-id'); const collabId = target.getAttribute('data-collab-id');
if (collabId) this.#broadcastPresence(undefined, collabId); if (collabId) this.#broadcastPresence(undefined, collabId);
@ -412,10 +440,14 @@ export class RStackCollabOverlay extends HTMLElement {
// Self row with Solo/Share toggle // Self row with Solo/Share toggle
const isSolo = this.#soloMode; const isSolo = this.#soloMode;
const selfModule = this.#moduleId || '';
fragments.push(` fragments.push(`
<div class="people-row"> <div class="people-row">
<span class="dot" style="background:${this.#localColor}"></span> <span class="dot" style="background:${this.#localColor}"></span>
<span class="name">${this.#escHtml(this.#localUsername)} <span class="you-tag">(you)</span></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"> <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-solo ${isSolo ? 'active' : ''}" data-action="solo">Solo</button>
<button class="mode-multi ${isSolo ? '' : 'active'}" data-action="share">Share</button> <button class="mode-multi ${isSolo ? '' : 'active'}" data-action="share">Share</button>
@ -426,10 +458,17 @@ export class RStackCollabOverlay extends HTMLElement {
// Remote peer rows // Remote peer rows
const isCanvas = this.#moduleId === 'rspace'; const isCanvas = this.#moduleId === 'rspace';
for (const [pid, peer] of this.#peers) { 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(` fragments.push(`
<div class="people-row"> <div class="people-row">
<span class="dot" style="background:${peer.color}"></span> <span class="dot" style="background:${peer.color}"></span>
<span class="name">${this.#escHtml(peer.username)}</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>` : ''} ${isCanvas ? `<button class="actions-btn" data-pid="${this.#escHtml(pid)}">&gt;</button>` : ''}
</div> </div>
`); `);
@ -568,13 +607,28 @@ export class RStackCollabOverlay extends HTMLElement {
container.innerHTML = fragments.join(''); 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() { #renderFocusRings() {
// Remove all existing focus rings from the document // Remove all existing focus rings from the document
document.querySelectorAll('.rstack-collab-focus-ring').forEach(el => el.remove()); document.querySelectorAll('.rstack-collab-focus-ring').forEach(el => el.remove());
for (const peer of this.#peers.values()) { for (const peer of this.#peers.values()) {
if (!peer.selection) continue; if (!peer.selection) continue;
const target = document.querySelector(`[data-collab-id="${CSS.escape(peer.selection)}"]`); const target = this.#findCollabEl(peer.selection);
if (!target) continue; if (!target) continue;
const rect = target.getBoundingClientRect(); const rect = target.getBoundingClientRect();
@ -761,8 +815,14 @@ const OVERLAY_CSS = `
height: 10px; height: 10px;
} }
.people-row .name { .people-row .name-block {
flex: 1; flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.people-row .name {
font-size: 13px; font-size: 13px;
color: var(--rs-text-primary, #fff); color: var(--rs-text-primary, #fff);
white-space: nowrap; white-space: nowrap;
@ -770,6 +830,15 @@ const OVERLAY_CSS = `
text-overflow: ellipsis; 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 { .you-tag {
font-size: 11px; font-size: 11px;
color: #94a3b8; color: #94a3b8;