compassmock/src/hooks/use-sync-status.ts
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

149 lines
3.8 KiB
TypeScript

"use client"
import { useSyncExternalStore, useCallback, useEffect, useState } from "react"
import { useDesktop } from "./use-desktop"
export type SyncStatus = "idle" | "syncing" | "error" | "offline"
export interface SyncState {
status: SyncStatus
pendingCount: number
lastSyncTime: number | null
errorMessage: string | null
}
const initialState: SyncState = {
status: "idle",
pendingCount: 0,
lastSyncTime: null,
errorMessage: null,
}
// Store for sync state (used by Tauri event listeners)
let syncState = { ...initialState }
const listeners = new Set<() => void>()
function notifyListeners() {
listeners.forEach((listener) => listener())
}
function getSyncSnapshot(): SyncState {
return syncState
}
function getSyncServerSnapshot(): SyncState {
return initialState
}
function subscribeToSync(onStoreChange: () => void): () => void {
listeners.add(onStoreChange)
return () => listeners.delete(onStoreChange)
}
// Update sync state (called by Tauri event handlers)
export function updateSyncState(updates: Partial<SyncState>): void {
syncState = { ...syncState, ...updates }
notifyListeners()
}
// Hook to track sync queue and status
export function useSyncStatus(): SyncState {
const isDesktop = useDesktop()
const state = useSyncExternalStore(
subscribeToSync,
getSyncSnapshot,
getSyncServerSnapshot,
)
// Set up Tauri event listeners for sync updates
useEffect(() => {
if (!isDesktop) return
let unlisten: (() => void) | undefined
async function setupListeners() {
try {
const { listen } = await import("@tauri-apps/api/event")
// Listen for sync status changes
const unlistenSync = await listen<SyncState>("sync:status", (event) => {
updateSyncState(event.payload)
})
const unlistenQueue = await listen<{ count: number }>(
"sync:queue-changed",
(event) => {
updateSyncState({ pendingCount: event.payload.count })
},
)
unlisten = () => {
unlistenSync()
unlistenQueue()
}
} catch (error) {
console.error("Failed to set up sync listeners:", error)
}
}
setupListeners()
return () => unlisten?.()
}, [isDesktop])
return isDesktop ? state : initialState
}
// Hook to trigger manual sync
export function useTriggerSync() {
const isDesktop = useDesktop()
return useCallback(async (): Promise<boolean> => {
if (!isDesktop) return false
try {
const { invoke } = await import("@tauri-apps/api/core")
await invoke("sync_now")
return true
} catch (error) {
console.error("Failed to trigger sync:", error)
return false
}
}, [isDesktop])
}
// Hook for offline detection (desktop-specific with Tauri network plugin)
export function useDesktopOnlineStatus(): boolean {
const isDesktopApp = useDesktop()
const [online, setOnline] = useState(
typeof navigator !== "undefined" ? navigator.onLine : true,
)
useEffect(() => {
if (!isDesktopApp) {
// Web fallback
const handleOnline = () => setOnline(true)
const handleOffline = () => setOnline(false)
window.addEventListener("online", handleOnline)
window.addEventListener("offline", handleOffline)
return () => {
window.removeEventListener("online", handleOnline)
window.removeEventListener("offline", handleOffline)
}
}
// Use navigator events (Tauri webview supports these)
const handleOnline = () => setOnline(true)
const handleOffline = () => setOnline(false)
setOnline(navigator.onLine)
window.addEventListener("online", handleOnline)
window.addEventListener("offline", handleOffline)
return () => {
window.removeEventListener("online", handleOnline)
window.removeEventListener("offline", handleOffline)
}
}, [isDesktopApp])
return online
}