compassmock/src/components/desktop/desktop-shell.tsx
Nicholai 40fdf48cbf
feat: add conversations, desktop (Tauri), and offline sync (#81)
* feat: add conversations, desktop (Tauri), and offline sync

Major new features:
- conversations module: Slack-like channels, threads, reactions, pins
- Tauri desktop app with local SQLite for offline-first operation
- Hybrid logical clock sync engine with conflict resolution
- DB provider abstraction (D1/Tauri/memory) with React context

Conversations:
- Text/voice/announcement channels with categories
- Message threads, reactions, attachments, pinning
- Real-time presence and typing indicators
- Full-text search across messages

Desktop (Tauri):
- Local SQLite database with sync to cloud D1
- Offline mutation queue with automatic replay
- Window management and keyboard shortcuts
- Desktop shell with offline banner

Sync infrastructure:
- Vector clock implementation for causality tracking
- Last-write-wins with semantic conflict resolution
- Delta sync via checkpoints for bandwidth efficiency
- Comprehensive test coverage

Also adds e2e test setup with Playwright and CI workflows
for desktop releases.

* fix(tests): sync engine test schema and checkpoint logic

- Add missing process_after column and sync_tombstone table to test schemas
- Fix checkpoint update to save cursor even when records array is empty
- Revert claude-code-review.yml workflow changes to match main

---------

Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
2026-02-14 19:32:14 -07:00

162 lines
5.0 KiB
TypeScript

"use client"
import { useEffect, createContext, useContext, useCallback, type ReactNode } from "react"
import { useDesktop, useTauriReady } from "@/hooks/use-desktop"
import { useTriggerSync, useSyncStatus, updateSyncState } from "@/hooks/use-sync-status"
import { getBackupQueueCount } from "@/lib/sync/queue/mutation-queue"
interface DesktopContextValue {
isDesktop: boolean
tauriReady: "loading" | "ready" | "error"
triggerSync: () => Promise<boolean>
syncStatus: "idle" | "syncing" | "error" | "offline"
pendingCount: number
}
const DesktopContext = createContext<DesktopContextValue>({
isDesktop: false,
tauriReady: "loading",
triggerSync: async () => false,
syncStatus: "idle",
pendingCount: 0,
})
export function useDesktopContext(): DesktopContextValue {
return useContext(DesktopContext)
}
interface DesktopShellProps {
readonly children: ReactNode
}
// Desktop shell initializes Tauri-specific features and provides context.
// Returns children unchanged on non-desktop platforms.
export function DesktopShell({ children }: DesktopShellProps) {
const isDesktop = useDesktop()
const tauriReady = useTauriReady()
const triggerSync = useTriggerSync()
const { status: syncStatus, pendingCount } = useSyncStatus()
// Handle beforeunload to warn about pending sync operations
const handleBeforeUnload = useCallback(
(event: BeforeUnloadEvent) => {
// Check both the sync status hook and localStorage backup
const backupCount = getBackupQueueCount()
const hasPendingOperations = pendingCount > 0 || backupCount > 0
const isCurrentlySyncing = syncStatus === "syncing"
if (hasPendingOperations || isCurrentlySyncing) {
// Modern browsers ignore custom messages, but we set it anyway
// The browser will show a generic "Leave site?" dialog
const message =
isCurrentlySyncing
? "Sync is in progress. Closing now may result in data loss."
: `You have ${pendingCount > 0 ? pendingCount : backupCount} pending changes waiting to sync. ` +
"Closing now may result in data loss."
event.preventDefault()
event.returnValue = message
return message
}
},
[pendingCount, syncStatus]
)
// Handle visibility change to persist queue when app goes to background
const handleVisibilityChange = useCallback(() => {
if (document.visibilityState === "hidden" && isDesktop) {
// The queue manager handles its own persistence, but we can trigger
// a final persist here for safety
updateSyncState({ pendingCount: getBackupQueueCount() })
}
}, [isDesktop])
// Initialize window state restoration and sync on mount
useEffect(() => {
if (!isDesktop || tauriReady !== "ready") return
async function initializeDesktop() {
try {
// Restore window state
const { WindowManager } = await import("@/lib/desktop/window-manager")
await WindowManager.restoreState()
// Check for restored mutations from localStorage and notify sync system
const backupCount = getBackupQueueCount()
if (backupCount > 0) {
console.info(`Found ${backupCount} backed-up mutations to restore`)
updateSyncState({ pendingCount: backupCount })
}
// Start initial sync after a short delay (let app load first)
const timeoutId = setTimeout(() => {
triggerSync()
}, 2000)
return () => clearTimeout(timeoutId)
} catch (error) {
console.error("Failed to initialize desktop shell:", error)
}
}
const cleanup = initializeDesktop()
return () => {
cleanup?.then((fn) => fn?.())
}
}, [isDesktop, tauriReady, triggerSync])
// Set up keyboard shortcuts
useEffect(() => {
if (!isDesktop || tauriReady !== "ready") return
let unregister: (() => void) | undefined
async function setupShortcuts() {
try {
const { registerShortcuts } = await import(
"@/lib/desktop/shortcuts"
)
unregister = await registerShortcuts({ triggerSync })
} catch (error) {
console.error("Failed to register desktop shortcuts:", error)
}
}
setupShortcuts()
return () => unregister?.()
}, [isDesktop, tauriReady, triggerSync])
// Set up beforeunload and visibility change handlers
useEffect(() => {
if (!isDesktop) return
window.addEventListener("beforeunload", handleBeforeUnload)
document.addEventListener("visibilitychange", handleVisibilityChange)
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload)
document.removeEventListener("visibilitychange", handleVisibilityChange)
}
}, [isDesktop, handleBeforeUnload, handleVisibilityChange])
// On non-desktop, just return children
if (!isDesktop) {
return <>{children}</>
}
// Provide desktop context
return (
<DesktopContext.Provider
value={{
isDesktop,
tauriReady,
triggerSync,
syncStatus,
pendingCount,
}}
>
{children}
</DesktopContext.Provider>
)
}