* 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>
170 lines
4.2 KiB
TypeScript
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,
|
|
}
|
|
}
|