Files
PROJECT-CONTORL/frontend/src/contexts/NotificationContext.tsx
beabigegg 55f85d0d3c feat: implement soft delete, task editing fixes, and UI improvements
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>
2026-01-10 01:32:13 +08:00

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
}