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:
parent
677a69645e
commit
df8631360e
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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">`,
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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="">`,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">`,
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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="">`,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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">`,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">`,
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">`,
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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">`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">`,
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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">`,
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">`,
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">`,
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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">`,
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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">`,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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">`,
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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">`,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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">`,
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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">`,
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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">`,
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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="">`,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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">`,
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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">`,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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)}">></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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue