Backend: - Add soft delete for spaces and projects (is_active flag) - Add status_id and assignee_id to TaskUpdate schema - Fix task PATCH endpoint to update status and assignee - Add validation for assignee_id and status_id in task updates - Fix health service to count tasks with "Blocked" status as blockers - Filter out deleted spaces/projects from health dashboard - Add workload cache invalidation on assignee changes Frontend: - Add delete confirmation dialogs for spaces and projects - Fix UserSelect to display selected user name (valueName prop) - Fix task detail modal to refresh data after save - Enforce 2-level subtask depth limit in UI - Fix timezone bug in date formatting (use local timezone) - Convert NotificationBell from Tailwind to inline styles - Add i18n translations for health, workload, settings pages - Add parent_task_id to Task interface across components OpenSpec: - Archive add-delete-capability change 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
251 lines
7.8 KiB
TypeScript
251 lines
7.8 KiB
TypeScript
import { createContext, useContext, useState, useEffect, useCallback, ReactNode, useRef } from 'react'
|
|
import { notificationsApi, Notification, NotificationListResponse } from '../services/collaboration'
|
|
|
|
interface NotificationContextType {
|
|
notifications: Notification[]
|
|
unreadCount: number
|
|
loading: boolean
|
|
error: string | null
|
|
fetchNotifications: (isRead?: boolean) => Promise<void>
|
|
markAsRead: (notificationId: string) => Promise<void>
|
|
markAllAsRead: () => Promise<void>
|
|
refreshUnreadCount: () => Promise<void>
|
|
}
|
|
|
|
const NotificationContext = createContext<NotificationContextType | undefined>(undefined)
|
|
|
|
const WS_RECONNECT_DELAY = 3000
|
|
const WS_PING_INTERVAL = 30000
|
|
|
|
export function NotificationProvider({ children }: { children: ReactNode }) {
|
|
const [notifications, setNotifications] = useState<Notification[]>([])
|
|
const [unreadCount, setUnreadCount] = useState(0)
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const wsRef = useRef<WebSocket | null>(null)
|
|
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const isMountedRef = useRef(true)
|
|
|
|
const refreshUnreadCount = useCallback(async () => {
|
|
try {
|
|
const count = await notificationsApi.getUnreadCount()
|
|
setUnreadCount(count)
|
|
} catch (err) {
|
|
console.error('Failed to fetch unread count:', err)
|
|
}
|
|
}, [])
|
|
|
|
const fetchNotifications = useCallback(async (isRead?: boolean) => {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const response: NotificationListResponse = await notificationsApi.list(isRead)
|
|
setNotifications(response.notifications)
|
|
setUnreadCount(response.unread_count)
|
|
} catch (err) {
|
|
setError('Failed to load notifications')
|
|
console.error('Failed to fetch notifications:', err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
const markAsRead = useCallback(async (notificationId: string) => {
|
|
try {
|
|
const updated = await notificationsApi.markAsRead(notificationId)
|
|
setNotifications(prev =>
|
|
prev.map(n => (n.id === notificationId ? updated : n))
|
|
)
|
|
setUnreadCount(prev => Math.max(0, prev - 1))
|
|
} catch (err) {
|
|
console.error('Failed to mark as read:', err)
|
|
}
|
|
}, [])
|
|
|
|
const markAllAsRead = useCallback(async () => {
|
|
try {
|
|
await notificationsApi.markAllAsRead()
|
|
setNotifications(prev =>
|
|
prev.map(n => ({ ...n, is_read: true, read_at: new Date().toISOString() }))
|
|
)
|
|
setUnreadCount(0)
|
|
} catch (err) {
|
|
console.error('Failed to mark all as read:', err)
|
|
}
|
|
}, [])
|
|
|
|
// WebSocket connection
|
|
const connectWebSocket = useCallback(() => {
|
|
const token = localStorage.getItem('token')
|
|
if (!token) return
|
|
|
|
// Use env var if available, otherwise derive from current location
|
|
let wsUrl: string
|
|
const envWsUrl = import.meta.env.VITE_WS_URL
|
|
if (envWsUrl) {
|
|
wsUrl = `${envWsUrl}/ws/notifications?token=${token}`
|
|
} else {
|
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
wsUrl = `${wsProtocol}//${window.location.host}/ws/notifications?token=${token}`
|
|
}
|
|
|
|
try {
|
|
const ws = new WebSocket(wsUrl)
|
|
wsRef.current = ws
|
|
|
|
ws.onopen = () => {
|
|
console.log('WebSocket connected')
|
|
// Start ping interval
|
|
pingIntervalRef.current = setInterval(() => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'ping' }))
|
|
}
|
|
}, WS_PING_INTERVAL)
|
|
}
|
|
|
|
ws.onmessage = (event) => {
|
|
try {
|
|
const message = JSON.parse(event.data)
|
|
|
|
switch (message.type) {
|
|
case 'connected':
|
|
console.log('WebSocket authenticated:', message.data.message)
|
|
break
|
|
|
|
case 'unread_sync':
|
|
// Merge unread notifications without removing already-loaded notifications
|
|
setNotifications(prev => {
|
|
const unreadNotifications = message.data.notifications || []
|
|
const existingIds = new Set(prev.map(n => n.id))
|
|
|
|
// Add new unread notifications that don't exist in current list
|
|
const newNotifications = unreadNotifications.filter(
|
|
(n: Notification) => !existingIds.has(n.id)
|
|
)
|
|
|
|
// Update existing unread notifications and prepend new ones
|
|
const updated = prev.map(existing => {
|
|
const fromSync = unreadNotifications.find((n: Notification) => n.id === existing.id)
|
|
return fromSync || existing
|
|
})
|
|
|
|
return [...newNotifications, ...updated]
|
|
})
|
|
setUnreadCount(message.data.unread_count || 0)
|
|
break
|
|
|
|
case 'notification':
|
|
// Add new notification to the top
|
|
setNotifications(prev => [message.data, ...prev])
|
|
setUnreadCount(prev => prev + 1)
|
|
break
|
|
|
|
case 'unread_count':
|
|
setUnreadCount(message.data.unread_count)
|
|
break
|
|
|
|
case 'ping':
|
|
// Server ping - respond with pong
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'pong' }))
|
|
}
|
|
break
|
|
|
|
case 'pong':
|
|
// Pong received, connection is alive
|
|
break
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to parse WebSocket message:', err)
|
|
}
|
|
}
|
|
|
|
ws.onclose = () => {
|
|
console.log('WebSocket disconnected')
|
|
// Clear ping interval
|
|
if (pingIntervalRef.current) {
|
|
clearInterval(pingIntervalRef.current)
|
|
pingIntervalRef.current = null
|
|
}
|
|
|
|
// Only attempt to reconnect if component is still mounted
|
|
if (isMountedRef.current) {
|
|
reconnectTimeoutRef.current = setTimeout(() => {
|
|
if (isMountedRef.current) {
|
|
connectWebSocket()
|
|
}
|
|
}, WS_RECONNECT_DELAY)
|
|
}
|
|
}
|
|
|
|
ws.onerror = (err) => {
|
|
console.error('WebSocket error:', err)
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to create WebSocket:', err)
|
|
}
|
|
}, [])
|
|
|
|
// Initial fetch and WebSocket connection
|
|
useEffect(() => {
|
|
isMountedRef.current = true
|
|
const token = localStorage.getItem('token')
|
|
if (token) {
|
|
// Fetch both unread count and initial notifications
|
|
refreshUnreadCount()
|
|
fetchNotifications()
|
|
// Delay WebSocket connection to avoid StrictMode race condition
|
|
const connectTimeout = setTimeout(() => {
|
|
if (isMountedRef.current) {
|
|
connectWebSocket()
|
|
}
|
|
}, 100)
|
|
|
|
return () => {
|
|
clearTimeout(connectTimeout)
|
|
isMountedRef.current = false
|
|
// Cleanup on unmount
|
|
if (wsRef.current) {
|
|
wsRef.current.close()
|
|
}
|
|
if (pingIntervalRef.current) {
|
|
clearInterval(pingIntervalRef.current)
|
|
}
|
|
if (reconnectTimeoutRef.current) {
|
|
clearTimeout(reconnectTimeoutRef.current)
|
|
}
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
isMountedRef.current = false
|
|
}
|
|
}, [refreshUnreadCount, fetchNotifications, connectWebSocket])
|
|
|
|
return (
|
|
<NotificationContext.Provider
|
|
value={{
|
|
notifications,
|
|
unreadCount,
|
|
loading,
|
|
error,
|
|
fetchNotifications,
|
|
markAsRead,
|
|
markAllAsRead,
|
|
refreshUnreadCount,
|
|
}}
|
|
>
|
|
{children}
|
|
</NotificationContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function useNotifications() {
|
|
const context = useContext(NotificationContext)
|
|
if (context === undefined) {
|
|
throw new Error('useNotifications must be used within a NotificationProvider')
|
|
}
|
|
return context
|
|
}
|