feat: implement collaboration module
- Backend (FastAPI): - Task comments with nested replies and soft delete - @mention parsing with 10-mention limit per comment - Notification system with read/unread tracking - Blocker management with project owner notification - WebSocket endpoint with JWT auth and keepalive - User search API for @mention autocomplete - Alembic migration for 4 new tables - Frontend (React + Vite): - Comments component with @mention autocomplete - NotificationBell with real-time WebSocket updates - BlockerDialog for task blocking workflow - NotificationContext for state management - OpenSpec: - 4 requirements with scenarios defined - add-collaboration change archived 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
191
frontend/src/contexts/NotificationContext.tsx
Normal file
191
frontend/src/contexts/NotificationContext.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
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<NodeJS.Timeout | null>(null)
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
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
|
||||
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const 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 '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 '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
|
||||
}
|
||||
|
||||
// Attempt to reconnect after delay
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
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(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
refreshUnreadCount()
|
||||
connectWebSocket()
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup on unmount
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
}
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current)
|
||||
}
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [refreshUnreadCount, 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
|
||||
}
|
||||
Reference in New Issue
Block a user