Merge branch 'dev'
CI/CD / deploy (push) Failing after 2m34s
Details
CI/CD / deploy (push) Failing after 2m34s
Details
This commit is contained in:
commit
0641a3189f
|
|
@ -41,6 +41,7 @@ export class CommentPinManager {
|
|||
#members: SpaceMember[] | null = null;
|
||||
#mentionDropdown: HTMLElement | null = null;
|
||||
#notes: RNoteItem[] | null = null;
|
||||
#onDocPointerDown: (e: PointerEvent) => void = () => {};
|
||||
|
||||
constructor(
|
||||
container: HTMLElement,
|
||||
|
|
@ -87,13 +88,14 @@ export class CommentPinManager {
|
|||
});
|
||||
|
||||
// Close popover on outside click
|
||||
document.addEventListener("pointerdown", (e) => {
|
||||
this.#onDocPointerDown = (e: PointerEvent) => {
|
||||
if (this.#popover.style.display === "none") return;
|
||||
if (this.#popover.contains(e.target as Node)) return;
|
||||
// Don't close if clicking a pin marker
|
||||
if ((e.target as HTMLElement)?.closest?.(".comment-pin-marker")) return;
|
||||
this.closePopover();
|
||||
});
|
||||
};
|
||||
document.addEventListener("pointerdown", this.#onDocPointerDown);
|
||||
}
|
||||
|
||||
// ── Camera ──
|
||||
|
|
@ -973,6 +975,7 @@ export class CommentPinManager {
|
|||
}
|
||||
|
||||
destroy() {
|
||||
document.removeEventListener("pointerdown", this.#onDocPointerDown);
|
||||
this.#pinLayer.remove();
|
||||
this.#popover.remove();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -299,6 +299,8 @@ export class FolkCommitmentPool extends FolkShape {
|
|||
if (this.#animFrame) cancelAnimationFrame(this.#animFrame);
|
||||
this.#animFrame = 0;
|
||||
this.#removeGhost();
|
||||
document.removeEventListener("pointermove", this.#onDocPointerMove);
|
||||
document.removeEventListener("pointerup", this.#onDocPointerUp);
|
||||
}
|
||||
|
||||
// ── Data fetching ──
|
||||
|
|
|
|||
|
|
@ -460,6 +460,12 @@ export class FolkDesignAgent extends FolkShape {
|
|||
this.#setState("idle");
|
||||
this.#addStep("!", "error", "Stopped by user");
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback?.();
|
||||
this.#abortController?.abort();
|
||||
this.#abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get(FolkDesignAgent.tagName)) {
|
||||
|
|
|
|||
|
|
@ -595,6 +595,7 @@ export class FolkDrawfast extends FolkShape {
|
|||
#resultArea: HTMLElement | null = null;
|
||||
#canvasArea: HTMLElement | null = null;
|
||||
#gestureEnabled = true;
|
||||
#resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
get strokes() {
|
||||
return this.#strokes;
|
||||
|
|
@ -832,11 +833,11 @@ export class FolkDrawfast extends FolkShape {
|
|||
});
|
||||
|
||||
// Watch for resize
|
||||
const ro = new ResizeObserver(() => {
|
||||
this.#resizeObserver = new ResizeObserver(() => {
|
||||
this.#resizeCanvas(canvasArea);
|
||||
this.#redraw();
|
||||
});
|
||||
ro.observe(canvasArea);
|
||||
this.#resizeObserver.observe(canvasArea);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
|
@ -1133,6 +1134,12 @@ export class FolkDrawfast extends FolkShape {
|
|||
return text.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback?.();
|
||||
this.#resizeObserver?.disconnect();
|
||||
this.#resizeObserver = null;
|
||||
}
|
||||
|
||||
static override fromData(data: Record<string, any>): FolkDrawfast {
|
||||
const shape = FolkShape.fromData(data) as FolkDrawfast;
|
||||
return shape;
|
||||
|
|
|
|||
|
|
@ -351,6 +351,7 @@ export class FolkMakereal extends FolkShape {
|
|||
#promptInput: HTMLInputElement | null = null;
|
||||
#generateBtn: HTMLButtonElement | null = null;
|
||||
#resultArea: HTMLElement | null = null;
|
||||
#resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
override createRenderRoot() {
|
||||
const root = super.createRenderRoot();
|
||||
|
|
@ -524,11 +525,11 @@ export class FolkMakereal extends FolkShape {
|
|||
this.#redraw();
|
||||
});
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
this.#resizeObserver = new ResizeObserver(() => {
|
||||
this.#resizeCanvas(canvasArea);
|
||||
this.#redraw();
|
||||
});
|
||||
ro.observe(canvasArea);
|
||||
this.#resizeObserver.observe(canvasArea);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
|
@ -738,6 +739,12 @@ export class FolkMakereal extends FolkShape {
|
|||
return text.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback?.();
|
||||
this.#resizeObserver?.disconnect();
|
||||
this.#resizeObserver = null;
|
||||
}
|
||||
|
||||
static override fromData(data: Record<string, any>): FolkMakereal {
|
||||
const shape = FolkShape.fromData(data) as FolkMakereal;
|
||||
return shape;
|
||||
|
|
|
|||
|
|
@ -434,6 +434,7 @@ interface MapLibreMap {
|
|||
getZoom(): number;
|
||||
addControl(control: unknown, position?: string): void;
|
||||
on(event: string, handler: (e: MapLibreEvent) => void): void;
|
||||
remove(): void;
|
||||
}
|
||||
|
||||
interface MapLibreEvent {
|
||||
|
|
@ -818,6 +819,8 @@ export class FolkMap extends FolkShape {
|
|||
disconnectedCallback() {
|
||||
super.disconnectedCallback?.();
|
||||
this.#leaveRoom();
|
||||
this.#map?.remove();
|
||||
this.#map = null;
|
||||
}
|
||||
|
||||
// ── MapLibre loading ──
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ export class FolkPiano extends FolkShape {
|
|||
#errorEl: HTMLElement | null = null;
|
||||
#minimizedEl: HTMLElement | null = null;
|
||||
#containerEl: HTMLElement | null = null;
|
||||
#onWindowError: ((e: ErrorEvent) => void) | null = null;
|
||||
|
||||
get isMinimized() {
|
||||
return this.#isMinimized;
|
||||
|
|
@ -249,11 +250,12 @@ export class FolkPiano extends FolkShape {
|
|||
});
|
||||
|
||||
// Suppress Chrome Music Lab console errors
|
||||
window.addEventListener("error", (e) => {
|
||||
this.#onWindowError = (e: ErrorEvent) => {
|
||||
if (e.message?.includes("musiclab") || e.filename?.includes("musiclab")) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
};
|
||||
window.addEventListener("error", this.#onWindowError);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
|
@ -281,6 +283,14 @@ export class FolkPiano extends FolkShape {
|
|||
this.#iframe.src = PIANO_URL;
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback?.();
|
||||
if (this.#onWindowError) {
|
||||
window.removeEventListener("error", this.#onWindowError);
|
||||
this.#onWindowError = null;
|
||||
}
|
||||
}
|
||||
|
||||
static override fromData(data: Record<string, any>): FolkPiano {
|
||||
const shape = FolkShape.fromData(data) as FolkPiano;
|
||||
if (data.isMinimized != null) shape.isMinimized = data.isMinimized;
|
||||
|
|
|
|||
|
|
@ -237,6 +237,7 @@ export class FolkSplat extends FolkShape {
|
|||
#viewerCanvas: HTMLCanvasElement | null = null;
|
||||
#gallerySplats: any[] = [];
|
||||
#urlInput: HTMLInputElement | null = null;
|
||||
#rafId: number | null = null;
|
||||
|
||||
get splatUrl() {
|
||||
return this.#splatUrl;
|
||||
|
|
@ -403,7 +404,7 @@ export class FolkSplat extends FolkShape {
|
|||
if (!this.#viewer) return;
|
||||
(viewer as any).update();
|
||||
(viewer as any).render();
|
||||
requestAnimationFrame(animate);
|
||||
this.#rafId = requestAnimationFrame(animate);
|
||||
};
|
||||
animate();
|
||||
} catch (err) {
|
||||
|
|
@ -436,6 +437,19 @@ export class FolkSplat extends FolkShape {
|
|||
};
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback?.();
|
||||
if (this.#rafId !== null) {
|
||||
cancelAnimationFrame(this.#rafId);
|
||||
this.#rafId = null;
|
||||
}
|
||||
if (this.#viewer) {
|
||||
try { (this.#viewer as any).dispose?.(); } catch { /* ignore */ }
|
||||
this.#viewer = null;
|
||||
}
|
||||
this.#viewerCanvas = null;
|
||||
}
|
||||
|
||||
override applyData(data: Record<string, any>): void {
|
||||
super.applyData(data);
|
||||
if ("splatUrl" in data && this.splatUrl !== data.splatUrl) {
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ class FolkCrowdSurfDashboard extends HTMLElement {
|
|||
// Multiplayer
|
||||
private lfClient: CrowdSurfLocalFirstClient | null = null;
|
||||
private _lfcUnsub: (() => void) | null = null;
|
||||
private _subscribedDocIds: string[] = [];
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
|
||||
// Expiry timer
|
||||
|
|
@ -108,6 +109,11 @@ class FolkCrowdSurfDashboard extends HTMLElement {
|
|||
this._lfcUnsub = null;
|
||||
this.lfClient?.disconnect();
|
||||
if (this._expiryTimer !== null) clearInterval(this._expiryTimer);
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime) {
|
||||
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
|
||||
}
|
||||
this._subscribedDocIds = [];
|
||||
}
|
||||
|
||||
private _onViewRestored = (e: CustomEvent) => {
|
||||
|
|
@ -158,6 +164,7 @@ class FolkCrowdSurfDashboard extends HTMLElement {
|
|||
try {
|
||||
const docId = crowdsurfDocId(this.space) as DocumentId;
|
||||
await runtime.subscribe(docId, crowdsurfSchema);
|
||||
this._subscribedDocIds.push(docId);
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ class FolkBnbView extends HTMLElement {
|
|||
#tour: LightTourEngine | null = null;
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
private _subscribedDocIds: string[] = [];
|
||||
|
||||
connectedCallback() {
|
||||
this.#space = this.getAttribute('space') || 'demo';
|
||||
|
|
@ -88,6 +89,11 @@ class FolkBnbView extends HTMLElement {
|
|||
window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener);
|
||||
this._stopPresence?.();
|
||||
this._offlineUnsub?.(); this._offlineUnsub = null;
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime) {
|
||||
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
|
||||
}
|
||||
this._subscribedDocIds = [];
|
||||
}
|
||||
|
||||
private _onViewRestored = (e: CustomEvent) => {
|
||||
|
|
@ -103,6 +109,7 @@ class FolkBnbView extends HTMLElement {
|
|||
try {
|
||||
const docId = bnbDocId(this.#space) as DocumentId;
|
||||
await runtime.subscribe(docId, bnbSchema);
|
||||
this._subscribedDocIds.push(docId);
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ export class FolkBookReader extends HTMLElement {
|
|||
private _error: string | null = null;
|
||||
private _flipBook: any = null;
|
||||
private _db: IDBDatabase | null = null;
|
||||
private _keyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
private _resizeHandler: (() => void) | null = null;
|
||||
private _resizeTimer: number | null = null;
|
||||
|
||||
static get observedAttributes() {
|
||||
return ["pdf-url", "book-id", "title", "author"];
|
||||
|
|
@ -90,6 +93,9 @@ export class FolkBookReader extends HTMLElement {
|
|||
localStorage.setItem(`book-position-${this._bookId}`, String(this._currentPage));
|
||||
this._flipBook?.destroy();
|
||||
this._db?.close();
|
||||
if (this._keyHandler) { document.removeEventListener("keydown", this._keyHandler); this._keyHandler = null; }
|
||||
if (this._resizeHandler) { window.removeEventListener("resize", this._resizeHandler); this._resizeHandler = null; }
|
||||
if (this._resizeTimer !== null) { clearTimeout(this._resizeTimer); this._resizeTimer = null; }
|
||||
}
|
||||
|
||||
// ── IndexedDB cache ──
|
||||
|
|
@ -340,17 +346,20 @@ export class FolkBookReader extends HTMLElement {
|
|||
});
|
||||
|
||||
// Keyboard nav
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (this._keyHandler) document.removeEventListener("keydown", this._keyHandler);
|
||||
this._keyHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowLeft") this._flipBook?.flipPrev();
|
||||
else if (e.key === "ArrowRight") this._flipBook?.flipNext();
|
||||
});
|
||||
};
|
||||
document.addEventListener("keydown", this._keyHandler);
|
||||
|
||||
// Resize handler
|
||||
let resizeTimer: number;
|
||||
window.addEventListener("resize", () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(() => this.renderReader(), 250);
|
||||
});
|
||||
if (this._resizeHandler) window.removeEventListener("resize", this._resizeHandler);
|
||||
this._resizeHandler = () => {
|
||||
if (this._resizeTimer !== null) clearTimeout(this._resizeTimer);
|
||||
this._resizeTimer = window.setTimeout(() => this.renderReader(), 250);
|
||||
};
|
||||
window.addEventListener("resize", this._resizeHandler);
|
||||
}
|
||||
|
||||
private updatePageCounter() {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export class FolkBookShelf extends HTMLElement {
|
|||
private _searchTerm = "";
|
||||
private _selectedTag: string | null = null;
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
private _subscribedDocIds: string[] = [];
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
private _tour!: TourEngine;
|
||||
private static readonly TOUR_STEPS = [
|
||||
|
|
@ -90,6 +91,11 @@ export class FolkBookShelf extends HTMLElement {
|
|||
this._stopPresence?.();
|
||||
this._offlineUnsub?.();
|
||||
this._offlineUnsub = null;
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime) {
|
||||
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
|
||||
}
|
||||
this._subscribedDocIds = [];
|
||||
}
|
||||
|
||||
private async subscribeOffline() {
|
||||
|
|
@ -99,6 +105,7 @@ export class FolkBookShelf extends HTMLElement {
|
|||
try {
|
||||
const docId = booksCatalogDocId(this._spaceSlug) as DocumentId;
|
||||
const doc = await runtime.subscribe(docId, booksCatalogSchema);
|
||||
this._subscribedDocIds.push(docId);
|
||||
this.renderFromDoc(doc);
|
||||
|
||||
this._offlineUnsub = runtime.onChange(docId, (updated: any) => {
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ class FolkCalendarView extends HTMLElement {
|
|||
private filteredSources = new Set<string>();
|
||||
private boundKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
private _subscribedDocIds: string[] = [];
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
|
||||
// Spatio-temporal state
|
||||
|
|
@ -219,6 +220,11 @@ class FolkCalendarView extends HTMLElement {
|
|||
this._offlineUnsub?.();
|
||||
this._offlineUnsub = null;
|
||||
this._stopPresence?.();
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime) {
|
||||
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
|
||||
}
|
||||
this._subscribedDocIds = [];
|
||||
if (this.boundKeyHandler) {
|
||||
document.removeEventListener("keydown", this.boundKeyHandler);
|
||||
this.boundKeyHandler = null;
|
||||
|
|
@ -247,6 +253,7 @@ class FolkCalendarView extends HTMLElement {
|
|||
try {
|
||||
const docId = calendarDocId(this.space) as DocumentId;
|
||||
const doc = await runtime.subscribe(docId, calendarSchema);
|
||||
this._subscribedDocIds.push(docId);
|
||||
this.renderFromCalDoc(doc);
|
||||
|
||||
this._offlineUnsub = runtime.onChange(docId, (updated: any) => {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ class FolkCartShop extends HTMLElement {
|
|||
private creatingPayment = false;
|
||||
private creatingGroupBuy = false;
|
||||
private _offlineUnsubs: (() => void)[] = [];
|
||||
private _subscribedDocIds: string[] = [];
|
||||
private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "order-detail" | "payments" | "group-buys">("carts", "rcart");
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
|
||||
|
|
@ -100,6 +101,11 @@ class FolkCartShop extends HTMLElement {
|
|||
for (const unsub of this._offlineUnsubs) unsub();
|
||||
this._offlineUnsubs = [];
|
||||
this._stopPresence?.();
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime) {
|
||||
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
|
||||
}
|
||||
this._subscribedDocIds = [];
|
||||
}
|
||||
|
||||
private async subscribeOffline() {
|
||||
|
|
@ -110,6 +116,7 @@ class FolkCartShop extends HTMLElement {
|
|||
// Subscribe to catalog
|
||||
const catDocId = catalogDocId(this.space) as DocumentId;
|
||||
const catDoc = await runtime.subscribe(catDocId, catalogSchema);
|
||||
this._subscribedDocIds.push(catDocId);
|
||||
if (catDoc?.items && Object.keys(catDoc.items).length > 0 && this.catalog.length === 0) {
|
||||
this.catalog = Object.values((catDoc as CatalogDoc).items).map(item => ({
|
||||
id: item.id, title: item.title, description: '',
|
||||
|
|
@ -135,6 +142,7 @@ class FolkCartShop extends HTMLElement {
|
|||
// Subscribe to shopping cart index
|
||||
const indexDocId = shoppingCartIndexDocId(this.space) as DocumentId;
|
||||
const indexDoc = await runtime.subscribe(indexDocId, shoppingCartIndexSchema);
|
||||
this._subscribedDocIds.push(indexDocId);
|
||||
if (indexDoc?.carts && Object.keys(indexDoc.carts).length > 0) {
|
||||
this.carts = Object.entries((indexDoc as ShoppingCartIndexDoc).carts).map(([id, c]) => ({ id, ...c }));
|
||||
this.render();
|
||||
|
|
|
|||
|
|
@ -1116,6 +1116,7 @@ routes.post("/api/shopping-carts/:cartId/items", async (c) => {
|
|||
detail: `Added ${productData.name || 'item'}`,
|
||||
timestamp: now,
|
||||
});
|
||||
if (d.events.length > 200) d.events.splice(0, d.events.length - 200);
|
||||
});
|
||||
|
||||
reindexCart(space, cartId);
|
||||
|
|
@ -1172,6 +1173,7 @@ routes.delete("/api/shopping-carts/:cartId/items/:itemId", async (c) => {
|
|||
detail: `Removed ${itemName}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
if (d.events.length > 200) d.events.splice(0, d.events.length - 200);
|
||||
});
|
||||
|
||||
reindexCart(space, cartId);
|
||||
|
|
@ -1212,6 +1214,7 @@ routes.post("/api/shopping-carts/:cartId/contribute", async (c) => {
|
|||
detail: `Contributed $${amount.toFixed(2)}`,
|
||||
timestamp: now,
|
||||
});
|
||||
if (d.events.length > 200) d.events.splice(0, d.events.length - 200);
|
||||
});
|
||||
|
||||
reindexCart(space, cartId);
|
||||
|
|
@ -1725,6 +1728,7 @@ routes.patch("/api/payments/:id/status", async (c) => {
|
|||
detail: `Paid $${contribAmount.toFixed(2)} via ${updated!.payment.paymentMethod || 'wallet'}`,
|
||||
timestamp: contribNow,
|
||||
});
|
||||
if (d.events.length > 200) d.events.splice(0, d.events.length - 200);
|
||||
});
|
||||
reindexCart(space, linkedCartId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ class FolkChoicesDashboard extends HTMLElement {
|
|||
/* Multiplayer state */
|
||||
private lfClient: ChoicesLocalFirstClient | null = null;
|
||||
private _lfcUnsub: (() => void) | null = null;
|
||||
private _subscribedDocIds: string[] = [];
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
private sessions: ChoiceSession[] = [];
|
||||
private activeSessionId: string | null = null;
|
||||
|
|
@ -119,6 +120,11 @@ class FolkChoicesDashboard extends HTMLElement {
|
|||
this._lfcUnsub?.();
|
||||
this._lfcUnsub = null;
|
||||
this.lfClient?.disconnect();
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime) {
|
||||
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
|
||||
}
|
||||
this._subscribedDocIds = [];
|
||||
}
|
||||
|
||||
private async initMultiplayer() {
|
||||
|
|
@ -157,6 +163,7 @@ class FolkChoicesDashboard extends HTMLElement {
|
|||
try {
|
||||
const docId = choicesDocId(this.space) as DocumentId;
|
||||
await runtime.subscribe(docId, choicesSchema);
|
||||
this._subscribedDocIds.push(docId);
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -186,6 +186,7 @@ class FolkDocsApp extends HTMLElement {
|
|||
private syncConnected = false;
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
private _offlineNotebookUnsubs: (() => void)[] = [];
|
||||
private _subscribedDocIds: string[] = [];
|
||||
|
||||
// ── Presence indicators ──
|
||||
private _presencePeers: Map<string, {
|
||||
|
|
@ -613,6 +614,11 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
|||
this._offlineUnsub = null;
|
||||
for (const unsub of this._offlineNotebookUnsubs) unsub();
|
||||
this._offlineNotebookUnsubs = [];
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime) {
|
||||
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
|
||||
}
|
||||
this._subscribedDocIds = [];
|
||||
}
|
||||
|
||||
// ── Sync (via shared runtime) ──
|
||||
|
|
@ -629,6 +635,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
|||
try {
|
||||
const docId = this.subscribedDocId as DocumentId;
|
||||
const doc = await runtime.subscribe(docId, notebookSchema);
|
||||
this._subscribedDocIds.push(docId);
|
||||
this.doc = doc;
|
||||
this.renderFromDoc();
|
||||
|
||||
|
|
|
|||
|
|
@ -488,6 +488,7 @@ export function createExchangeRoutes(getSyncServer: () => SyncServer | null) {
|
|||
if (!body.text?.trim()) return c.json({ error: 'text required' }, 400);
|
||||
|
||||
const msgId = crypto.randomUUID();
|
||||
const MAX_CHAT_MESSAGES = 200;
|
||||
ss().changeDoc<ExchangeTradesDoc>(exchangeTradesDocId(space), 'trade chat message', (d) => {
|
||||
if (!d.trades[id].chatMessages) d.trades[id].chatMessages = [] as any;
|
||||
(d.trades[id].chatMessages as any[]).push({
|
||||
|
|
@ -497,6 +498,11 @@ export function createExchangeRoutes(getSyncServer: () => SyncServer | null) {
|
|||
text: body.text.trim(),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
// Cap chat history to prevent unbounded CRDT growth
|
||||
const msgs = d.trades[id].chatMessages as any[];
|
||||
if (msgs.length > MAX_CHAT_MESSAGES) {
|
||||
msgs.splice(0, msgs.length - MAX_CHAT_MESSAGES);
|
||||
}
|
||||
});
|
||||
|
||||
const updated = ss().getDoc<ExchangeTradesDoc>(exchangeTradesDocId(space))!;
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
private loading = false;
|
||||
private error = "";
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
private _subscribedDocIds: string[] = [];
|
||||
|
||||
// Canvas state
|
||||
private canvasZoom = 1;
|
||||
|
|
@ -201,6 +202,10 @@ class FolkFlowsApp extends HTMLElement {
|
|||
// Tour engine
|
||||
private _tour!: TourEngine;
|
||||
|
||||
// Cleanup handles
|
||||
private _themeChangeHandler: (() => void) | null = null;
|
||||
private _mutationObserver: MutationObserver | null = null;
|
||||
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '[data-canvas-action="add-source"]', title: "Add a Source", message: "Sources represent inflows of resources. Click the + Source button to add one.", advanceOnClick: true },
|
||||
{ target: '[data-canvas-action="add-funnel"]', title: "Add a Funnel", message: "Funnels allocate resources between spending and overflow. Click + Funnel to add one.", advanceOnClick: true },
|
||||
|
|
@ -227,9 +232,10 @@ class FolkFlowsApp extends HTMLElement {
|
|||
|
||||
// Mirror document theme to host for shadow DOM CSS selectors
|
||||
this._syncTheme();
|
||||
document.addEventListener("theme-change", () => this._syncTheme());
|
||||
new MutationObserver(() => this._syncTheme())
|
||||
.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
|
||||
this._themeChangeHandler = () => this._syncTheme();
|
||||
document.addEventListener("theme-change", this._themeChangeHandler);
|
||||
this._mutationObserver = new MutationObserver(() => this._syncTheme());
|
||||
this._mutationObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
|
||||
|
||||
// Read view attribute, default to canvas (detail) view
|
||||
const viewAttr = this.getAttribute("view");
|
||||
|
|
@ -364,6 +370,11 @@ class FolkFlowsApp extends HTMLElement {
|
|||
disconnectedCallback() {
|
||||
this._offlineUnsub?.();
|
||||
this._offlineUnsub = null;
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime) {
|
||||
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
|
||||
}
|
||||
this._subscribedDocIds = [];
|
||||
this._lfcUnsub?.();
|
||||
this._lfcUnsub = null;
|
||||
this._stopPresence?.();
|
||||
|
|
@ -372,6 +383,12 @@ class FolkFlowsApp extends HTMLElement {
|
|||
if (this.saveTimer) { clearTimeout(this.saveTimer); this.saveTimer = null; }
|
||||
if (this.budgetSaveTimer) { clearTimeout(this.budgetSaveTimer); this.budgetSaveTimer = null; }
|
||||
this.localFirstClient?.disconnect();
|
||||
if (this._themeChangeHandler) {
|
||||
document.removeEventListener("theme-change", this._themeChangeHandler);
|
||||
this._themeChangeHandler = null;
|
||||
}
|
||||
this._mutationObserver?.disconnect();
|
||||
this._mutationObserver = null;
|
||||
}
|
||||
|
||||
// ─── Auto-save (debounced) ──────────────────────────────
|
||||
|
|
@ -533,6 +550,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
try {
|
||||
const docId = flowsDocId(this.space) as DocumentId;
|
||||
const doc = await runtime.subscribe(docId, flowsSchema);
|
||||
this._subscribedDocIds.push(docId);
|
||||
|
||||
// Render cached flow associations immediately
|
||||
this.renderFlowsFromDoc(doc);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ class FolkForumDashboard extends HTMLElement {
|
|||
private pollTimer: number | null = null;
|
||||
private space = "";
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
private _subscribedDocIds: string[] = [];
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
private _history = new ViewHistory<"list" | "detail" | "create">("list", "rforum");
|
||||
private _tour!: TourEngine;
|
||||
|
|
@ -73,6 +74,11 @@ class FolkForumDashboard extends HTMLElement {
|
|||
if (this.pollTimer) clearInterval(this.pollTimer);
|
||||
this._offlineUnsub?.();
|
||||
this._offlineUnsub = null;
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime) {
|
||||
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
|
||||
}
|
||||
this._subscribedDocIds = [];
|
||||
}
|
||||
|
||||
private async subscribeOffline() {
|
||||
|
|
@ -83,6 +89,7 @@ class FolkForumDashboard extends HTMLElement {
|
|||
// Forum uses a global doc (not space-scoped)
|
||||
const docId = FORUM_DOC_ID as DocumentId;
|
||||
const doc = await runtime.subscribe(docId, forumSchema);
|
||||
this._subscribedDocIds.push(docId);
|
||||
|
||||
if (doc?.instances && Object.keys(doc.instances).length > 0 && this.instances.length === 0) {
|
||||
this.instances = Object.values((doc as ForumDoc).instances).map(inst => ({
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@ class FolkMapViewer extends HTMLElement {
|
|||
private _history = new ViewHistory<"lobby" | "map">("lobby", "rmaps");
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
private _subscribedDocIds: string[] = [];
|
||||
|
||||
// Chat + Local-first state
|
||||
private lfClient: MapsLocalFirstClient | null = null;
|
||||
|
|
@ -226,6 +227,11 @@ class FolkMapViewer extends HTMLElement {
|
|||
window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener);
|
||||
this._stopPresence?.();
|
||||
this._offlineUnsub?.(); this._offlineUnsub = null;
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime) {
|
||||
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
|
||||
}
|
||||
this._subscribedDocIds = [];
|
||||
if (this._demoInterval) { clearInterval(this._demoInterval); this._demoInterval = null; }
|
||||
this.leaveRoom();
|
||||
if (this._themeObserver) {
|
||||
|
|
@ -251,6 +257,7 @@ class FolkMapViewer extends HTMLElement {
|
|||
try {
|
||||
const docId = mapsDocId(this.space) as DocumentId;
|
||||
await runtime.subscribe(docId, mapsSchema);
|
||||
this._subscribedDocIds.push(docId);
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ class FolkJitsiRoom extends HTMLElement {
|
|||
private directorError = "";
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
private _subscribedDocIds: string[] = [];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -52,6 +53,11 @@ class FolkJitsiRoom extends HTMLElement {
|
|||
disconnectedCallback() {
|
||||
this._stopPresence?.();
|
||||
this._offlineUnsub?.(); this._offlineUnsub = null;
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime) {
|
||||
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
|
||||
}
|
||||
this._subscribedDocIds = [];
|
||||
this.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -61,6 +67,7 @@ class FolkJitsiRoom extends HTMLElement {
|
|||
try {
|
||||
const docId = meetsDocId(this.space) as DocumentId;
|
||||
await runtime.subscribe(docId, meetsSchema);
|
||||
this._subscribedDocIds.push(docId);
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ class FolkCrmView extends HTMLElement {
|
|||
private graphSelectedId: string | null = null;
|
||||
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
private _subscribedDocIds: string[] = [];
|
||||
|
||||
// Guided tour
|
||||
private _tour!: TourEngine;
|
||||
|
|
@ -175,6 +176,11 @@ class FolkCrmView extends HTMLElement {
|
|||
window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener);
|
||||
document.removeEventListener("rapp-tab-change", this._onTabChange);
|
||||
this._stopPresence?.();
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime) {
|
||||
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
|
||||
}
|
||||
this._subscribedDocIds = [];
|
||||
}
|
||||
|
||||
private _onViewRestored = (e: CustomEvent) => {
|
||||
|
|
@ -190,6 +196,7 @@ class FolkCrmView extends HTMLElement {
|
|||
try {
|
||||
const docId = networkDocId(this.space) as DocumentId;
|
||||
await runtime.subscribe(docId, networkSchema);
|
||||
this._subscribedDocIds.push(docId);
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@ class FolkGraphViewer extends HTMLElement {
|
|||
private _textSpriteCache = new Map<string, any>();
|
||||
private _badgeSpriteCache = new Map<string, any>();
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
private _subscribedDocIds: string[] = [];
|
||||
|
||||
// Local-first client for layer config persistence
|
||||
private _lfClient: NetworkLocalFirstClient | null = null;
|
||||
|
|
@ -237,6 +238,11 @@ class FolkGraphViewer extends HTMLElement {
|
|||
this.graph._destructor?.();
|
||||
this.graph = null;
|
||||
}
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime) {
|
||||
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
|
||||
}
|
||||
this._subscribedDocIds = [];
|
||||
}
|
||||
|
||||
private async subscribeCollabOverlay() {
|
||||
|
|
@ -245,6 +251,7 @@ class FolkGraphViewer extends HTMLElement {
|
|||
try {
|
||||
const docId = networkDocId(this.space) as DocumentId;
|
||||
await runtime.subscribe(docId, networkSchema);
|
||||
this._subscribedDocIds.push(docId);
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
private _tour!: TourEngine;
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
private _subscribedDocIds: string[] = [];
|
||||
private _history = new ViewHistory<"gallery" | "album" | "lightbox">("gallery", "rphotos");
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '.album-card', title: "Albums", message: "Browse shared photo albums — click one to see its photos.", advanceOnClick: false },
|
||||
|
|
@ -90,6 +91,11 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
disconnectedCallback() {
|
||||
this._stopPresence?.();
|
||||
this._offlineUnsub?.(); this._offlineUnsub = null;
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime) {
|
||||
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
|
||||
}
|
||||
this._subscribedDocIds = [];
|
||||
this._history.destroy();
|
||||
window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener);
|
||||
}
|
||||
|
|
@ -108,6 +114,7 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
try {
|
||||
const docId = photosDocId(this.space) as DocumentId;
|
||||
await runtime.subscribe(docId, photosSchema);
|
||||
this._subscribedDocIds.push(docId);
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export class FolkPubsFlipbook extends HTMLElement {
|
|||
private _flipBook: any = null;
|
||||
private _keyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
private _resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private _resizeHandler: (() => void) | null = null;
|
||||
|
||||
static get observedAttributes() {
|
||||
return ["pdf-url"];
|
||||
|
|
@ -46,8 +47,9 @@ export class FolkPubsFlipbook extends HTMLElement {
|
|||
|
||||
disconnectedCallback() {
|
||||
this._flipBook?.destroy();
|
||||
if (this._keyHandler) document.removeEventListener("keydown", this._keyHandler);
|
||||
if (this._resizeTimer) clearTimeout(this._resizeTimer);
|
||||
if (this._keyHandler) { document.removeEventListener("keydown", this._keyHandler); this._keyHandler = null; }
|
||||
if (this._resizeTimer) { clearTimeout(this._resizeTimer); this._resizeTimer = null; }
|
||||
if (this._resizeHandler) { window.removeEventListener("resize", this._resizeHandler); this._resizeHandler = null; }
|
||||
}
|
||||
|
||||
private async loadPDF() {
|
||||
|
|
@ -294,10 +296,12 @@ export class FolkPubsFlipbook extends HTMLElement {
|
|||
};
|
||||
document.addEventListener("keydown", this._keyHandler);
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
if (this._resizeHandler) window.removeEventListener("resize", this._resizeHandler);
|
||||
this._resizeHandler = () => {
|
||||
if (this._resizeTimer) clearTimeout(this._resizeTimer);
|
||||
this._resizeTimer = setTimeout(() => this.renderReader(), 250);
|
||||
});
|
||||
};
|
||||
window.addEventListener("resize", this._resizeHandler);
|
||||
}
|
||||
|
||||
private renderFallback() {
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ class FolkScheduleApp extends HTMLElement {
|
|||
private loading = false;
|
||||
private runningJobId: string | null = null;
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
private _subscribedDocIds: string[] = [];
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
private _tour!: TourEngine;
|
||||
private static readonly TOUR_STEPS = [
|
||||
|
|
@ -148,6 +149,11 @@ class FolkScheduleApp extends HTMLElement {
|
|||
this._offlineUnsub = null;
|
||||
}
|
||||
this._stopPresence?.();
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime) {
|
||||
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
|
||||
}
|
||||
this._subscribedDocIds = [];
|
||||
}
|
||||
|
||||
private async subscribeOffline() {
|
||||
|
|
@ -157,6 +163,7 @@ class FolkScheduleApp extends HTMLElement {
|
|||
try {
|
||||
const docId = scheduleDocId(this.space) as DocumentId;
|
||||
const doc = await runtime.subscribe(docId, scheduleSchema);
|
||||
this._subscribedDocIds.push(docId);
|
||||
if (doc) this.renderFromDoc(doc as ScheduleDoc);
|
||||
|
||||
this._offlineUnsub = runtime.onChange(docId, (doc: ScheduleDoc) => {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export class FolkCampaignManager extends HTMLElement {
|
|||
private _space = 'demo';
|
||||
private _campaigns: Campaign[] = [];
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
private _subscribedDocIds: string[] = [];
|
||||
|
||||
// AI generation state
|
||||
private _generatedCampaign: Campaign | null = null;
|
||||
|
|
@ -58,6 +59,11 @@ export class FolkCampaignManager extends HTMLElement {
|
|||
disconnectedCallback() {
|
||||
this._offlineUnsub?.();
|
||||
this._offlineUnsub = null;
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime) {
|
||||
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
|
||||
}
|
||||
this._subscribedDocIds = [];
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string, val: string) {
|
||||
|
|
@ -71,6 +77,7 @@ export class FolkCampaignManager extends HTMLElement {
|
|||
try {
|
||||
const docId = socialsDocId(this._space) as DocumentId;
|
||||
const doc = await runtime.subscribe(docId, socialsSchema);
|
||||
this._subscribedDocIds.push(docId);
|
||||
this.renderFromDoc(doc);
|
||||
|
||||
this._offlineUnsub = runtime.onChange(docId, (updated: any) => {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export class FolkThreadBuilder extends HTMLElement {
|
|||
private _tweetImages: Record<string, string> = {};
|
||||
private _autoSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
private _subscribedDocIds: string[] = [];
|
||||
private _offlineReady: Promise<void> | null = null;
|
||||
private _tweetImageUploadIdx: string | null = null;
|
||||
private _linkPreviewCache: Map<string, { title: string; description: string; image: string | null; domain: string } | null> = new Map();
|
||||
|
|
@ -72,6 +73,11 @@ export class FolkThreadBuilder extends HTMLElement {
|
|||
this._offlineUnsub?.();
|
||||
this._offlineUnsub = null;
|
||||
if (this._autoSaveTimer) clearTimeout(this._autoSaveTimer);
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime) {
|
||||
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
|
||||
}
|
||||
this._subscribedDocIds = [];
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string, val: string) {
|
||||
|
|
@ -111,6 +117,7 @@ export class FolkThreadBuilder extends HTMLElement {
|
|||
try {
|
||||
const docId = socialsDocId(this._space) as DocumentId;
|
||||
const doc = await runtime.subscribe(docId, socialsSchema);
|
||||
this._subscribedDocIds.push(docId);
|
||||
|
||||
if (this._threadId && doc?.threads?.[this._threadId] && !this._thread) {
|
||||
// Deep-clone to get a plain object (not an Automerge proxy)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export class FolkThreadGallery extends HTMLElement {
|
|||
private _threads: ThreadData[] = [];
|
||||
private _draftPosts: DraftPostCard[] = [];
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
private _subscribedDocIds: string[] = [];
|
||||
private _isDemoFallback = false;
|
||||
|
||||
static get observedAttributes() { return ['space']; }
|
||||
|
|
@ -41,6 +42,11 @@ export class FolkThreadGallery extends HTMLElement {
|
|||
disconnectedCallback() {
|
||||
this._offlineUnsub?.();
|
||||
this._offlineUnsub = null;
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime) {
|
||||
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
|
||||
}
|
||||
this._subscribedDocIds = [];
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string, val: string) {
|
||||
|
|
@ -62,6 +68,7 @@ export class FolkThreadGallery extends HTMLElement {
|
|||
try {
|
||||
const docId = socialsDocId(this._space) as DocumentId;
|
||||
const doc = await runtime.subscribe(docId, socialsSchema);
|
||||
this._subscribedDocIds.push(docId);
|
||||
this.renderFromDoc(doc);
|
||||
|
||||
this._offlineUnsub = runtime.onChange(docId, (updated: any) => {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
private _inlineViewer = false;
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
private _subscribedDocIds: string[] = [];
|
||||
private _generatedUrl = "";
|
||||
private _generatedTitle = "";
|
||||
private _savedSlug = "";
|
||||
|
|
@ -100,6 +101,7 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
try {
|
||||
const docId = splatScenesDocId(this._spaceSlug) as DocumentId;
|
||||
const doc = await runtime.subscribe(docId, splatScenesSchema);
|
||||
this._subscribedDocIds.push(docId);
|
||||
this.renderFromDoc(doc);
|
||||
|
||||
this._offlineUnsub = runtime.onChange(docId, (updated: any) => {
|
||||
|
|
@ -155,6 +157,11 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
this._stopPresence?.();
|
||||
this._offlineUnsub?.();
|
||||
this._offlineUnsub = null;
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime) {
|
||||
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
|
||||
}
|
||||
this._subscribedDocIds = [];
|
||||
if (this._viewer) {
|
||||
try { this._viewer.dispose(); } catch {}
|
||||
this._viewer = null;
|
||||
|
|
|
|||
|
|
@ -246,6 +246,7 @@ class FolkSwagDesigner extends HTMLElement {
|
|||
private lfClient: SwagLocalFirstClient | null = null;
|
||||
private _lfcUnsub: (() => void) | null = null;
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
private _subscribedDocIds: string[] = [];
|
||||
private sharedDesigns: SwagDesign[] = [];
|
||||
|
||||
private _tour!: TourEngine;
|
||||
|
|
@ -296,6 +297,11 @@ class FolkSwagDesigner extends HTMLElement {
|
|||
this._lfcUnsub?.();
|
||||
this._lfcUnsub = null;
|
||||
this.lfClient?.disconnect();
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime) {
|
||||
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
|
||||
}
|
||||
this._subscribedDocIds = [];
|
||||
}
|
||||
|
||||
private _onViewRestored = (e: CustomEvent) => {
|
||||
|
|
@ -330,6 +336,7 @@ class FolkSwagDesigner extends HTMLElement {
|
|||
try {
|
||||
const docId = swagDocId(this.space) as DocumentId;
|
||||
await runtime.subscribe(docId, swagSchema);
|
||||
this._subscribedDocIds.push(docId);
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -328,6 +328,8 @@ class FolkTimebankApp extends HTMLElement {
|
|||
private _theme: 'dark' | 'light' = 'dark';
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
private _subscribedDocIds: string[] = [];
|
||||
private _resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -368,6 +370,7 @@ class FolkTimebankApp extends HTMLElement {
|
|||
try {
|
||||
const docId = commitmentsDocId(this.space) as DocumentId;
|
||||
await runtime.subscribe(docId, commitmentsSchema);
|
||||
this._subscribedDocIds.push(docId);
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
|
|
@ -399,6 +402,13 @@ class FolkTimebankApp extends HTMLElement {
|
|||
window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener);
|
||||
if (this.animFrame) cancelAnimationFrame(this.animFrame);
|
||||
this._stopPresence?.(); this._offlineUnsub?.(); this._offlineUnsub = null;
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime) {
|
||||
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
|
||||
}
|
||||
this._subscribedDocIds = [];
|
||||
this._resizeObserver?.disconnect();
|
||||
this._resizeObserver = null;
|
||||
}
|
||||
|
||||
private _onViewRestored = (e: CustomEvent) => {
|
||||
|
|
@ -806,8 +816,8 @@ class FolkTimebankApp extends HTMLElement {
|
|||
});
|
||||
|
||||
// Resize — now targets pool panel
|
||||
const resizeObserver = new ResizeObserver(() => { if (this.currentView === 'canvas') this.resizePoolCanvas(); });
|
||||
resizeObserver.observe(this);
|
||||
this._resizeObserver = new ResizeObserver(() => { if (this.currentView === 'canvas') this.resizePoolCanvas(); });
|
||||
this._resizeObserver.observe(this);
|
||||
|
||||
this.resizePoolCanvas();
|
||||
this.poolFrame();
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ class FolkVideoPlayer extends HTMLElement {
|
|||
private expandedView: number | null = null;
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
private _subscribedDocIds: string[] = [];
|
||||
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 },
|
||||
|
|
@ -73,6 +74,13 @@ class FolkVideoPlayer extends HTMLElement {
|
|||
window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener);
|
||||
this._stopPresence?.();
|
||||
this._offlineUnsub?.(); this._offlineUnsub = null;
|
||||
if (this.splitPollInterval) { clearInterval(this.splitPollInterval); this.splitPollInterval = null; }
|
||||
if (this.liveSplitStatusInterval) { clearInterval(this.liveSplitStatusInterval); this.liveSplitStatusInterval = null; }
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime) {
|
||||
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
|
||||
}
|
||||
this._subscribedDocIds = [];
|
||||
}
|
||||
|
||||
private _onViewRestored = (e: CustomEvent) => {
|
||||
|
|
@ -90,6 +98,7 @@ class FolkVideoPlayer extends HTMLElement {
|
|||
try {
|
||||
const docId = tubeDocId(this.space) as DocumentId;
|
||||
await runtime.subscribe(docId, tubeSchema);
|
||||
this._subscribedDocIds.push(docId);
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ class FolkVnbView extends HTMLElement {
|
|||
#tour: LightTourEngine | null = null;
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
private _subscribedDocIds: string[] = [];
|
||||
|
||||
connectedCallback() {
|
||||
this.#space = this.getAttribute('space') || 'demo';
|
||||
|
|
@ -93,6 +94,11 @@ class FolkVnbView extends HTMLElement {
|
|||
window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener);
|
||||
this._stopPresence?.();
|
||||
this._offlineUnsub?.(); this._offlineUnsub = null;
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime) {
|
||||
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
|
||||
}
|
||||
this._subscribedDocIds = [];
|
||||
}
|
||||
|
||||
private _onViewRestored = (e: CustomEvent) => {
|
||||
|
|
@ -108,6 +114,7 @@ class FolkVnbView extends HTMLElement {
|
|||
try {
|
||||
const docId = vnbDocId(this.#space) as DocumentId;
|
||||
await runtime.subscribe(docId, vnbSchema);
|
||||
this._subscribedDocIds.push(docId);
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -226,6 +226,7 @@ class FolkWalletViewer extends HTMLElement {
|
|||
// Multiplayer state
|
||||
private lfClient: WalletLocalFirstClient | null = null;
|
||||
private _lfcUnsub: (() => void) | null = null;
|
||||
private _subscribedDocIds: string[] = [];
|
||||
private watchedAddresses: WatchedAddress[] = [];
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
|
||||
|
|
@ -280,6 +281,12 @@ class FolkWalletViewer extends HTMLElement {
|
|||
clearInterval(this.flowsPlayInterval);
|
||||
this.flowsPlayInterval = null;
|
||||
}
|
||||
if (this._quoteTimer) { clearTimeout(this._quoteTimer); this._quoteTimer = null; }
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime) {
|
||||
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
|
||||
}
|
||||
this._subscribedDocIds = [];
|
||||
}
|
||||
|
||||
private _onViewRestored = (e: CustomEvent) => {
|
||||
|
|
@ -341,6 +348,7 @@ class FolkWalletViewer extends HTMLElement {
|
|||
try {
|
||||
const docId = walletDocId(this.space) as DocumentId;
|
||||
await runtime.subscribe(docId, walletSchema);
|
||||
this._subscribedDocIds.push(docId);
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -104,6 +104,10 @@ export interface SyncServerOptions {
|
|||
onRelayBackup?: (docId: string, blob: Uint8Array) => void;
|
||||
/** Called to load a relay blob for restore on subscribe */
|
||||
onRelayLoad?: (docId: string) => Promise<Uint8Array | null>;
|
||||
/** Called before evicting a doc from memory (persist to disk) */
|
||||
onDocEvict?: (docId: string, doc: Automerge.Doc<any>) => void;
|
||||
/** Max docs to keep in memory; 0 = unlimited (default: 500) */
|
||||
maxDocs?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -114,18 +118,25 @@ export class SyncServer {
|
|||
#peers = new Map<string, Peer>();
|
||||
#docs = new Map<string, Automerge.Doc<any>>();
|
||||
#docSubscribers = new Map<string, Set<string>>(); // docId → Set<peerId>
|
||||
#docLastAccess = new Map<string, number>(); // docId → timestamp
|
||||
#participantMode: boolean;
|
||||
#relayOnlyDocs = new Set<string>(); // docIds forced to relay mode (encrypted spaces)
|
||||
#watchers: Array<{ prefix: string; cb: (docId: string, doc: Automerge.Doc<any>) => void }> = [];
|
||||
#onDocChange?: (docId: string, doc: Automerge.Doc<any>) => void;
|
||||
#onRelayBackup?: (docId: string, blob: Uint8Array) => void;
|
||||
#onRelayLoad?: (docId: string) => Promise<Uint8Array | null>;
|
||||
/** Called before evicting a doc so it can be persisted to disk. */
|
||||
#onDocEvict?: (docId: string, doc: Automerge.Doc<any>) => void;
|
||||
/** Max docs to keep in memory (0 = unlimited). */
|
||||
#maxDocs: number;
|
||||
|
||||
constructor(opts: SyncServerOptions = {}) {
|
||||
this.#participantMode = opts.participantMode ?? true;
|
||||
this.#onDocChange = opts.onDocChange;
|
||||
this.#onRelayBackup = opts.onRelayBackup;
|
||||
this.#onRelayLoad = opts.onRelayLoad;
|
||||
this.#onDocEvict = opts.onDocEvict;
|
||||
this.#maxDocs = opts.maxDocs ?? 500;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -231,6 +242,7 @@ export class SyncServer {
|
|||
* Get a server-side document (participant mode).
|
||||
*/
|
||||
getDoc<T>(docId: string): Automerge.Doc<T> | undefined {
|
||||
if (this.#docs.has(docId)) this.#touchDoc(docId);
|
||||
return this.#docs.get(docId);
|
||||
}
|
||||
|
||||
|
|
@ -246,10 +258,12 @@ export class SyncServer {
|
|||
*/
|
||||
setDoc(docId: string, doc: Automerge.Doc<any>): void {
|
||||
this.#docs.set(docId, doc);
|
||||
this.#touchDoc(docId);
|
||||
this.#syncDocToAllPeers(docId);
|
||||
if (this.#onDocChange) {
|
||||
this.#onDocChange(docId, doc);
|
||||
}
|
||||
this.#evictIfNeeded();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -261,6 +275,7 @@ export class SyncServer {
|
|||
|
||||
doc = Automerge.change(doc, message, fn as any);
|
||||
this.#docs.set(docId, doc);
|
||||
this.#touchDoc(docId);
|
||||
this.#syncDocToAllPeers(docId);
|
||||
|
||||
if (this.#onDocChange) {
|
||||
|
|
@ -290,9 +305,15 @@ export class SyncServer {
|
|||
|
||||
/**
|
||||
* Register a callback to fire when any doc whose ID contains `prefix` changes.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
registerWatcher(prefix: string, cb: (docId: string, doc: Automerge.Doc<any>) => void): void {
|
||||
this.#watchers.push({ prefix, cb });
|
||||
registerWatcher(prefix: string, cb: (docId: string, doc: Automerge.Doc<any>) => void): () => void {
|
||||
const entry = { prefix, cb };
|
||||
this.#watchers.push(entry);
|
||||
return () => {
|
||||
const idx = this.#watchers.indexOf(entry);
|
||||
if (idx >= 0) this.#watchers.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -375,6 +396,7 @@ export class SyncServer {
|
|||
// Create an empty doc if this is the first time we see this docId
|
||||
doc = Automerge.init();
|
||||
this.#docs.set(docId, doc);
|
||||
this.#evictIfNeeded();
|
||||
}
|
||||
|
||||
let syncState = peer.syncStates.get(docId) ?? Automerge.initSyncState();
|
||||
|
|
@ -382,6 +404,7 @@ export class SyncServer {
|
|||
|
||||
const changed = newDoc !== doc;
|
||||
this.#docs.set(docId, newDoc);
|
||||
this.#touchDoc(docId);
|
||||
peer.syncStates.set(docId, newSyncState);
|
||||
|
||||
// Send response sync message back to this peer
|
||||
|
|
@ -493,4 +516,50 @@ export class SyncServer {
|
|||
console.error(`[SyncServer] Failed to send to peer ${peer.id}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark a doc as recently accessed for LRU tracking. */
|
||||
#touchDoc(docId: string): void {
|
||||
this.#docLastAccess.set(docId, Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict least-recently-used docs that have no active subscribers.
|
||||
* Called after adding new docs to keep memory bounded.
|
||||
*/
|
||||
#evictIfNeeded(): void {
|
||||
if (this.#maxDocs <= 0 || this.#docs.size <= this.#maxDocs) return;
|
||||
|
||||
// Build list of evictable docs (no active subscribers)
|
||||
const evictable: Array<{ docId: string; lastAccess: number }> = [];
|
||||
for (const docId of this.#docs.keys()) {
|
||||
const subs = this.#docSubscribers.get(docId);
|
||||
if (subs && subs.size > 0) continue; // has active subscribers, skip
|
||||
evictable.push({
|
||||
docId,
|
||||
lastAccess: this.#docLastAccess.get(docId) ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by oldest access first
|
||||
evictable.sort((a, b) => a.lastAccess - b.lastAccess);
|
||||
|
||||
// Evict until under the cap
|
||||
const toEvict = this.#docs.size - this.#maxDocs;
|
||||
let evicted = 0;
|
||||
for (const { docId } of evictable) {
|
||||
if (evicted >= toEvict) break;
|
||||
|
||||
const doc = this.#docs.get(docId);
|
||||
if (doc && this.#onDocEvict) {
|
||||
this.#onDocEvict(docId, doc);
|
||||
}
|
||||
this.#docs.delete(docId);
|
||||
this.#docLastAccess.delete(docId);
|
||||
evicted++;
|
||||
}
|
||||
|
||||
if (evicted > 0) {
|
||||
console.log(`[SyncServer] Evicted ${evicted} idle docs (${this.#docs.size} remaining)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,10 +32,16 @@ function getEncryptionKeyId(docId: string): string | undefined {
|
|||
|
||||
export const syncServer = new SyncServer({
|
||||
participantMode: true,
|
||||
maxDocs: 500,
|
||||
onDocChange: (docId, doc) => {
|
||||
const encryptionKeyId = getEncryptionKeyId(docId);
|
||||
saveDoc(docId, doc, encryptionKeyId);
|
||||
},
|
||||
onDocEvict: (docId, doc) => {
|
||||
// Persist to disk before evicting from memory
|
||||
const encryptionKeyId = getEncryptionKeyId(docId);
|
||||
saveDoc(docId, doc, encryptionKeyId);
|
||||
},
|
||||
onRelayBackup: (docId, blob) => {
|
||||
saveEncryptedBlob(docId, blob);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Automerge browser shim — resolves to the globally-loaded Automerge instance
|
||||
* exposed by shell-offline.ts via window.__automerge.
|
||||
*
|
||||
* Module builds alias '@automerge/automerge' to this file to avoid
|
||||
* re-bundling the full Automerge + WASM (~2.5MB) in every module.
|
||||
*/
|
||||
const Automerge = (window as any).__automerge;
|
||||
if (!Automerge) {
|
||||
console.warn('[automerge-shim] Automerge not loaded yet — shell-offline may not have initialized');
|
||||
}
|
||||
export default Automerge;
|
||||
export const {
|
||||
init,
|
||||
from,
|
||||
change,
|
||||
emptyChange,
|
||||
load,
|
||||
save,
|
||||
merge,
|
||||
getActorId,
|
||||
getHeads,
|
||||
getHistory,
|
||||
getChanges,
|
||||
getAllChanges,
|
||||
applyChanges,
|
||||
generateSyncMessage,
|
||||
receiveSyncMessage,
|
||||
initSyncState,
|
||||
encodeSyncState,
|
||||
decodeSyncState,
|
||||
clone,
|
||||
free,
|
||||
getConflicts,
|
||||
getMissingDeps,
|
||||
equals,
|
||||
dump,
|
||||
toJS,
|
||||
} = Automerge ?? {};
|
||||
|
|
@ -32,6 +32,7 @@ export class RStackSpaceSwitcher extends HTMLElement {
|
|||
#createFormOpen = false;
|
||||
#createName = '';
|
||||
#createVisibility = 'public';
|
||||
#onDocPointerDown: ((e: PointerEvent) => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -58,6 +59,13 @@ export class RStackSpaceSwitcher extends HTMLElement {
|
|||
this.#render();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.#onDocPointerDown) {
|
||||
document.removeEventListener("pointerdown", this.#onDocPointerDown);
|
||||
this.#onDocPointerDown = null;
|
||||
}
|
||||
}
|
||||
|
||||
async #loadSpaces() {
|
||||
if (this.#loaded) return;
|
||||
try {
|
||||
|
|
@ -123,13 +131,17 @@ export class RStackSpaceSwitcher extends HTMLElement {
|
|||
});
|
||||
|
||||
// Close menu only when clicking outside both trigger and menu
|
||||
document.addEventListener("pointerdown", (e) => {
|
||||
if (this.#onDocPointerDown) {
|
||||
document.removeEventListener("pointerdown", this.#onDocPointerDown);
|
||||
}
|
||||
this.#onDocPointerDown = (e: PointerEvent) => {
|
||||
if (!menu.classList.contains("open")) return;
|
||||
const path = e.composedPath();
|
||||
if (path.includes(menu) || path.includes(trigger)) return;
|
||||
this.#saveCreateFormState(menu);
|
||||
menu.classList.remove("open");
|
||||
});
|
||||
};
|
||||
document.addEventListener("pointerdown", this.#onDocPointerDown);
|
||||
|
||||
// Prevent clicks inside menu from closing it
|
||||
menu.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||||
|
|
|
|||
|
|
@ -54,6 +54,10 @@ export class RSpaceOfflineRuntime {
|
|||
#moduleScopes = new Map<string, 'global' | 'space'>();
|
||||
/** Lazy WebSocket connections per space slug (for cross-space subscriptions). */
|
||||
#spaceConnections = new Map<string, DocSyncManager>();
|
||||
/** Max idle space connections to keep alive (LRU eviction beyond this). */
|
||||
static readonly MAX_SPACE_CONNECTIONS = 3;
|
||||
/** Access order for LRU eviction of space connections. */
|
||||
#spaceAccessOrder: string[] = [];
|
||||
|
||||
constructor(space: string) {
|
||||
this.#activeSpace = space;
|
||||
|
|
@ -322,10 +326,14 @@ export class RSpaceOfflineRuntime {
|
|||
|
||||
this.#activeSpace = newSpace;
|
||||
|
||||
// Update LRU access order
|
||||
this.#touchSpaceAccess(newSpace);
|
||||
|
||||
// Re-use existing connection if we've visited this space before
|
||||
const existing = this.#spaceConnections.get(newSpace);
|
||||
if (existing) {
|
||||
this.#sync = existing;
|
||||
this.#evictStaleSpaces();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -337,6 +345,9 @@ export class RSpaceOfflineRuntime {
|
|||
this.#sync = newSync;
|
||||
this.#spaceConnections.set(newSpace, newSync);
|
||||
|
||||
// Evict old connections beyond the cap
|
||||
this.#evictStaleSpaces();
|
||||
|
||||
// Connect lazily — will connect when first doc is subscribed
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${proto}//${location.host}/ws/${newSpace}`;
|
||||
|
|
@ -443,6 +454,32 @@ export class RSpaceOfflineRuntime {
|
|||
|
||||
// ── Private ──
|
||||
|
||||
/** Move space to end of LRU access order. */
|
||||
#touchSpaceAccess(space: string): void {
|
||||
this.#spaceAccessOrder = this.#spaceAccessOrder.filter(s => s !== space);
|
||||
this.#spaceAccessOrder.push(space);
|
||||
}
|
||||
|
||||
/** Evict oldest idle space connections beyond the cap. */
|
||||
#evictStaleSpaces(): void {
|
||||
while (this.#spaceConnections.size > RSpaceOfflineRuntime.MAX_SPACE_CONNECTIONS) {
|
||||
// Find the oldest space that isn't the active one
|
||||
const oldest = this.#spaceAccessOrder.find(
|
||||
s => s !== this.#activeSpace && this.#spaceConnections.has(s)
|
||||
);
|
||||
if (!oldest) break;
|
||||
|
||||
const sync = this.#spaceConnections.get(oldest);
|
||||
if (sync) {
|
||||
sync.flush().catch(() => {});
|
||||
sync.disconnect();
|
||||
}
|
||||
this.#spaceConnections.delete(oldest);
|
||||
this.#spaceAccessOrder = this.#spaceAccessOrder.filter(s => s !== oldest);
|
||||
console.debug(`[OfflineRuntime] Evicted idle space connection: ${oldest}`);
|
||||
}
|
||||
}
|
||||
|
||||
async #runStorageHousekeeping(): Promise<void> {
|
||||
try {
|
||||
// Request persistent storage (browser may grant silently)
|
||||
|
|
|
|||
|
|
@ -294,6 +294,17 @@ export class DocSyncManager {
|
|||
for (const id of docIds) {
|
||||
if (this.#subscribedDocs.delete(id)) {
|
||||
removed.push(id);
|
||||
|
||||
// Clean up per-doc state to free memory
|
||||
this.#syncStates.delete(id);
|
||||
this.#changeListeners.delete(id);
|
||||
this.#awarenessListeners.delete(id);
|
||||
|
||||
const timer = this.#saveTimers.get(id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.#saveTimers.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,22 +2,33 @@ import { resolve } from "node:path";
|
|||
import { defineConfig } from "vite";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
|
||||
// Path to Automerge shim that re-exports from window.__automerge (loaded by shell-offline)
|
||||
const AUTOMERGE_SHIM = resolve(__dirname, 'shared/automerge-shim.ts');
|
||||
|
||||
// WASM-aware build wrapper — ensures every sub-build can handle Automerge WASM imports
|
||||
// By default, aliases @automerge/automerge to a global shim (loaded by shell-offline)
|
||||
// to avoid re-bundling ~2.5MB WASM per module. Set _bundleAutomerge = true to bundle
|
||||
// the real Automerge (only needed for shell-offline and service worker builds).
|
||||
async function wasmBuild(config: any) {
|
||||
const { build } = await import("vite");
|
||||
const bundleAutomerge = config._bundleAutomerge ?? false;
|
||||
const { _bundleAutomerge, ...restConfig } = config;
|
||||
|
||||
return build({
|
||||
...config,
|
||||
plugins: [...(config.plugins || []), wasm()],
|
||||
...restConfig,
|
||||
plugins: [...(restConfig.plugins || []), wasm()],
|
||||
resolve: {
|
||||
...config.resolve,
|
||||
...restConfig.resolve,
|
||||
alias: {
|
||||
...(config.resolve?.alias || {}),
|
||||
'@automerge/automerge': resolve(__dirname, 'node_modules/@automerge/automerge'),
|
||||
...(restConfig.resolve?.alias || {}),
|
||||
'@automerge/automerge': bundleAutomerge
|
||||
? resolve(__dirname, 'node_modules/@automerge/automerge')
|
||||
: AUTOMERGE_SHIM,
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: "esnext",
|
||||
...config.build,
|
||||
...restConfig.build,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -55,6 +66,7 @@ export default defineConfig({
|
|||
|
||||
// Build shell.ts as a standalone JS bundle (needs wasm() for Automerge via runtime)
|
||||
await wasmBuild({
|
||||
_bundleAutomerge: true,
|
||||
configFile: false,
|
||||
root: resolve(__dirname, "website"),
|
||||
plugins: [wasm()],
|
||||
|
|
@ -62,7 +74,6 @@ export default defineConfig({
|
|||
alias: {
|
||||
"@lib": resolve(__dirname, "./lib"),
|
||||
"@shared": resolve(__dirname, "./shared"),
|
||||
'@automerge/automerge': resolve(__dirname, 'node_modules/@automerge/automerge'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
|
@ -358,18 +369,11 @@ export default defineConfig({
|
|||
resolve(__dirname, "dist/modules/rchoices/choices.css"),
|
||||
);
|
||||
|
||||
// Build crowdsurf module component (with Automerge WASM for local-first client)
|
||||
// Build crowdsurf module component (uses Automerge shim from shell-offline)
|
||||
await wasmBuild({
|
||||
configFile: false,
|
||||
root: resolve(__dirname, "modules/crowdsurf/components"),
|
||||
plugins: [wasm()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@automerge/automerge': resolve(__dirname, 'node_modules/@automerge/automerge'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: "esnext",
|
||||
emptyOutDir: false,
|
||||
outDir: resolve(__dirname, "dist/modules/crowdsurf"),
|
||||
lib: {
|
||||
|
|
@ -446,15 +450,10 @@ export default defineConfig({
|
|||
await wasmBuild({
|
||||
configFile: false,
|
||||
root: resolve(__dirname, "modules/rflows/components"),
|
||||
plugins: [wasm()],
|
||||
resolve: {
|
||||
alias: {
|
||||
...flowsAlias,
|
||||
'@automerge/automerge': resolve(__dirname, 'node_modules/@automerge/automerge'),
|
||||
},
|
||||
alias: flowsAlias,
|
||||
},
|
||||
build: {
|
||||
target: "esnext",
|
||||
emptyOutDir: false,
|
||||
outDir: resolve(__dirname, "dist/modules/rflows"),
|
||||
lib: {
|
||||
|
|
@ -635,18 +634,11 @@ export default defineConfig({
|
|||
resolve(__dirname, "dist/modules/rvote/vote.css"),
|
||||
);
|
||||
|
||||
// Build notes module component (with Automerge WASM support)
|
||||
// Build notes module component (uses Automerge shim from shell-offline)
|
||||
await wasmBuild({
|
||||
configFile: false,
|
||||
root: resolve(__dirname, "modules/rnotes/components"),
|
||||
plugins: [wasm()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@automerge/automerge': resolve(__dirname, 'node_modules/@automerge/automerge'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: "esnext",
|
||||
emptyOutDir: false,
|
||||
outDir: resolve(__dirname, "dist/modules/rnotes"),
|
||||
lib: {
|
||||
|
|
@ -950,18 +942,11 @@ export default defineConfig({
|
|||
resolve(__dirname, "dist/modules/rsocials/socials.css"),
|
||||
);
|
||||
|
||||
// Build campaign planner component (with Automerge WASM support via local-first-client)
|
||||
// Build campaign planner component (uses Automerge shim from shell-offline)
|
||||
await wasmBuild({
|
||||
configFile: false,
|
||||
root: resolve(__dirname, "modules/rsocials/components"),
|
||||
plugins: [wasm()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@automerge/automerge': resolve(__dirname, 'node_modules/@automerge/automerge'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: "esnext",
|
||||
emptyOutDir: false,
|
||||
outDir: resolve(__dirname, "dist/modules/rsocials"),
|
||||
lib: {
|
||||
|
|
|
|||
|
|
@ -5,11 +5,15 @@
|
|||
* shell bundle stays small and doesn't block first paint.
|
||||
*/
|
||||
|
||||
import * as Automerge from '@automerge/automerge';
|
||||
import { RStackHistoryPanel } from "../shared/components/rstack-history-panel";
|
||||
import { RSpaceOfflineRuntime } from "../shared/local-first/runtime";
|
||||
import { CommunitySync } from "../lib/community-sync";
|
||||
import { OfflineStore } from "../lib/offline-store";
|
||||
|
||||
// Expose Automerge globally so module builds can externalize it (~2.5MB savings per module)
|
||||
(window as any).__automerge = Automerge;
|
||||
|
||||
// Define the history panel component (depends on Automerge)
|
||||
RStackHistoryPanel.define();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue