diff --git a/src/automerge/useAutomergeStoreV2.ts b/src/automerge/useAutomergeStoreV2.ts index d57b7b7..43cd7b2 100644 --- a/src/automerge/useAutomergeStoreV2.ts +++ b/src/automerge/useAutomergeStoreV2.ts @@ -136,10 +136,12 @@ export function useAutomergeStoreV2({ handle, userId: _userId, adapter, + isNetworkOnline = true, }: { handle: DocHandle userId: string adapter?: any + isNetworkOnline?: boolean }): TLStoreWithStatus { // useAutomergeStoreV2 initializing @@ -1074,58 +1076,122 @@ export function useAutomergeStoreV2({ try { await handle.whenReady() const doc = handle.doc() - + // Check if store is already populated from patches const existingStoreRecords = store.allRecords() const existingStoreShapes = existingStoreRecords.filter((r: any) => r.typeName === 'shape') - + + // Determine connection status based on network state + const connectionStatus = isNetworkOnline ? "online" : "offline" + if (doc.store) { const storeKeys = Object.keys(doc.store) const docShapes = Object.values(doc.store).filter((r: any) => r?.typeName === 'shape').length - console.log(`๐Ÿ“Š Patch-based initialization: doc has ${storeKeys.length} records (${docShapes} shapes), store has ${existingStoreRecords.length} records (${existingStoreShapes.length} shapes)`) - + console.log(`๐Ÿ“Š Patch-based initialization: doc has ${storeKeys.length} records (${docShapes} shapes), store has ${existingStoreRecords.length} records (${existingStoreShapes.length} shapes), network: ${connectionStatus}`) + // If store already has shapes, patches have been applied (dev mode behavior) if (existingStoreShapes.length > 0) { console.log(`โœ… Store already populated from patches (${existingStoreShapes.length} shapes) - using patch-based loading like dev`) - + // REMOVED: Aggressive shape refresh that was causing coordinate loss // Shapes should be visible through normal patch application // If shapes aren't visible, it's likely a different issue that refresh won't fix - + setStoreWithStatus({ store, status: "synced-remote", - connectionStatus: "online", + connectionStatus, }) return } - + + // OFFLINE FAST PATH: When offline with local data, load immediately + // Don't wait for patches that will never come from the network + if (!isNetworkOnline && docShapes > 0) { + console.log(`๐Ÿ“ด Offline mode with ${docShapes} shapes in local storage - loading immediately`) + + // Manually load data from Automerge doc since patches won't come through + try { + const allRecords: TLRecord[] = [] + Object.entries(doc.store).forEach(([id, record]: [string, any]) => { + if (!record || !record.typeName || !record.id) return + if (record.typeName === 'obsidian_vault' || (typeof record.id === 'string' && record.id.startsWith('obsidian_vault:'))) return + + try { + let cleanRecord: any + try { + cleanRecord = JSON.parse(JSON.stringify(record)) + } catch { + cleanRecord = safeExtractPlainObject(record) + } + + if (cleanRecord && typeof cleanRecord === 'object') { + const sanitized = sanitizeRecord(cleanRecord) + const plainSanitized = JSON.parse(JSON.stringify(sanitized)) + allRecords.push(plainSanitized) + } + } catch (e) { + console.warn(`โš ๏ธ Could not process record ${id}:`, e) + } + }) + + // Filter out SharedPiano shapes since they're no longer supported + const filteredRecords = allRecords.filter((record: any) => { + if (record.typeName === 'shape' && record.type === 'SharedPiano') { + return false + } + return true + }) + + if (filteredRecords.length > 0) { + console.log(`๐Ÿ“ด Loading ${filteredRecords.length} records from offline storage`) + store.mergeRemoteChanges(() => { + const pageRecords = filteredRecords.filter(r => r.typeName === 'page') + const shapeRecords = filteredRecords.filter(r => r.typeName === 'shape') + const otherRecords = filteredRecords.filter(r => r.typeName !== 'page' && r.typeName !== 'shape') + const recordsToAdd = [...pageRecords, ...otherRecords, ...shapeRecords] + store.put(recordsToAdd) + }) + console.log(`โœ… Offline data loaded: ${filteredRecords.filter(r => r.typeName === 'shape').length} shapes`) + } + } catch (error) { + console.error(`โŒ Error loading offline data:`, error) + } + + setStoreWithStatus({ + store, + status: "synced-remote", // Use synced-remote so Board renders + connectionStatus: "offline", + }) + return + } + // If doc has data but store doesn't, patches should have been generated when data was written // The automergeChangeHandler (set up above) should process them automatically // Just wait a bit for patches to be processed, then set status if (docShapes > 0 && existingStoreShapes.length === 0) { console.log(`๐Ÿ“Š Doc has ${docShapes} shapes but store is empty. Waiting for patches to be processed by handler...`) - + // Wait briefly for patches to be processed by automergeChangeHandler // The handler is already set up, so it should catch patches from the initial data load let attempts = 0 const maxAttempts = 10 // Wait up to 2 seconds (10 * 200ms) - + await new Promise(resolve => { const checkForPatches = () => { attempts++ const currentShapes = store.allRecords().filter((r: any) => r.typeName === 'shape') - + if (currentShapes.length > 0) { console.log(`โœ… Patches applied successfully: ${currentShapes.length} shapes loaded via patches`) - + // REMOVED: Aggressive shape refresh that was causing coordinate loss // Shapes loaded via patches should be visible without forced refresh - + setStoreWithStatus({ store, status: "synced-remote", - connectionStatus: "online", + connectionStatus, }) resolve() } else if (attempts < maxAttempts) { @@ -1136,35 +1202,35 @@ export function useAutomergeStoreV2({ console.warn(`โš ๏ธ No patches received after ${maxAttempts} attempts for room initialization.`) console.warn(`โš ๏ธ This may happen if Automerge doc was initialized with server data before handler was ready.`) console.warn(`โš ๏ธ Store will remain empty - patches should handle data loading in normal operation.`) - + // Simplified fallback: Just log and continue with empty store // Patches should handle data loading, so if they don't come through, // it's likely the document is actually empty or there's a timing issue // that will resolve on next sync - + setStoreWithStatus({ store, status: "synced-remote", - connectionStatus: "online", + connectionStatus, }) resolve() } } - + // Start checking immediately since handler is already set up setTimeout(checkForPatches, 100) }) - + return } - + // If doc is empty, just set status if (docShapes === 0) { console.log(`๐Ÿ“Š Empty document - starting fresh (patch-based loading)`) setStoreWithStatus({ store, status: "synced-remote", - connectionStatus: "online", + connectionStatus, }) return } @@ -1174,7 +1240,7 @@ export function useAutomergeStoreV2({ setStoreWithStatus({ store, status: "synced-remote", - connectionStatus: "online", + connectionStatus: isNetworkOnline ? "online" : "offline", }) return } @@ -1183,17 +1249,17 @@ export function useAutomergeStoreV2({ setStoreWithStatus({ store, status: "synced-remote", - connectionStatus: "online", + connectionStatus: isNetworkOnline ? "online" : "offline", }) } } initializeStore() - + return () => { unsubs.forEach((unsub) => unsub()) } - }, [handle, store]) + }, [handle, store, isNetworkOnline]) /* -------------------- Presence -------------------- */ // Create a safe handle that won't cause null errors diff --git a/src/automerge/useAutomergeSyncRepo.ts b/src/automerge/useAutomergeSyncRepo.ts index d4c331b..f200053 100644 --- a/src/automerge/useAutomergeSyncRepo.ts +++ b/src/automerge/useAutomergeSyncRepo.ts @@ -296,13 +296,40 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus console.error('โŒ Error applying presence:', error) } }, [flushPresenceUpdates]) - + + // Handle presence leave - remove the user's presence record from the store + const handlePresenceLeave = useCallback((sessionId: string) => { + const currentStore = storeRef.current + if (!currentStore) return + + try { + // Find and remove the presence record for this session + // Presence IDs are formatted as "instance_presence:{sessionId}" + const presenceId = `instance_presence:${sessionId}` + + // Check if this record exists before trying to remove it + const allRecords = currentStore.allRecords() + const presenceRecord = allRecords.find((r: any) => + r.id === presenceId || + r.id?.includes(sessionId) + ) + + if (presenceRecord) { + console.log('๐Ÿ‘‹ Removing presence record for session:', sessionId, presenceRecord.id) + currentStore.remove([presenceRecord.id]) + } + } catch (error) { + console.error('Error removing presence on leave:', error) + } + }, []) + const { repo, adapter, storageAdapter } = useMemo(() => { const adapter = new CloudflareNetworkAdapter( workerUrl, roomId, applyJsonSyncData, - applyPresenceUpdate + applyPresenceUpdate, + handlePresenceLeave ) // Store adapter ref for use in callbacks @@ -325,7 +352,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus }) return { repo, adapter, storageAdapter } - }, [workerUrl, roomId, applyJsonSyncData, applyPresenceUpdate]) + }, [workerUrl, roomId, applyJsonSyncData, applyPresenceUpdate, handlePresenceLeave]) // Subscribe to connection state changes useEffect(() => { @@ -653,20 +680,26 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus } // Get user metadata for presence + // Color is generated from the username (name) for consistency across sessions, + // not from the unique session ID (userId) which changes per tab/session const userMetadata: { userId: string; name: string; color: string } = (() => { if (user && 'userId' in user) { const uid = (user as { userId: string; name: string; color?: string }).userId + const name = (user as { userId: string; name: string; color?: string }).name return { userId: uid, - name: (user as { userId: string; name: string; color?: string }).name, - color: (user as { userId: string; name: string; color?: string }).color || generateUserColor(uid) + name: name, + // Use name for color (consistent across sessions), fall back to uid if no name + color: (user as { userId: string; name: string; color?: string }).color || generateUserColor(name || uid) } } const uid = user?.id || 'anonymous' + const name = user?.name || 'Anonymous' return { userId: uid, - name: user?.name || 'Anonymous', - color: generateUserColor(uid) + name: name, + // Use name for color (consistent across sessions), fall back to uid if no name + color: generateUserColor(name !== 'Anonymous' ? name : uid) } })() @@ -674,7 +707,8 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus const storeWithStatus = useAutomergeStoreV2({ handle: handle || null as any, userId: userMetadata.userId, - adapter: adapter // Pass adapter for JSON sync broadcasting + adapter: adapter, // Pass adapter for JSON sync broadcasting + isNetworkOnline // Pass network state for offline support }) // Update store ref when store is available diff --git a/src/components/ConnectionStatusIndicator.tsx b/src/components/ConnectionStatusIndicator.tsx index d5767a6..54b9cc1 100644 --- a/src/components/ConnectionStatusIndicator.tsx +++ b/src/components/ConnectionStatusIndicator.tsx @@ -40,8 +40,8 @@ export function ConnectionStatusIndicator({ color: '#8b5cf6', // Purple - calm, not alarming icon: '๐Ÿ„', pulse: false, - description: 'Your data is safe and encrypted locally', - detailedMessage: `Your canvas is stored securely in your browser using encrypted local storage. All changes are preserved with your personal encryption key. When you reconnect, your work will automatically sync with the shared canvas โ€” no data will be lost.`, + description: 'Viewing locally saved canvas', + detailedMessage: `You're viewing your locally cached canvas. All your previous work is safely stored in your browser. Any changes you make will be saved locally and automatically synced when you reconnect โ€” no data will be lost.`, } } diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index 13c8b3a..d9c4a51 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -45,6 +45,9 @@ import { ImageGenShape } from "@/shapes/ImageGenShapeUtil" import { ImageGenTool } from "@/tools/ImageGenTool" import { VideoGenShape } from "@/shapes/VideoGenShapeUtil" import { VideoGenTool } from "@/tools/VideoGenTool" +import { DrawfastShape } from "@/shapes/DrawfastShapeUtil" +import { DrawfastTool } from "@/tools/DrawfastTool" +import { LiveImageProvider } from "@/hooks/useLiveImage" import { MultmuxTool } from "@/tools/MultmuxTool" import { MultmuxShape } from "@/shapes/MultmuxShapeUtil" // MycelialIntelligence moved to permanent UI bar - shape kept for backwards compatibility @@ -152,6 +155,7 @@ const customShapeUtils = [ FathomNoteShape, // Individual Fathom meeting notes created from FathomMeetingsBrowser ImageGenShape, VideoGenShape, + DrawfastShape, MultmuxShape, MycelialIntelligenceShape, // Deprecated - kept for backwards compatibility PrivateWorkspaceShape, // Private zone for Google Export data sovereignty @@ -173,6 +177,7 @@ const customTools = [ FathomMeetingsTool, ImageGenTool, VideoGenTool, + DrawfastTool, MultmuxTool, PrivateWorkspaceTool, GoogleItemTool, @@ -383,12 +388,16 @@ export function Board() { } // Handler for successful authentication from banner + // NOTE: We don't call fetchBoardPermission here because: + // 1. This callback captures the OLD fetchBoardPermission from before re-render + // 2. The useEffect watching session.authed already handles re-fetching + // 3. That useEffect will run AFTER React re-renders with the new (cache-cleared) callback const handleAuthenticated = () => { setShowEditPrompt(false) - // Re-fetch permission after authentication - fetchBoardPermission(roomId).then(perm => { - setPermission(perm) - }) + // Force permission state reset - the useEffect will fetch fresh permissions + setPermission(null) + setPermissionLoading(true) + console.log('๐Ÿ” handleAuthenticated: Cleared permission state, useEffect will fetch fresh') } // Store roomId in localStorage for VideoChatShapeUtil to access @@ -444,10 +453,13 @@ export function Board() { } // Set up user preferences for TLDraw collaboration + // Color is based on session.username (CryptID) for consistency across sessions + // uniqueUserId is used for tldraw's presence system (allows multiple tabs) const [userPreferences, setUserPreferences] = useState(() => ({ id: uniqueUserId || 'anonymous', name: session.username || 'Anonymous', - color: uniqueUserId ? generateUserColor(uniqueUserId) : '#000000', + // Use session.username for color (not uniqueUserId) so color is consistent across all browser sessions + color: session.username ? generateUserColor(session.username) : (uniqueUserId ? generateUserColor(uniqueUserId) : '#000000'), colorScheme: getColorScheme(), })) @@ -457,7 +469,8 @@ export function Board() { setUserPreferences({ id: uniqueUserId, name: session.username || 'Anonymous', - color: generateUserColor(uniqueUserId), + // Use session.username for color consistency across sessions + color: session.username ? generateUserColor(session.username) : generateUserColor(uniqueUserId), colorScheme: getColorScheme(), }) } @@ -485,6 +498,7 @@ export function Board() { return () => observer.disconnect() }, []) + // Create the user object for TLDraw const user = useTldrawUser({ userPreferences, setUserPreferences }) @@ -918,12 +932,43 @@ export function Board() { } }, [editor, store.store, store.status]) - // Update presence when session changes + // Update presence when session changes and clean up stale presences useEffect(() => { - if (!editor || !session.authed || !session.username) return - - // The presence should automatically update through the useAutomergeSync configuration - // when the session changes, but we can also try to force an update + if (!editor) return + + const cleanupStalePresences = () => { + try { + const allRecords = editor.store.allRecords() + const presenceRecords = allRecords.filter((r: any) => + r.typeName === 'instance_presence' || + r.id?.startsWith('instance_presence:') + ) + + if (presenceRecords.length > 0) { + // Filter out stale presences (older than 30 seconds) + const now = Date.now() + const staleThreshold = 30 * 1000 // 30 seconds + const stalePresences = presenceRecords.filter((r: any) => + r.lastActivityTimestamp && (now - r.lastActivityTimestamp > staleThreshold) + ) + + if (stalePresences.length > 0) { + console.log(`๐Ÿงน Cleaning up ${stalePresences.length} stale presence record(s)`) + editor.store.remove(stalePresences.map((r: any) => r.id)) + } + } + } catch (error) { + console.error('Error cleaning up stale presences:', error) + } + } + + // Clean up immediately on auth change + cleanupStalePresences() + + // Also run periodic cleanup every 15 seconds + const cleanupInterval = setInterval(cleanupStalePresences, 15000) + + return () => clearInterval(cleanupInterval) }, [editor, session.authed, session.username]) // Update TLDraw user preferences when editor is available and user is authenticated @@ -1140,16 +1185,20 @@ export function Board() { // Tldraw will automatically render shapes as they're added via patches (like in dev) const hasStore = !!store.store const isSynced = store.status === 'synced-remote' - - // Render as soon as store is synced - shapes will load via patches + + // OFFLINE SUPPORT: Also render when we have local data but no network + // This allows users to view their board even when offline + const isOfflineWithLocalData = !isNetworkOnline && hasStore && store.status !== 'error' + + // Render as soon as store is synced OR we're offline with local data // This matches dev behavior where Tldraw mounts first, then shapes load - const shouldRender = hasStore && isSynced - + const shouldRender = hasStore && (isSynced || isOfflineWithLocalData) + if (!shouldRender) { return (
-
Loading canvas...
+
{!isNetworkOnline ? 'Loading offline data...' : 'Loading canvas...'}
) @@ -1158,6 +1207,7 @@ export function Board() { return ( +
)}
+
)