compassmock/src/hooks/use-realtime-channel.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

170 lines
4.2 KiB
TypeScript

"use client"
import { useState, useEffect, useRef, useCallback } from "react"
import { getChannelUpdates } from "@/app/actions/conversations-realtime"
type TypingUser = {
id: string
displayName: string | null
}
type MessageData = {
id: string
channelId: string
threadId: string | null
content: string
contentHtml: string | null
editedAt: string | null
deletedAt: string | null
isPinned: boolean
replyCount: number
lastReplyAt: string | null
createdAt: string
user: {
id: string
displayName: string | null
email: string
avatarUrl: string | null
} | null
}
type RealtimeUpdate = {
newMessages: MessageData[]
typingUsers: TypingUser[]
isPolling: boolean
}
type PollingOptions = {
visibleInterval?: number
hiddenInterval?: number
}
const DEFAULT_VISIBLE_POLL_INTERVAL = 2500 // 2.5 seconds when tab is visible
const DEFAULT_HIDDEN_POLL_INTERVAL = 10000 // 10 seconds when tab is hidden
export function useRealtimeChannel(
channelId: string,
lastMessageId: string | null,
options?: PollingOptions,
): RealtimeUpdate {
const [newMessages, setNewMessages] = useState<MessageData[]>([])
const [typingUsers, setTypingUsers] = useState<TypingUser[]>([])
const [isPolling, setIsPolling] = useState(false)
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
const isVisibleRef = useRef(true)
const lastMessageIdRef = useRef(lastMessageId)
const visibleInterval = options?.visibleInterval ?? DEFAULT_VISIBLE_POLL_INTERVAL
const hiddenInterval = options?.hiddenInterval ?? DEFAULT_HIDDEN_POLL_INTERVAL
// keep lastMessageId ref in sync
useEffect(() => {
lastMessageIdRef.current = lastMessageId
}, [lastMessageId])
const poll = useCallback(async () => {
// don't poll without a baseline message to compare against
if (!lastMessageIdRef.current) {
return
}
setIsPolling(true)
try {
const result = await getChannelUpdates(
channelId,
lastMessageIdRef.current ?? undefined,
)
if (result.success) {
// accumulate new messages (avoid duplicates)
if (result.data.messages.length > 0) {
setNewMessages((prev) => {
const existingIds = new Set(prev.map((m) => m.id))
const uniqueNew = result.data.messages.filter(
(m) => !existingIds.has(m.id),
)
return [...prev, ...uniqueNew]
})
}
setTypingUsers(result.data.typingUsers)
}
} catch (error) {
console.error("[useRealtimeChannel] poll error:", error)
} finally {
setIsPolling(false)
}
}, [channelId])
// handle visibility changes
useEffect(() => {
const handleVisibilityChange = () => {
isVisibleRef.current = document.visibilityState === "visible"
// restart polling with correct interval when visibility changes
if (pollingRef.current) {
clearInterval(pollingRef.current)
pollingRef.current = null
}
// only start polling if we have a lastMessageId
if (lastMessageIdRef.current) {
const interval = isVisibleRef.current
? visibleInterval
: hiddenInterval
pollingRef.current = setInterval(poll, interval)
// also poll immediately when becoming visible
if (isVisibleRef.current) {
poll()
}
}
}
document.addEventListener("visibilitychange", handleVisibilityChange)
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange)
}
}, [poll])
// main polling setup
useEffect(() => {
// clear any existing interval
if (pollingRef.current) {
clearInterval(pollingRef.current)
pollingRef.current = null
}
// only start polling when we have messages to compare against
if (!lastMessageId) {
return
}
const interval = isVisibleRef.current
? visibleInterval
: hiddenInterval
// initial poll
poll()
// set up interval
pollingRef.current = setInterval(poll, interval)
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current)
pollingRef.current = null
}
}
}, [channelId, lastMessageId, poll])
return {
newMessages,
typingUsers,
isPolling,
}
}