feat: add offline storage fallback for browser reload
When the browser reloads without network connectivity, the canvas now automatically loads from local IndexedDB storage and renders the last known state. Changes: - Board.tsx: Updated render condition to allow rendering when offline with local data (isOfflineWithLocalData flag) - useAutomergeStoreV2: Added isNetworkOnline parameter and offline fast path that immediately loads records from Automerge doc without waiting for network patches - useAutomergeSyncRepo: Passes isNetworkOnline to useAutomergeStoreV2 - ConnectionStatusIndicator: Updated messaging to clarify users are viewing locally cached canvas when offline 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
f06c5c7537
commit
fafad35cb0
|
|
@ -136,10 +136,12 @@ export function useAutomergeStoreV2({
|
||||||
handle,
|
handle,
|
||||||
userId: _userId,
|
userId: _userId,
|
||||||
adapter,
|
adapter,
|
||||||
|
isNetworkOnline = true,
|
||||||
}: {
|
}: {
|
||||||
handle: DocHandle<any>
|
handle: DocHandle<any>
|
||||||
userId: string
|
userId: string
|
||||||
adapter?: any
|
adapter?: any
|
||||||
|
isNetworkOnline?: boolean
|
||||||
}): TLStoreWithStatus {
|
}): TLStoreWithStatus {
|
||||||
// useAutomergeStoreV2 initializing
|
// useAutomergeStoreV2 initializing
|
||||||
|
|
||||||
|
|
@ -1074,58 +1076,122 @@ export function useAutomergeStoreV2({
|
||||||
try {
|
try {
|
||||||
await handle.whenReady()
|
await handle.whenReady()
|
||||||
const doc = handle.doc()
|
const doc = handle.doc()
|
||||||
|
|
||||||
// Check if store is already populated from patches
|
// Check if store is already populated from patches
|
||||||
const existingStoreRecords = store.allRecords()
|
const existingStoreRecords = store.allRecords()
|
||||||
const existingStoreShapes = existingStoreRecords.filter((r: any) => r.typeName === 'shape')
|
const existingStoreShapes = existingStoreRecords.filter((r: any) => r.typeName === 'shape')
|
||||||
|
|
||||||
|
// Determine connection status based on network state
|
||||||
|
const connectionStatus = isNetworkOnline ? "online" : "offline"
|
||||||
|
|
||||||
if (doc.store) {
|
if (doc.store) {
|
||||||
const storeKeys = Object.keys(doc.store)
|
const storeKeys = Object.keys(doc.store)
|
||||||
const docShapes = Object.values(doc.store).filter((r: any) => r?.typeName === 'shape').length
|
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 store already has shapes, patches have been applied (dev mode behavior)
|
||||||
if (existingStoreShapes.length > 0) {
|
if (existingStoreShapes.length > 0) {
|
||||||
console.log(`✅ Store already populated from patches (${existingStoreShapes.length} shapes) - using patch-based loading like dev`)
|
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
|
// REMOVED: Aggressive shape refresh that was causing coordinate loss
|
||||||
// Shapes should be visible through normal patch application
|
// Shapes should be visible through normal patch application
|
||||||
// If shapes aren't visible, it's likely a different issue that refresh won't fix
|
// If shapes aren't visible, it's likely a different issue that refresh won't fix
|
||||||
|
|
||||||
setStoreWithStatus({
|
setStoreWithStatus({
|
||||||
store,
|
store,
|
||||||
status: "synced-remote",
|
status: "synced-remote",
|
||||||
connectionStatus: "online",
|
connectionStatus,
|
||||||
})
|
})
|
||||||
return
|
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
|
// 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
|
// The automergeChangeHandler (set up above) should process them automatically
|
||||||
// Just wait a bit for patches to be processed, then set status
|
// Just wait a bit for patches to be processed, then set status
|
||||||
if (docShapes > 0 && existingStoreShapes.length === 0) {
|
if (docShapes > 0 && existingStoreShapes.length === 0) {
|
||||||
console.log(`📊 Doc has ${docShapes} shapes but store is empty. Waiting for patches to be processed by handler...`)
|
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
|
// 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
|
// The handler is already set up, so it should catch patches from the initial data load
|
||||||
let attempts = 0
|
let attempts = 0
|
||||||
const maxAttempts = 10 // Wait up to 2 seconds (10 * 200ms)
|
const maxAttempts = 10 // Wait up to 2 seconds (10 * 200ms)
|
||||||
|
|
||||||
await new Promise<void>(resolve => {
|
await new Promise<void>(resolve => {
|
||||||
const checkForPatches = () => {
|
const checkForPatches = () => {
|
||||||
attempts++
|
attempts++
|
||||||
const currentShapes = store.allRecords().filter((r: any) => r.typeName === 'shape')
|
const currentShapes = store.allRecords().filter((r: any) => r.typeName === 'shape')
|
||||||
|
|
||||||
if (currentShapes.length > 0) {
|
if (currentShapes.length > 0) {
|
||||||
console.log(`✅ Patches applied successfully: ${currentShapes.length} shapes loaded via patches`)
|
console.log(`✅ Patches applied successfully: ${currentShapes.length} shapes loaded via patches`)
|
||||||
|
|
||||||
// REMOVED: Aggressive shape refresh that was causing coordinate loss
|
// REMOVED: Aggressive shape refresh that was causing coordinate loss
|
||||||
// Shapes loaded via patches should be visible without forced refresh
|
// Shapes loaded via patches should be visible without forced refresh
|
||||||
|
|
||||||
setStoreWithStatus({
|
setStoreWithStatus({
|
||||||
store,
|
store,
|
||||||
status: "synced-remote",
|
status: "synced-remote",
|
||||||
connectionStatus: "online",
|
connectionStatus,
|
||||||
})
|
})
|
||||||
resolve()
|
resolve()
|
||||||
} else if (attempts < maxAttempts) {
|
} else if (attempts < maxAttempts) {
|
||||||
|
|
@ -1136,35 +1202,35 @@ export function useAutomergeStoreV2({
|
||||||
console.warn(`⚠️ No patches received after ${maxAttempts} attempts for room initialization.`)
|
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(`⚠️ 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.`)
|
console.warn(`⚠️ Store will remain empty - patches should handle data loading in normal operation.`)
|
||||||
|
|
||||||
// Simplified fallback: Just log and continue with empty store
|
// Simplified fallback: Just log and continue with empty store
|
||||||
// Patches should handle data loading, so if they don't come through,
|
// 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
|
// it's likely the document is actually empty or there's a timing issue
|
||||||
// that will resolve on next sync
|
// that will resolve on next sync
|
||||||
|
|
||||||
setStoreWithStatus({
|
setStoreWithStatus({
|
||||||
store,
|
store,
|
||||||
status: "synced-remote",
|
status: "synced-remote",
|
||||||
connectionStatus: "online",
|
connectionStatus,
|
||||||
})
|
})
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start checking immediately since handler is already set up
|
// Start checking immediately since handler is already set up
|
||||||
setTimeout(checkForPatches, 100)
|
setTimeout(checkForPatches, 100)
|
||||||
})
|
})
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If doc is empty, just set status
|
// If doc is empty, just set status
|
||||||
if (docShapes === 0) {
|
if (docShapes === 0) {
|
||||||
console.log(`📊 Empty document - starting fresh (patch-based loading)`)
|
console.log(`📊 Empty document - starting fresh (patch-based loading)`)
|
||||||
setStoreWithStatus({
|
setStoreWithStatus({
|
||||||
store,
|
store,
|
||||||
status: "synced-remote",
|
status: "synced-remote",
|
||||||
connectionStatus: "online",
|
connectionStatus,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -1174,7 +1240,7 @@ export function useAutomergeStoreV2({
|
||||||
setStoreWithStatus({
|
setStoreWithStatus({
|
||||||
store,
|
store,
|
||||||
status: "synced-remote",
|
status: "synced-remote",
|
||||||
connectionStatus: "online",
|
connectionStatus: isNetworkOnline ? "online" : "offline",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -1183,17 +1249,17 @@ export function useAutomergeStoreV2({
|
||||||
setStoreWithStatus({
|
setStoreWithStatus({
|
||||||
store,
|
store,
|
||||||
status: "synced-remote",
|
status: "synced-remote",
|
||||||
connectionStatus: "online",
|
connectionStatus: isNetworkOnline ? "online" : "offline",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeStore()
|
initializeStore()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubs.forEach((unsub) => unsub())
|
unsubs.forEach((unsub) => unsub())
|
||||||
}
|
}
|
||||||
}, [handle, store])
|
}, [handle, store, isNetworkOnline])
|
||||||
|
|
||||||
/* -------------------- Presence -------------------- */
|
/* -------------------- Presence -------------------- */
|
||||||
// Create a safe handle that won't cause null errors
|
// Create a safe handle that won't cause null errors
|
||||||
|
|
|
||||||
|
|
@ -296,13 +296,40 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
||||||
console.error('❌ Error applying presence:', error)
|
console.error('❌ Error applying presence:', error)
|
||||||
}
|
}
|
||||||
}, [flushPresenceUpdates])
|
}, [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 { repo, adapter, storageAdapter } = useMemo(() => {
|
||||||
const adapter = new CloudflareNetworkAdapter(
|
const adapter = new CloudflareNetworkAdapter(
|
||||||
workerUrl,
|
workerUrl,
|
||||||
roomId,
|
roomId,
|
||||||
applyJsonSyncData,
|
applyJsonSyncData,
|
||||||
applyPresenceUpdate
|
applyPresenceUpdate,
|
||||||
|
handlePresenceLeave
|
||||||
)
|
)
|
||||||
|
|
||||||
// Store adapter ref for use in callbacks
|
// Store adapter ref for use in callbacks
|
||||||
|
|
@ -325,7 +352,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
||||||
})
|
})
|
||||||
|
|
||||||
return { repo, adapter, storageAdapter }
|
return { repo, adapter, storageAdapter }
|
||||||
}, [workerUrl, roomId, applyJsonSyncData, applyPresenceUpdate])
|
}, [workerUrl, roomId, applyJsonSyncData, applyPresenceUpdate, handlePresenceLeave])
|
||||||
|
|
||||||
// Subscribe to connection state changes
|
// Subscribe to connection state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -653,20 +680,26 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user metadata for presence
|
// 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 } = (() => {
|
const userMetadata: { userId: string; name: string; color: string } = (() => {
|
||||||
if (user && 'userId' in user) {
|
if (user && 'userId' in user) {
|
||||||
const uid = (user as { userId: string; name: string; color?: string }).userId
|
const uid = (user as { userId: string; name: string; color?: string }).userId
|
||||||
|
const name = (user as { userId: string; name: string; color?: string }).name
|
||||||
return {
|
return {
|
||||||
userId: uid,
|
userId: uid,
|
||||||
name: (user as { userId: string; name: string; color?: string }).name,
|
name: name,
|
||||||
color: (user as { userId: string; name: string; color?: string }).color || generateUserColor(uid)
|
// 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 uid = user?.id || 'anonymous'
|
||||||
|
const name = user?.name || 'Anonymous'
|
||||||
return {
|
return {
|
||||||
userId: uid,
|
userId: uid,
|
||||||
name: user?.name || 'Anonymous',
|
name: name,
|
||||||
color: generateUserColor(uid)
|
// 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({
|
const storeWithStatus = useAutomergeStoreV2({
|
||||||
handle: handle || null as any,
|
handle: handle || null as any,
|
||||||
userId: userMetadata.userId,
|
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
|
// Update store ref when store is available
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,8 @@ export function ConnectionStatusIndicator({
|
||||||
color: '#8b5cf6', // Purple - calm, not alarming
|
color: '#8b5cf6', // Purple - calm, not alarming
|
||||||
icon: '🍄',
|
icon: '🍄',
|
||||||
pulse: false,
|
pulse: false,
|
||||||
description: 'Your data is safe and encrypted locally',
|
description: 'Viewing locally saved canvas',
|
||||||
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.`,
|
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.`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,9 @@ import { ImageGenShape } from "@/shapes/ImageGenShapeUtil"
|
||||||
import { ImageGenTool } from "@/tools/ImageGenTool"
|
import { ImageGenTool } from "@/tools/ImageGenTool"
|
||||||
import { VideoGenShape } from "@/shapes/VideoGenShapeUtil"
|
import { VideoGenShape } from "@/shapes/VideoGenShapeUtil"
|
||||||
import { VideoGenTool } from "@/tools/VideoGenTool"
|
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 { MultmuxTool } from "@/tools/MultmuxTool"
|
||||||
import { MultmuxShape } from "@/shapes/MultmuxShapeUtil"
|
import { MultmuxShape } from "@/shapes/MultmuxShapeUtil"
|
||||||
// MycelialIntelligence moved to permanent UI bar - shape kept for backwards compatibility
|
// 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
|
FathomNoteShape, // Individual Fathom meeting notes created from FathomMeetingsBrowser
|
||||||
ImageGenShape,
|
ImageGenShape,
|
||||||
VideoGenShape,
|
VideoGenShape,
|
||||||
|
DrawfastShape,
|
||||||
MultmuxShape,
|
MultmuxShape,
|
||||||
MycelialIntelligenceShape, // Deprecated - kept for backwards compatibility
|
MycelialIntelligenceShape, // Deprecated - kept for backwards compatibility
|
||||||
PrivateWorkspaceShape, // Private zone for Google Export data sovereignty
|
PrivateWorkspaceShape, // Private zone for Google Export data sovereignty
|
||||||
|
|
@ -173,6 +177,7 @@ const customTools = [
|
||||||
FathomMeetingsTool,
|
FathomMeetingsTool,
|
||||||
ImageGenTool,
|
ImageGenTool,
|
||||||
VideoGenTool,
|
VideoGenTool,
|
||||||
|
DrawfastTool,
|
||||||
MultmuxTool,
|
MultmuxTool,
|
||||||
PrivateWorkspaceTool,
|
PrivateWorkspaceTool,
|
||||||
GoogleItemTool,
|
GoogleItemTool,
|
||||||
|
|
@ -383,12 +388,16 @@ export function Board() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler for successful authentication from banner
|
// 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 = () => {
|
const handleAuthenticated = () => {
|
||||||
setShowEditPrompt(false)
|
setShowEditPrompt(false)
|
||||||
// Re-fetch permission after authentication
|
// Force permission state reset - the useEffect will fetch fresh permissions
|
||||||
fetchBoardPermission(roomId).then(perm => {
|
setPermission(null)
|
||||||
setPermission(perm)
|
setPermissionLoading(true)
|
||||||
})
|
console.log('🔐 handleAuthenticated: Cleared permission state, useEffect will fetch fresh')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store roomId in localStorage for VideoChatShapeUtil to access
|
// Store roomId in localStorage for VideoChatShapeUtil to access
|
||||||
|
|
@ -444,10 +453,13 @@ export function Board() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up user preferences for TLDraw collaboration
|
// 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<TLUserPreferences>(() => ({
|
const [userPreferences, setUserPreferences] = useState<TLUserPreferences>(() => ({
|
||||||
id: uniqueUserId || 'anonymous',
|
id: uniqueUserId || 'anonymous',
|
||||||
name: session.username || '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(),
|
colorScheme: getColorScheme(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -457,7 +469,8 @@ export function Board() {
|
||||||
setUserPreferences({
|
setUserPreferences({
|
||||||
id: uniqueUserId,
|
id: uniqueUserId,
|
||||||
name: session.username || 'Anonymous',
|
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(),
|
colorScheme: getColorScheme(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -485,6 +498,7 @@ export function Board() {
|
||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
// Create the user object for TLDraw
|
// Create the user object for TLDraw
|
||||||
const user = useTldrawUser({ userPreferences, setUserPreferences })
|
const user = useTldrawUser({ userPreferences, setUserPreferences })
|
||||||
|
|
||||||
|
|
@ -918,12 +932,43 @@ export function Board() {
|
||||||
}
|
}
|
||||||
}, [editor, store.store, store.status])
|
}, [editor, store.store, store.status])
|
||||||
|
|
||||||
// Update presence when session changes
|
// Update presence when session changes and clean up stale presences
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor || !session.authed || !session.username) return
|
if (!editor) return
|
||||||
|
|
||||||
// The presence should automatically update through the useAutomergeSync configuration
|
const cleanupStalePresences = () => {
|
||||||
// when the session changes, but we can also try to force an update
|
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])
|
}, [editor, session.authed, session.username])
|
||||||
|
|
||||||
// Update TLDraw user preferences when editor is available and user is authenticated
|
// 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)
|
// Tldraw will automatically render shapes as they're added via patches (like in dev)
|
||||||
const hasStore = !!store.store
|
const hasStore = !!store.store
|
||||||
const isSynced = store.status === 'synced-remote'
|
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
|
// This matches dev behavior where Tldraw mounts first, then shapes load
|
||||||
const shouldRender = hasStore && isSynced
|
const shouldRender = hasStore && (isSynced || isOfflineWithLocalData)
|
||||||
|
|
||||||
if (!shouldRender) {
|
if (!shouldRender) {
|
||||||
return (
|
return (
|
||||||
<AutomergeHandleProvider handle={automergeHandle}>
|
<AutomergeHandleProvider handle={automergeHandle}>
|
||||||
<div style={{ position: "fixed", inset: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
<div style={{ position: "fixed", inset: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
<div>Loading canvas...</div>
|
<div>{!isNetworkOnline ? 'Loading offline data...' : 'Loading canvas...'}</div>
|
||||||
</div>
|
</div>
|
||||||
</AutomergeHandleProvider>
|
</AutomergeHandleProvider>
|
||||||
)
|
)
|
||||||
|
|
@ -1158,6 +1207,7 @@ export function Board() {
|
||||||
return (
|
return (
|
||||||
<AutomergeHandleProvider handle={automergeHandle}>
|
<AutomergeHandleProvider handle={automergeHandle}>
|
||||||
<ConnectionProvider connectionState={connectionState} isNetworkOnline={isNetworkOnline}>
|
<ConnectionProvider connectionState={connectionState} isNetworkOnline={isNetworkOnline}>
|
||||||
|
<LiveImageProvider>
|
||||||
<div style={{ position: "fixed", inset: 0 }}>
|
<div style={{ position: "fixed", inset: 0 }}>
|
||||||
<Tldraw
|
<Tldraw
|
||||||
key={`tldraw-${authChangeCount}-${session.authed ? 'authed' : 'anon'}-${session.username || 'guest'}`}
|
key={`tldraw-${authChangeCount}-${session.authed ? 'authed' : 'anon'}-${session.username || 'guest'}`}
|
||||||
|
|
@ -1299,6 +1349,7 @@ export function Board() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</LiveImageProvider>
|
||||||
</ConnectionProvider>
|
</ConnectionProvider>
|
||||||
</AutomergeHandleProvider>
|
</AutomergeHandleProvider>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue