import * as Automerge from "@automerge/automerge"; import type { FolkShape } from "./folk-shape"; import type { OfflineStore } from "./offline-store"; import type { Layer, LayerFlow, FlowKind } from "./layer-types"; import type { SpaceConnection, ConnectionState, MembranePermeability, } from "./connection-types"; import { computeMembranePermeability } from "./connection-types"; import { makeChangeMessage, parseChangeMessage } from "../shared/local-first/change-message"; import type { HistoryEntry } from "../shared/components/rstack-history-panel"; import type { EventEntry } from "./event-bus"; // Shape data stored in Automerge document export interface ShapeData { type: string; id: string; x: number; y: number; width: number; height: number; rotation: number; content?: string; // Arrow-specific sourceId?: string; targetId?: string; color?: string; strokeWidth?: number; // Wrapper-specific title?: string; icon?: string; primaryColor?: string; isMinimized?: boolean; isPinned?: boolean; tags?: string[]; // Whiteboard SVG drawing svgMarkup?: string; // Data pipe fields (arrow port connections) sourcePort?: string; targetPort?: string; // Shape port values (for data piping) ports?: Record; // Event bus subscriptions (channel names this shape listens to) subscriptions?: string[]; // Group membership groupId?: string; // Allow arbitrary shape-specific properties from toJSON() [key: string]: unknown; } // ── Nested space types (client-side) ── export interface NestPermissions { read: boolean; write: boolean; addShapes: boolean; deleteShapes: boolean; reshare: boolean; expiry?: number; } export interface SpaceRefFilter { shapeTypes?: string[]; shapeIds?: string[]; tags?: string[]; moduleIds?: string[]; } export interface SpaceRef { id: string; sourceSlug: string; sourceDID?: string; filter?: SpaceRefFilter; x: number; y: number; width: number; height: number; rotation: number; permissions: NestPermissions; collapsed?: boolean; label?: string; createdAt: number; createdBy: string; } // Automerge document structure export interface CommunityDoc { meta: { name: string; slug: string; createdAt: string; enabledModules?: string[]; description?: string; avatar?: string; }; shapes: { [id: string]: ShapeData; }; nestedSpaces?: { [refId: string]: SpaceRef; }; /** Tab/layer system — each layer is an rApp page in this space */ layers?: { [id: string]: Layer; }; /** Inter-layer flows (economic, trust, data, etc.) */ flows?: { [id: string]: LayerFlow; }; /** Bilateral typed connections to other spaces */ connections?: { [connId: string]: SpaceConnection; }; /** Named shape groups (semantic clusters) */ groups?: { [groupId: string]: { id: string; name: string; color: string; icon: string; memberIds: string[]; collapsed: boolean; isTemplate: boolean; templateName?: string; createdAt: number; updatedAt: number; }; }; /** Currently active layer ID */ activeLayerId?: string; /** Layer view mode: flat (tabs) or stack (side view) */ layerViewMode?: "flat" | "stack"; /** Pub/sub event log — bounded ring buffer (last 100 entries) */ eventLog?: EventEntry[]; } type SyncState = Automerge.SyncState; /** * CommunitySync - Bridges FolkJS shapes with Automerge CRDT sync * * Handles: * - Local shape changes → Automerge document → WebSocket broadcast * - Remote Automerge sync messages → Local document → DOM updates */ export class CommunitySync extends EventTarget { #doc: Automerge.Doc; #syncState: SyncState; #ws: WebSocket | null = null; #disconnectedIntentionally = false; #communitySlug: string; #shapes: Map = new Map(); #pendingChanges: boolean = false; #reconnectAttempts = 0; #maxReconnectAttempts = 5; #reconnectDelay = 1000; #offlineStore: OfflineStore | null = null; #saveDebounceTimer: ReturnType | null = null; #syncedDebounceTimer: ReturnType | null = null; #wsUrl: string | null = null; constructor(communitySlug: string, offlineStore?: OfflineStore) { super(); this.#communitySlug = communitySlug; // Initialize empty Automerge document this.#doc = Automerge.init(); this.#doc = Automerge.change(this.#doc, "Initialize community", (doc) => { doc.meta = { name: communitySlug, slug: communitySlug, createdAt: new Date().toISOString(), }; doc.shapes = {}; }); this.#syncState = Automerge.initSyncState(); if (offlineStore) { this.#offlineStore = offlineStore; } } /** * Load document and sync state from offline cache. * Call BEFORE connect() to show cached content immediately. * @returns true if cached data was loaded. */ async initFromCache(): Promise { if (!this.#offlineStore) return false; try { const docBinary = await this.#offlineStore.loadDoc(this.#communitySlug); if (!docBinary) return false; this.#doc = Automerge.load(docBinary); // Try to restore sync state for incremental reconnection const syncStateBinary = await this.#offlineStore.loadSyncState(this.#communitySlug); if (syncStateBinary) { this.#syncState = Automerge.decodeSyncState(syncStateBinary); } // Apply cached doc to DOM this.#applyDocToDOM(); this.dispatchEvent(new CustomEvent("offline-loaded", { detail: { slug: this.#communitySlug } })); return true; } catch (e) { console.error("[CommunitySync] Failed to load from cache:", e); return false; } } get doc(): Automerge.Doc { return this.#doc; } get shapes(): Map { return this.#shapes; } /** * Apply a pre-built Automerge doc change (used by GroupManager and other * subsystems that need batch mutations beyond the shape-level API). */ _applyDocChange(newDoc: Automerge.Doc): void { this.#doc = newDoc; this.#scheduleSave(); this.#syncToServer(); this.#applyDocToDOM(); } /** * Connect to WebSocket server for real-time sync */ connect(wsUrl: string): void { this.#wsUrl = wsUrl; if (this.#ws?.readyState === WebSocket.OPEN) { return; } // Attach auth token for private/authenticated spaces let authUrl = wsUrl; try { const sess = JSON.parse(localStorage.getItem('encryptid_session') || ''); if (sess?.accessToken) { const sep = wsUrl.includes('?') ? '&' : '?'; authUrl = `${wsUrl}${sep}token=${encodeURIComponent(sess.accessToken)}`; } } catch {} this.#ws = new WebSocket(authUrl); this.#ws.binaryType = "arraybuffer"; this.#ws.onopen = () => { console.log(`[CommunitySync] Connected to ${this.#communitySlug}`); this.#reconnectAttempts = 0; // Request initial sync this.#requestSync(); this.dispatchEvent(new CustomEvent("connected")); // Re-announce presence on every (re)connect if (this.#announceData) { this.#send({ type: "announce", ...this.#announceData }); } if (this.#offlineStore) { this.#offlineStore.markSynced(this.#communitySlug); } }; this.#ws.onmessage = (event) => { this.#handleMessage(event.data); }; this.#ws.onclose = () => { console.log(`[CommunitySync] Disconnected from ${this.#communitySlug}`); this.dispatchEvent(new CustomEvent("disconnected")); if (!this.#disconnectedIntentionally) { this.#attemptReconnect(wsUrl); } }; this.#ws.onerror = (error) => { console.error("[CommunitySync] WebSocket error:", error); this.dispatchEvent(new CustomEvent("error", { detail: error })); }; } #attemptReconnect(wsUrl: string): void { // When offline store is available, keep retrying forever (user has local persistence) // Without offline store, give up after maxReconnectAttempts if (!this.#offlineStore && this.#reconnectAttempts >= this.#maxReconnectAttempts) { console.error("[CommunitySync] Max reconnect attempts reached"); return; } this.#reconnectAttempts++; const maxDelay = this.#offlineStore ? 30000 : 16000; const delay = Math.min( this.#reconnectDelay * Math.pow(2, this.#reconnectAttempts - 1), maxDelay ); console.log(`[CommunitySync] Reconnecting in ${delay}ms (attempt ${this.#reconnectAttempts})`); setTimeout(() => { this.connect(wsUrl); }, delay); } /** * Request sync from server (sends our sync state) */ #requestSync(): void { const [nextSyncState, syncMessage] = Automerge.generateSyncMessage( this.#doc, this.#syncState ); this.#syncState = nextSyncState; this.#persistSyncState(); if (syncMessage) { this.#send({ type: "sync", data: Array.from(syncMessage), }); } } /** * Handle incoming WebSocket messages */ #handleMessage(data: ArrayBuffer | string): void { try { // Handle binary Automerge sync messages if (data instanceof ArrayBuffer) { const message = new Uint8Array(data); this.#applySyncMessage(message); return; } // Handle JSON messages const msg = JSON.parse(data as string); switch (msg.type) { case "sync": // Server sending sync message as JSON array if (Array.isArray(msg.data)) { const syncMessage = new Uint8Array(msg.data); this.#applySyncMessage(syncMessage); } break; case "full-sync": // Server sending full document — merge to preserve local changes if (msg.doc) { const binary = new Uint8Array(msg.doc); const serverDoc = Automerge.load(binary); this.#doc = Automerge.merge(this.#doc, serverDoc); this.#syncState = Automerge.initSyncState(); this.#applyDocToDOM(); this.#scheduleSave(); } break; case "presence": // Handle presence updates (cursors, selections) this.dispatchEvent(new CustomEvent("presence", { detail: msg })); break; case "peer-list": this.dispatchEvent(new CustomEvent("peer-list", { detail: msg })); break; case "peer-joined": this.dispatchEvent(new CustomEvent("peer-joined", { detail: msg })); break; case "peer-left": this.dispatchEvent(new CustomEvent("peer-left", { detail: msg })); break; case "ping-user": this.dispatchEvent(new CustomEvent("ping-user", { detail: msg })); break; case "clock": // Ephemeral clock event from server — dispatch for event bus delivery this.dispatchEvent(new CustomEvent("clock-event", { detail: { channel: msg.channel, payload: msg.payload } })); break; case "notification": // Dispatch to window so the notification bell component picks it up window.dispatchEvent(new CustomEvent("rspace-notification", { detail: msg })); break; } } catch (e) { console.error("[CommunitySync] Failed to handle message:", e); } } /** * Apply incoming Automerge sync message */ #applySyncMessage(message: Uint8Array): void { const oldDoc = this.#doc; const result = Automerge.receiveSyncMessage( this.#doc, this.#syncState, message ); this.#doc = result[0]; this.#syncState = result[1]; // Persist after receiving remote changes this.#scheduleSave(); this.#persistSyncState(); // Apply changes to DOM if the document changed if (this.#doc !== oldDoc) { const patch = result[2] as { patches: Automerge.Patch[] } | null; if (patch && patch.patches && patch.patches.length > 0) { this.#applyPatchesToDOM(patch.patches); } else { // Automerge 2.x receiveSyncMessage may not return patches; // fall back to full document-to-DOM reconciliation this.#applyDocToDOM(); } } // Generate response if needed const [nextSyncState, responseMessage] = Automerge.generateSyncMessage( this.#doc, this.#syncState ); this.#syncState = nextSyncState; this.#persistSyncState(); if (responseMessage) { this.#send({ type: "sync", data: Array.from(responseMessage), }); } } /** * Send message over WebSocket */ #send(message: object): void { if (this.#ws?.readyState === WebSocket.OPEN) { this.#ws.send(JSON.stringify(message)); } } /** * Send presence update (cursor position, selection) */ sendPresence(data: { cursor?: { x: number; y: number }; selection?: string; username?: string; color?: string }): void { this.#send({ type: "presence", ...data, }); } // ── People Online (announce / ping) ── #announceData: { peerId: string; username: string; color: string } | null = null; /** * Store announce payload and send immediately if already connected. */ setAnnounceData(data: { peerId: string; username: string; color: string }): void { this.#announceData = data; if (this.#ws?.readyState === WebSocket.OPEN) { this.#send({ type: "announce", ...data }); } } /** * Ask the server to relay a "come here" ping to a specific peer. */ sendPingUser(targetPeerId: string, viewport: { x: number; y: number }): void { this.#send({ type: "ping-user", targetPeerId, viewport }); } /** * Send a keep-alive ping to prevent WebSocket idle timeout */ ping(): void { this.#send({ type: "ping" }); } /** * Register a shape element for syncing */ registerShape(shape: FolkShape): void { this.#shapes.set(shape.id, shape); // Listen for transform events shape.addEventListener("folk-transform", ((e: CustomEvent) => { this.#handleShapeChange(shape); }) as EventListener); // Listen for content changes (for markdown shapes) shape.addEventListener("content-change", ((e: CustomEvent) => { this.#handleShapeChange(shape); }) as EventListener); // Add to document if not exists if (!this.#doc.shapes[shape.id]) { this.#updateShapeInDoc(shape); } } /** * Unregister a shape */ unregisterShape(shapeId: string): void { this.#shapes.delete(shapeId); } /** * Handle local shape change - update Automerge doc and sync */ #handleShapeChange(shape: FolkShape): void { this.#updateShapeInDoc(shape); this.#syncToServer(); // Broadcast to parent frame (for rtrips.online integration) const shapeData = this.#shapeToData(shape); this.#postMessageToParent("shape-updated", shapeData); } /** * Update shape data in Automerge document */ #updateShapeInDoc(shape: FolkShape): void { const shapeData = this.#shapeToData(shape); this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Update shape ${shape.id}`), (doc) => { if (!doc.shapes) doc.shapes = {}; doc.shapes[shape.id] = JSON.parse(JSON.stringify(shapeData)); }); this.#scheduleSave(); } /** * Convert FolkShape to serializable data using the shape's own toJSON() method. * This ensures all shape-specific properties are captured for every shape type. */ #shapeToData(shape: FolkShape): ShapeData { const json = (shape as any).toJSON?.() ?? {}; const data: ShapeData = { type: (json.type as string) || shape.tagName.toLowerCase(), id: (json.id as string) || shape.id, x: (json.x as number) ?? shape.x, y: (json.y as number) ?? shape.y, width: (json.width as number) ?? shape.width, height: (json.height as number) ?? shape.height, rotation: (json.rotation as number) ?? shape.rotation, }; // Merge all extra properties from toJSON for (const [key, value] of Object.entries(json)) { if (!(key in data)) { data[key] = typeof value === 'object' && value !== null ? JSON.parse(JSON.stringify(value)) : value; } } return data; } /** * Sync local changes to server */ #syncToServer(): void { const [nextSyncState, syncMessage] = Automerge.generateSyncMessage( this.#doc, this.#syncState ); this.#syncState = nextSyncState; this.#persistSyncState(); if (syncMessage) { this.#send({ type: "sync", data: Array.from(syncMessage), }); } } /** * Delete a shape — now aliases to forgetShape for backward compat. * For true hard-delete, use hardDeleteShape(). */ deleteShape(shapeId: string, did?: string): void { this.forgetShape(shapeId, did || 'unknown'); } /** * Add raw shape data directly (for shapes without DOM elements, like wb-svg drawings). */ addShapeData(shapeData: ShapeData): void { this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Add shape ${shapeData.id}`), (doc) => { if (!doc.shapes) doc.shapes = {}; doc.shapes[shapeData.id] = shapeData; }); this.#scheduleSave(); this.#syncToServer(); } /** * FUN: Update — explicitly update specific fields of a shape. * Use this for programmatic updates (API calls, module callbacks). * Shape transform/content changes are auto-captured via registerShape(). */ updateShape(shapeId: string, fields: Record): void { const existing = this.#doc.shapes?.[shapeId]; if (!existing) return; this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Update shape ${shapeId}`), (doc) => { if (doc.shapes && doc.shapes[shapeId]) { for (const [key, value] of Object.entries(fields)) { (doc.shapes[shapeId] as Record)[key] = value; } } }); // Sync the updated shape to DOM and server const shape = this.#shapes.get(shapeId); if (shape) { this.#updateShapeElement(shape, this.#doc.shapes[shapeId]); } this.#scheduleSave(); this.#syncToServer(); } /** * Forget a shape — add DID to forgottenBy map. Shape fades but stays in DOM. * Three-state: present → forgotten (faded) → deleted */ forgetShape(shapeId: string, did: string): void { this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Forget shape ${shapeId}`), (doc) => { if (doc.shapes && doc.shapes[shapeId]) { const shape = doc.shapes[shapeId] as Record; if (!shape.forgottenBy || typeof shape.forgottenBy !== 'object') { shape.forgottenBy = {}; } (shape.forgottenBy as Record)[did] = Date.now(); // Legacy compat shape.forgotten = true; shape.forgottenAt = Date.now(); } }); // Don't remove from DOM — just update visual state this.dispatchEvent(new CustomEvent("shape-state-changed", { detail: { shapeId, state: 'forgotten', data: this.#doc.shapes?.[shapeId] } })); this.#scheduleSave(); this.#syncToServer(); } /** * Remember a forgotten shape — clear forgottenBy + deleted, restore to present. */ rememberShape(shapeId: string): void { const shapeData = this.#doc.shapes?.[shapeId]; if (!shapeData) return; const wasDeleted = !!(shapeData as Record).deleted; this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Remember shape ${shapeId}`), (doc) => { if (doc.shapes && doc.shapes[shapeId]) { const shape = doc.shapes[shapeId] as Record; shape.forgottenBy = {}; shape.deleted = false; // Legacy compat shape.forgotten = false; shape.forgottenAt = 0; } }); if (wasDeleted) { // Re-add to DOM if was hard-deleted this.#applyShapeToDOM(this.#doc.shapes[shapeId]); } this.dispatchEvent(new CustomEvent("shape-state-changed", { detail: { shapeId, state: 'present', data: this.#doc.shapes?.[shapeId] } })); this.#scheduleSave(); this.#syncToServer(); } /** * Hard-delete a shape — set deleted: true, remove from DOM. * Shape stays in Automerge doc for restore from memory panel. */ hardDeleteShape(shapeId: string): void { this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Delete shape ${shapeId}`), (doc) => { if (doc.shapes && doc.shapes[shapeId]) { (doc.shapes[shapeId] as Record).deleted = true; } }); this.#removeShapeFromDOM(shapeId); this.dispatchEvent(new CustomEvent("shape-state-changed", { detail: { shapeId, state: 'deleted', data: this.#doc.shapes?.[shapeId] } })); this.#scheduleSave(); this.#syncToServer(); } /** * Get the visual state of a shape: 'present' | 'forgotten' | 'deleted' */ getShapeVisualState(shapeId: string): 'present' | 'forgotten' | 'deleted' { const data = this.#doc.shapes?.[shapeId] as Record | undefined; if (!data) return 'deleted'; if (data.deleted === true) return 'deleted'; const fb = data.forgottenBy; if (fb && typeof fb === 'object' && Object.keys(fb).length > 0) return 'forgotten'; return 'present'; } /** * Check if a specific user has forgotten a shape */ hasUserForgotten(shapeId: string, did: string): boolean { const data = this.#doc.shapes?.[shapeId] as Record | undefined; if (!data) return false; const fb = data.forgottenBy as Record | undefined; return !!(fb && fb[did]); } /** * Get all forgotten (faded but not deleted) shapes */ getFadedShapes(): ShapeData[] { const shapes = this.#doc.shapes || {}; return Object.values(shapes).filter(s => { const d = s as Record; if (d.deleted === true) return false; const fb = d.forgottenBy; return fb && typeof fb === 'object' && Object.keys(fb).length > 0; }); } /** * Get all hard-deleted shapes (for memory panel "Deleted" section) */ getDeletedShapes(): ShapeData[] { const shapes = this.#doc.shapes || {}; return Object.values(shapes).filter(s => (s as Record).deleted === true); } /** * Get all forgotten shapes — includes both faded and deleted (for backward compat). */ getForgottenShapes(): ShapeData[] { const shapes = this.#doc.shapes || {}; return Object.values(shapes).filter(s => { const d = s as Record; if (d.deleted === true) return true; const fb = d.forgottenBy; if (fb && typeof fb === 'object' && Object.keys(fb).length > 0) return true; return !!d.forgotten; }); } /** * Apply full document to DOM (for initial load). * Three-state: deleted shapes are skipped, forgotten shapes are rendered faded. */ #applyDocToDOM(): void { const shapes = this.#doc.shapes || {}; for (const [id, shapeData] of Object.entries(shapes)) { const d = shapeData as Record; if (d.deleted === true) continue; // Deleted: not in DOM this.#applyShapeToDOM(shapeData); // If forgotten (faded), emit state-changed so canvas can apply visual const fb = d.forgottenBy; if (fb && typeof fb === 'object' && Object.keys(fb).length > 0) { this.dispatchEvent(new CustomEvent("shape-state-changed", { detail: { shapeId: id, state: 'forgotten', data: shapeData } })); } } // Notify event bus if there are any events to process if (this.#doc.eventLog && this.#doc.eventLog.length > 0) { this.dispatchEvent(new CustomEvent("eventlog-changed")); } // Debounce the synced event — during initial sync negotiation, #applyDocToDOM() // is called for every Automerge sync message (100+ round-trips). Debounce to // fire once after the burst settles. if (this.#syncedDebounceTimer) clearTimeout(this.#syncedDebounceTimer); this.#syncedDebounceTimer = setTimeout(() => { this.#syncedDebounceTimer = null; this.dispatchEvent(new CustomEvent("synced", { detail: { shapes } })); }, 300); } /** * Apply Automerge patches to DOM. * Three-state: forgotten shapes stay in DOM (faded), deleted shapes are removed. */ #applyPatchesToDOM(patches: Automerge.Patch[]): void { let eventLogChanged = false; for (const patch of patches) { const path = patch.path; // Detect eventLog changes for the event bus if (path[0] === "eventLog") { eventLogChanged = true; continue; } // Handle shape updates: ["shapes", shapeId, ...] if (path[0] === "shapes" && typeof path[1] === "string") { const shapeId = path[1]; const shapeData = this.#doc.shapes?.[shapeId]; if (patch.action === "del" && path.length === 2) { // Shape truly removed from Automerge doc this.#removeShapeFromDOM(shapeId); } else if (shapeData) { const d = shapeData as Record; const state = this.getShapeVisualState(shapeId); if (state === 'deleted') { // Hard-deleted: remove from DOM this.#removeShapeFromDOM(shapeId); this.dispatchEvent(new CustomEvent("shape-state-changed", { detail: { shapeId, state: 'deleted', data: shapeData } })); } else if (state === 'forgotten') { // Forgotten: keep in DOM, emit state change for fade visual this.#applyShapeToDOM(shapeData); this.dispatchEvent(new CustomEvent("shape-state-changed", { detail: { shapeId, state: 'forgotten', data: shapeData } })); this.dispatchEvent(new CustomEvent("shape-forgotten", { detail: { shapeId, data: shapeData } })); } else { // Present: render normally this.#applyShapeToDOM(shapeData); this.dispatchEvent(new CustomEvent("shape-state-changed", { detail: { shapeId, state: 'present', data: shapeData } })); this.#postMessageToParent("shape-updated", shapeData); } } } } // Notify event bus of new remote events if (eventLogChanged) { this.dispatchEvent(new CustomEvent("eventlog-changed")); } } /** * Apply shape data to DOM element */ #applyShapeToDOM(shapeData: ShapeData): void { let shape = this.#shapes.get(shapeData.id); if (!shape) { // FUN: New — instantiate shape element shape = this.#newShapeElement(shapeData); if (shape) { this.#shapes.set(shapeData.id, shape); this.dispatchEvent(new CustomEvent("shape-new", { detail: { shape, data: shapeData } })); } return; } // Update existing shape (avoid triggering our own change events) this.#updateShapeElement(shape, shapeData); } /** * FUN: New — emit event for the canvas to instantiate a new shape from data */ #newShapeElement(data: ShapeData): FolkShape | undefined { this.dispatchEvent(new CustomEvent("new-shape", { detail: data })); return undefined; } /** * Update shape element without triggering change events. * Delegates to each shape's applyData() method (co-located with toJSON/fromData). */ #updateShapeElement(shape: FolkShape, data: ShapeData): void { if (typeof (shape as any).applyData === "function") { (shape as any).applyData(data); } else { // Fallback for shapes without applyData (e.g. wb-svg) if (shape.x !== data.x) shape.x = data.x; if (shape.y !== data.y) shape.y = data.y; if (shape.width !== data.width) shape.width = data.width; if (shape.height !== data.height) shape.height = data.height; if (shape.rotation !== data.rotation) shape.rotation = data.rotation; } } /** * FUN: Forget — remove shape from visible DOM (shape remains in Automerge doc) */ #removeShapeFromDOM(shapeId: string): void { const shape = this.#shapes.get(shapeId); if (shape) { this.#shapes.delete(shapeId); this.dispatchEvent(new CustomEvent("shape-removed", { detail: { shapeId, shape } })); } } /** * Save current state immediately. Call from beforeunload handler. * Uses synchronous localStorage fallback since IndexedDB may not complete. */ saveBeforeUnload(): void { if (this.#saveDebounceTimer) { clearTimeout(this.#saveDebounceTimer); this.#saveDebounceTimer = null; } try { const binary = Automerge.save(this.#doc); // Synchronous localStorage fallback (guaranteed to complete) if (this.#offlineStore) { this.#offlineStore.saveDocEmergency(this.#communitySlug, binary); } // Also fire off IndexedDB save (may not complete before page dies) if (this.#offlineStore) { this.#offlineStore.saveDocImmediate(this.#communitySlug, binary); } // Push any pending Automerge changes to the server this.#syncToServer(); } catch (e) { console.warn("[CommunitySync] Failed to save before unload:", e); } } #scheduleSave(): void { if (!this.#offlineStore) return; if (this.#saveDebounceTimer) { clearTimeout(this.#saveDebounceTimer); } this.#saveDebounceTimer = setTimeout(() => { this.#saveDebounceTimer = null; this.#offlineStore!.saveDoc( this.#communitySlug, Automerge.save(this.#doc) ); }, 2000); } #persistSyncState(): void { if (!this.#offlineStore) return; try { const encoded = Automerge.encodeSyncState(this.#syncState); this.#offlineStore.saveSyncState(this.#communitySlug, encoded); } catch (e) { console.warn("[CommunitySync] Failed to save sync state:", e); } } // ── Layer & Flow API ── /** Add a layer to the document */ addLayer(layer: Layer): void { this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Add layer ${layer.id}`), (doc) => { if (!doc.layers) doc.layers = {}; doc.layers[layer.id] = layer; }); this.#scheduleSave(); this.#syncToServer(); this.dispatchEvent(new CustomEvent("layer-added", { detail: layer })); } /** Remove a layer */ removeLayer(layerId: string): void { this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Remove layer ${layerId}`), (doc) => { if (doc.layers && doc.layers[layerId]) { delete doc.layers[layerId]; } // Remove flows connected to this layer if (doc.flows) { for (const [fid, flow] of Object.entries(doc.flows)) { if (flow.sourceLayerId === layerId || flow.targetLayerId === layerId) { delete doc.flows[fid]; } } } // If active layer was removed, switch to first remaining if (doc.activeLayerId === layerId) { const remaining = doc.layers ? Object.keys(doc.layers) : []; doc.activeLayerId = remaining[0] || ""; } }); this.#scheduleSave(); this.#syncToServer(); this.dispatchEvent(new CustomEvent("layer-removed", { detail: { layerId } })); } /** Update a layer's properties */ updateLayer(layerId: string, updates: Partial): void { this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Update layer ${layerId}`), (doc) => { if (doc.layers && doc.layers[layerId]) { for (const [key, value] of Object.entries(updates)) { (doc.layers[layerId] as unknown as Record)[key] = value; } } }); this.#scheduleSave(); this.#syncToServer(); } /** Set active layer */ setActiveLayer(layerId: string): void { this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Switch to layer ${layerId}`), (doc) => { doc.activeLayerId = layerId; }); this.#scheduleSave(); this.#syncToServer(); this.dispatchEvent(new CustomEvent("active-layer-changed", { detail: { layerId } })); } /** Set layer view mode */ setLayerViewMode(mode: "flat" | "stack"): void { this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Set view mode ${mode}`), (doc) => { doc.layerViewMode = mode; }); this.#scheduleSave(); this.#syncToServer(); } /** Add a flow between layers */ addFlow(flow: LayerFlow): void { this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Add flow ${flow.id}`), (doc) => { if (!doc.flows) doc.flows = {}; doc.flows[flow.id] = flow; }); this.#scheduleSave(); this.#syncToServer(); this.dispatchEvent(new CustomEvent("flow-added", { detail: flow })); } /** Remove a flow */ removeFlow(flowId: string): void { this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Remove flow ${flowId}`), (doc) => { if (doc.flows && doc.flows[flowId]) { delete doc.flows[flowId]; } }); this.#scheduleSave(); this.#syncToServer(); } /** Update flow properties */ updateFlow(flowId: string, updates: Partial): void { this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Update flow ${flowId}`), (doc) => { if (doc.flows && doc.flows[flowId]) { for (const [key, value] of Object.entries(updates)) { (doc.flows[flowId] as unknown as Record)[key] = value; } } }); this.#scheduleSave(); this.#syncToServer(); } /** Get all layers (sorted by order) */ getLayers(): Layer[] { const layers = this.#doc.layers || {}; return Object.values(layers).sort((a, b) => a.order - b.order); } /** Get all flows */ getFlows(): LayerFlow[] { const flows = this.#doc.flows || {}; return Object.values(flows); } /** Get flows for a specific layer (as source or target) */ getFlowsForLayer(layerId: string): LayerFlow[] { return this.getFlows().filter( f => f.sourceLayerId === layerId || f.targetLayerId === layerId ); } // ── Connection API ── /** Add a connection to the document */ addConnection(conn: SpaceConnection): void { this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Add connection ${conn.id}`), (doc) => { if (!doc.connections) doc.connections = {}; doc.connections[conn.id] = conn; }); this.#scheduleSave(); this.#syncToServer(); this.dispatchEvent(new CustomEvent("connection-added", { detail: conn })); } /** Remove a connection */ removeConnection(connId: string): void { this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Remove connection ${connId}`), (doc) => { if (doc.connections && doc.connections[connId]) { delete doc.connections[connId]; } }); this.#scheduleSave(); this.#syncToServer(); this.dispatchEvent(new CustomEvent("connection-removed", { detail: { connId } })); } /** Update connection properties */ updateConnection(connId: string, updates: Partial): void { this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Update connection ${connId}`), (doc) => { if (doc.connections && doc.connections[connId]) { for (const [key, value] of Object.entries(updates)) { if (key === 'id') continue; (doc.connections[connId] as unknown as Record)[key] = value; } } }); this.#scheduleSave(); this.#syncToServer(); this.dispatchEvent(new CustomEvent("connection-updated", { detail: { connId, updates } })); } /** Get all connections */ getConnections(): SpaceConnection[] { const connections = this.#doc.connections || {}; return Object.values(connections); } /** Get connections filtered by state */ getConnectionsByState(state: ConnectionState): SpaceConnection[] { return this.getConnections().filter(c => c.state === state); } /** Get connections filtered by flow kind */ getConnectionsByFlowKind(kind: FlowKind): SpaceConnection[] { return this.getConnections().filter(c => c.flowKinds.includes(kind)); } /** Compute membrane permeability from all connections */ getMembranePermeability(): MembranePermeability { return computeMembranePermeability(this.getConnections(), this.#communitySlug); } // ── Event Bus API ── /** Max event log entries before oldest are pruned. */ static readonly MAX_EVENT_LOG = 100; /** Append an event entry to the CRDT eventLog (ring-buffered). */ appendEvent(entry: EventEntry): void { this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Event: ${entry.channel}`), (doc) => { if (!doc.eventLog) doc.eventLog = []; doc.eventLog.push(entry as any); // Ring buffer: prune oldest entries beyond limit while (doc.eventLog.length > CommunitySync.MAX_EVENT_LOG) { doc.eventLog.splice(0, 1); } }); this.#scheduleSave(); this.#syncToServer(); } /** Read the current event log. */ getEventLog(): EventEntry[] { return (this.#doc.eventLog as EventEntry[] | undefined) || []; } /** Set event subscriptions for a shape. */ setShapeSubscriptions(shapeId: string, channels: string[]): void { if (!this.#doc.shapes?.[shapeId]) return; this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Subscribe ${shapeId}`), (doc) => { if (doc.shapes[shapeId]) { (doc.shapes[shapeId] as Record).subscriptions = channels; } }); this.#scheduleSave(); this.#syncToServer(); } /** Get event subscriptions for a shape. */ getShapeSubscriptions(shapeId: string): string[] { const data = this.#doc.shapes?.[shapeId] as Record | undefined; return (data?.subscriptions as string[] | undefined) || []; } /** Get all shape IDs subscribed to a given channel. */ getShapesSubscribedTo(channel: string): string[] { const shapes = this.#doc.shapes || {}; const result: string[] = []; for (const [id, data] of Object.entries(shapes)) { const subs = (data as Record).subscriptions as string[] | undefined; if (subs && subs.includes(channel)) { result.push(id); } } return result; } /** Get a shape DOM element by ID (for event delivery). */ getShapeElement(shapeId: string): FolkShape | undefined { return this.#shapes.get(shapeId); } // ── History API ── /** * View document state at a specific point in history. */ viewAt(heads: string[]): CommunityDoc { return Automerge.view(this.#doc, heads); } /** * Get parsed history entries for the activity feed. */ getHistoryEntries(limit?: number): HistoryEntry[] { try { const changes = Automerge.getHistory(this.#doc); const entries: HistoryEntry[] = changes.map((entry, i) => { const change = entry.change; const parsed = parseChangeMessage(change.message || null); return { hash: change.hash || `change-${i}`, actor: change.actor || "unknown", username: parsed.user || "Unknown", time: parsed.ts || (change.time ? change.time * 1000 : 0), message: parsed.text, opsCount: change.ops?.length || 0, seq: change.seq || i, }; }); return limit ? entries.slice(-limit) : entries; } catch { return []; } } /** * Disconnect from server */ disconnect(): void { this.#disconnectedIntentionally = true; if (this.#ws) { this.#ws.close(); this.#ws = null; } } /** * Get document as binary for storage */ getDocumentBinary(): Uint8Array { return Automerge.save(this.#doc); } /** * Load document from binary */ loadDocumentBinary(binary: Uint8Array): void { this.#doc = Automerge.load(binary); this.#syncState = Automerge.initSyncState(); this.#applyDocToDOM(); } /** * Broadcast shape updates to parent frame (for iframe embedding in rtrips.online) */ #postMessageToParent(type: string, data: ShapeData): void { if (typeof window === "undefined" || window.parent === window) return; try { window.parent.postMessage( { source: "rspace-canvas", type, communitySlug: this.#communitySlug, shapeId: data.id, data, }, "*" ); } catch { // postMessage may fail in certain security contexts } } }