// WebSocket context for real-time updates (US-036) import { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react' import { useAuth } from './AuthContext' const ACCESS_TOKEN_KEY = 'smartbotic_access_token' interface WebSocketMessage { type: string [key: string]: unknown } interface Notification { id: string message: string level: 'info' | 'warn' | 'error' timestamp: string read: boolean } interface DocumentEvent { workspace_id: string collection: string action: 'create' | 'update' | 'delete' document_id: string data?: unknown } interface WebSocketContextType { isConnected: boolean notifications: Notification[] unreadCount: number subscribe: (workspaceId: string, collection: string) => void unsubscribe: (workspaceId: string, collection: string) => void markAsRead: (notificationId: string) => void markAllAsRead: () => void clearNotifications: () => void onDocumentEvent: (callback: (event: DocumentEvent) => void) => () => void } const WebSocketContext = createContext(undefined) const WS_URL = `ws://${window.location.hostname}:18081/ws` const RECONNECT_DELAY = 3000 const MAX_NOTIFICATIONS = 50 export function WebSocketProvider({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading: isAuthLoading } = useAuth() const wsRef = useRef(null) const reconnectTimeoutRef = useRef | undefined>(undefined) const documentEventCallbacksRef = useRef void>>(new Set()) const [isConnected, setIsConnected] = useState(false) const [notifications, setNotifications] = useState([]) const [subscriptions, setSubscriptions] = useState>(new Set()) const unreadCount = notifications.filter((n) => !n.read).length // Get access token from localStorage const getAccessToken = useCallback(() => { return localStorage.getItem(ACCESS_TOKEN_KEY) }, []) // Connect to WebSocket const connect = useCallback(() => { // Don't connect while auth is still loading if (isAuthLoading) { return } const accessToken = getAccessToken() if (!isAuthenticated || !accessToken || wsRef.current?.readyState === WebSocket.OPEN) { return } try { const ws = new WebSocket(WS_URL) wsRef.current = ws ws.onopen = () => { console.log('WebSocket connected') // Send authentication ws.send(JSON.stringify({ type: 'auth', token: accessToken })) } ws.onmessage = (event) => { try { const message: WebSocketMessage = JSON.parse(event.data) handleMessage(message) } catch (err) { console.error('Failed to parse WebSocket message:', err) } } ws.onclose = () => { console.log('WebSocket disconnected') setIsConnected(false) wsRef.current = null // Attempt reconnection if (isAuthenticated) { reconnectTimeoutRef.current = setTimeout(connect, RECONNECT_DELAY) } } ws.onerror = (error) => { console.error('WebSocket error:', error) } } catch (err) { console.error('Failed to create WebSocket connection:', err) } }, [isAuthenticated, isAuthLoading, getAccessToken]) // Handle incoming messages const handleMessage = useCallback((message: WebSocketMessage) => { switch (message.type) { case 'auth_success': setIsConnected(true) // Re-subscribe to previous subscriptions subscriptions.forEach((key) => { const [workspaceId, collection] = key.split(':') wsRef.current?.send( JSON.stringify({ type: 'subscribe', workspace_id: workspaceId, collection }) ) }) break case 'auth_error': console.error('WebSocket auth error:', message.error) wsRef.current?.close() break case 'notification': addNotification({ id: crypto.randomUUID(), message: String(message.message || 'New notification'), level: (message.level as 'info' | 'warn' | 'error') || 'info', timestamp: new Date().toISOString(), read: false, }) break case 'document': { // Notify document event listeners const docEvent: DocumentEvent = { workspace_id: String(message.workspace_id || ''), collection: String(message.collection || ''), action: message.action as 'create' | 'update' | 'delete', document_id: String(message.document_id || ''), data: message.data, } documentEventCallbacksRef.current.forEach((cb) => cb(docEvent)) break } case 'subscribed': console.log(`Subscribed to ${message.workspace_id}:${message.collection}`) break case 'unsubscribed': console.log(`Unsubscribed from ${message.workspace_id}:${message.collection}`) break case 'error': console.error('WebSocket error message:', message.error) break default: console.log('Unknown WebSocket message type:', message.type) } }, [subscriptions]) // Add notification const addNotification = useCallback((notification: Notification) => { setNotifications((prev) => { const updated = [notification, ...prev] // Keep only the last MAX_NOTIFICATIONS return updated.slice(0, MAX_NOTIFICATIONS) }) }, []) // Subscribe to collection changes const subscribe = useCallback((workspaceId: string, collection: string) => { const key = `${workspaceId}:${collection}` setSubscriptions((prev) => new Set(prev).add(key)) if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ type: 'subscribe', workspace_id: workspaceId, collection })) } }, []) // Unsubscribe from collection changes const unsubscribe = useCallback((workspaceId: string, collection: string) => { const key = `${workspaceId}:${collection}` setSubscriptions((prev) => { const updated = new Set(prev) updated.delete(key) return updated }) if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send( JSON.stringify({ type: 'unsubscribe', workspace_id: workspaceId, collection }) ) } }, []) // Mark notification as read const markAsRead = useCallback((notificationId: string) => { setNotifications((prev) => prev.map((n) => (n.id === notificationId ? { ...n, read: true } : n)) ) }, []) // Mark all notifications as read const markAllAsRead = useCallback(() => { setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))) }, []) // Clear all notifications const clearNotifications = useCallback(() => { setNotifications([]) }, []) // Register document event callback const onDocumentEvent = useCallback((callback: (event: DocumentEvent) => void) => { documentEventCallbacksRef.current.add(callback) return () => { documentEventCallbacksRef.current.delete(callback) } }, []) // Connect on auth change useEffect(() => { // Wait for auth to finish loading if (isAuthLoading) { return } if (isAuthenticated) { connect() } else { // Disconnect on logout if (wsRef.current) { wsRef.current.close() wsRef.current = null } setIsConnected(false) setSubscriptions(new Set()) } return () => { if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current) } } }, [isAuthenticated, isAuthLoading, connect]) // Cleanup on unmount useEffect(() => { return () => { if (wsRef.current) { wsRef.current.close() } if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current) } } }, []) const value: WebSocketContextType = { isConnected, notifications, unreadCount, subscribe, unsubscribe, markAsRead, markAllAsRead, clearNotifications, onDocumentEvent, } return {children} } export function useWebSocket() { const context = useContext(WebSocketContext) if (context === undefined) { throw new Error('useWebSocket must be used within a WebSocketProvider') } return context }