Websocket and SSE in working state :

Add Server-Sent Events (SSE) support with connection status and settings components

- Implemented SSEConnectionStatus component to display real-time connection status.
- Created SSESettings component for enabling/disabling SSE based on user profile.
- Introduced SSEContext and useSSE hook for managing SSE connections and state.
- Added auto-connect functionality for SSE based on user profile and token validity.
- Updated WebSocketSettings to ensure proper handling of WebSocket state.
- Enhanced MyChores view with bulk archive functionality and improved UI feedback.
- Refactored NotificationSetting to ensure boolean values are set correctly.
- Updated Settings view to include RealTimeSettings component for SSE configuration.
This commit is contained in:
Mo Tarbin
2025-06-17 01:07:14 -04:00
parent 4098f2e498
commit 6cb6b1534d
13 changed files with 1137 additions and 48 deletions

View File

@@ -0,0 +1,244 @@
import { Sync, SyncDisabled } from '@mui/icons-material'
import {
Box,
Card,
Chip,
FormControl,
FormHelperText,
FormLabel,
Option,
Select,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import { useWebSocketContext } from '../contexts/WebSocketContext'
import { useSSEContext } from '../hooks/useSSEContext'
import { useUserProfile } from '../queries/UserQueries'
import { isPlusAccount } from '../utils/Helpers'
import SSEConnectionStatus from './SSEConnectionStatus'
import WebSocketConnectionStatus from './WebSocketConnectionStatus'
const REALTIME_TYPES = {
DISABLED: 'disabled',
WEBSOCKET: 'websocket',
SSE: 'sse',
}
const RealTimeSettings = () => {
const { data: userProfile } = useUserProfile()
// WebSocket context
const webSocketContext = useWebSocketContext()
// SSE context
const sseContext = useSSEContext()
// Get current realtime type from localStorage
const getCurrentRealtimeType = () => {
const wsEnabled = localStorage.getItem('websocket_enabled') !== 'false'
const sseEnabled = localStorage.getItem('sse_enabled') === 'true'
if (sseEnabled) return REALTIME_TYPES.SSE
if (wsEnabled) return REALTIME_TYPES.WEBSOCKET
return REALTIME_TYPES.DISABLED
}
const [realtimeType, setRealtimeType] = useState(getCurrentRealtimeType())
const handleRealtimeTypeChange = (event, newValue) => {
if (!isPlusAccount(userProfile)) {
return // Don't allow changes for non-Plus users
}
setRealtimeType(newValue)
// Update localStorage and toggle connections
switch (newValue) {
case REALTIME_TYPES.DISABLED:
localStorage.setItem('websocket_enabled', 'false')
localStorage.setItem('sse_enabled', 'false')
webSocketContext.disconnect()
sseContext.disconnect()
break
case REALTIME_TYPES.WEBSOCKET:
localStorage.setItem('websocket_enabled', 'true')
localStorage.setItem('sse_enabled', 'false')
sseContext.disconnect()
webSocketContext.connect()
break
case REALTIME_TYPES.SSE:
localStorage.setItem('websocket_enabled', 'false')
localStorage.setItem('sse_enabled', 'true')
webSocketContext.disconnect()
sseContext.connect()
break
}
}
const getCurrentContext = () => {
switch (realtimeType) {
case REALTIME_TYPES.WEBSOCKET:
return webSocketContext
case REALTIME_TYPES.SSE:
return sseContext
default:
return {
isConnected: false,
isConnecting: false,
error: null,
getConnectionStatus: () => 'disabled',
}
}
}
const context = getCurrentContext()
const getStatusDescription = () => {
if (!isPlusAccount(userProfile)) {
return 'Real-time updates are not available in the Basic plan. Upgrade to Plus to receive instant notifications when chores are updated.'
}
if (realtimeType === REALTIME_TYPES.DISABLED) {
return 'Real-time updates are disabled. Enable WebSocket or SSE to see live changes when you or other circle members complete, skip, or modify chores.'
}
const typeLabel =
realtimeType === REALTIME_TYPES.WEBSOCKET ? 'WebSocket' : 'SSE'
if (context.isConnected) {
return `Real-time updates (${typeLabel}) are working. You'll see live changes when you or other circle members complete, skip, or modify chores.`
}
if (context.isConnecting) {
return `Connecting to real-time updates (${typeLabel})...`
}
if (context.error) {
return `Real-time updates (${typeLabel}) are enabled but not working: ${context.error}`
}
return `Real-time updates (${typeLabel}) are enabled but not currently connected.`
}
const getConnectionStatusComponent = () => {
switch (realtimeType) {
case REALTIME_TYPES.WEBSOCKET:
return <WebSocketConnectionStatus variant='chip' />
case REALTIME_TYPES.SSE:
return <SSEConnectionStatus variant='chip' />
default:
return null
}
}
return (
<Card sx={{ mt: 2, p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
{realtimeType !== REALTIME_TYPES.DISABLED &&
isPlusAccount(userProfile) ? (
<Sync color={context.isConnected ? 'success' : 'disabled'} />
) : (
<SyncDisabled color='disabled' />
)}
<Box sx={{ flex: 1 }}>
<Typography level='title-md'>
Real-time Updates
{!isPlusAccount(userProfile) && (
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
Plus Feature
</Chip>
)}
</Typography>
<Typography level='body-sm' color='neutral'>
Get instant notifications when chores are updated
</Typography>
</Box>
{realtimeType !== REALTIME_TYPES.DISABLED &&
isPlusAccount(userProfile) &&
getConnectionStatusComponent()}
</Box>
<FormControl orientation='horizontal' sx={{ mb: 2 }}>
<Box sx={{ flex: 1 }}>
<FormLabel>Real-time Connection Type</FormLabel>
<FormHelperText sx={{ mt: 0 }}>
Choose how to receive real-time updates
</FormHelperText>
</Box>
<Select
value={realtimeType}
onChange={handleRealtimeTypeChange}
disabled={!isPlusAccount(userProfile)}
sx={{ minWidth: 140 }}
>
<Option value={REALTIME_TYPES.DISABLED}>Disabled</Option>
<Option value={REALTIME_TYPES.WEBSOCKET}>WebSocket</Option>
<Option value={REALTIME_TYPES.SSE}>SSE</Option>
</Select>
</FormControl>
<FormHelperText sx={{ mb: 2 }}>{getStatusDescription()}</FormHelperText>
{realtimeType !== REALTIME_TYPES.DISABLED &&
isPlusAccount(userProfile) && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
<Typography level='body-xs' color='neutral'>
Status:
</Typography>
<Chip
size='sm'
variant='soft'
color={
context.isConnected
? 'success'
: context.isConnecting
? 'warning'
: 'danger'
}
>
{context.getConnectionStatus()}
</Chip>
{context.error && (
<Typography level='body-xs' color='danger'>
{context.error}
</Typography>
)}
</Box>
)}
{!isPlusAccount(userProfile) && (
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
Real-time updates are not available in the Basic plan. Upgrade to Plus
to receive instant notifications when you or other circle members
complete, skip, or modify chores.
</Typography>
)}
{realtimeType !== REALTIME_TYPES.DISABLED &&
isPlusAccount(userProfile) && (
<Box
sx={{
mt: 2,
p: 2,
bgcolor: 'background.level1',
borderRadius: 'sm',
}}
>
<Typography level='body-sm' sx={{ fontWeight: 'bold', mb: 1 }}>
Connection Types:
</Typography>
<Typography level='body-xs' sx={{ mb: 1 }}>
<strong>WebSocket:</strong> Traditional bi-directional real-time
connection
</Typography>
<Typography level='body-xs'>
<strong>SSE:</strong> Server-Sent Events - lighter weight,
one-way updates from server
</Typography>
</Box>
)}
</Card>
)
}
export default RealTimeSettings

View File

@@ -0,0 +1,106 @@
import { Circle, SignalWifi4Bar, SignalWifiOff } from '@mui/icons-material'
import { Box, Chip, Tooltip, Typography } from '@mui/joy'
import { useSSEContext } from '../hooks/useSSEContext'
const SSEConnectionStatus = ({
variant = 'minimal',
showError = false,
sx = {},
}) => {
const { isConnected, isConnecting, error, getConnectionStatus } =
useSSEContext()
const getStatusColor = () => {
if (isConnected) return 'success'
if (isConnecting) return 'warning'
return 'danger'
}
const getStatusIcon = () => {
if (isConnected) return <SignalWifi4Bar />
if (isConnecting) return <Circle />
return <SignalWifiOff />
}
const getStatusText = () => {
if (isConnected) return 'Connected'
if (isConnecting) return 'Connecting...'
return 'Disconnected'
}
const getTooltipText = () => {
const status = getConnectionStatus()
if (error) return `Real-time updates (SSE): ${status} - ${error}`
if (!isConnected && !isConnecting) {
return `Real-time updates (SSE): ${status} - Join a circle to enable real-time updates`
}
return `Real-time updates (SSE): ${status}`
}
if (variant === 'minimal') {
return (
<Tooltip title={getTooltipText()} size='sm'>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
...sx,
}}
>
<Circle
sx={{
fontSize: 8,
color:
getStatusColor() === 'success'
? 'success.main'
: getStatusColor() === 'warning'
? 'warning.main'
: 'danger.main',
}}
/>
{showError && error && (
<Typography level='body-xs' color='danger'>
{error}
</Typography>
)}
</Box>
</Tooltip>
)
}
if (variant === 'chip') {
return (
<Tooltip title={getTooltipText()} size='sm'>
<Chip
color={getStatusColor()}
size='sm'
variant='soft'
startDecorator={getStatusIcon()}
sx={sx}
>
{getStatusText()}
</Chip>
</Tooltip>
)
}
// Full variant
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, ...sx }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{getStatusIcon()}
<Typography level='body-sm' color={getStatusColor()}>
{getStatusText()}
</Typography>
</Box>
{showError && error && (
<Typography level='body-xs' color='danger'>
{error}
</Typography>
)}
</Box>
)
}
export default SSEConnectionStatus

View File

@@ -0,0 +1,149 @@
import { Sync, SyncDisabled } from '@mui/icons-material'
import {
Box,
Card,
Chip,
FormControl,
FormHelperText,
FormLabel,
Switch,
Typography,
} from '@mui/joy'
import { useSSEContext } from '../hooks/useSSEContext'
import { useUserProfile } from '../queries/UserQueries'
import { isPlusAccount } from '../utils/Helpers'
import SSEConnectionStatus from './SSEConnectionStatus'
const SSESettings = () => {
const { data: userProfile } = useUserProfile()
const {
isConnected,
isConnecting,
error,
getConnectionStatus,
toggleSSEEnabled,
isSSEEnabled,
} = useSSEContext()
const handleToggle = () => {
console.log('=== TOGGLE CLICKED ===')
if (!isPlusAccount(userProfile)) {
console.log('Not a Plus account, returning early')
return // Don't allow toggle for non-Plus users
}
const currentlyEnabled = isSSEEnabled()
console.log('SSE Settings - Toggle clicked:', {
currentlyEnabled,
newState: !currentlyEnabled,
userProfile,
isPlusAccount: isPlusAccount(userProfile),
})
toggleSSEEnabled(!currentlyEnabled)
}
const getStatusDescription = () => {
if (!isPlusAccount(userProfile)) {
return 'Real-time updates (SSE) are not available in the Basic plan. Upgrade to Plus to receive instant notifications when chores are updated.'
}
if (!isSSEEnabled()) {
return 'Real-time updates (SSE) are disabled. Enable to see live changes when you or other circle members complete, skip, or modify chores.'
}
if (isConnected) {
return "Real-time updates (SSE) are working. You'll see live changes when you or other circle members complete, skip, or modify chores."
}
if (isConnecting) {
return 'Connecting to real-time updates (SSE)...'
}
if (error) {
return `Real-time updates (SSE) are enabled but not working: ${error}`
}
return 'Real-time updates (SSE) are enabled but not currently connected.'
}
return (
<Card sx={{ mt: 2, p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
{isSSEEnabled() && isPlusAccount(userProfile) ? (
<Sync color={isConnected ? 'success' : 'disabled'} />
) : (
<SyncDisabled color='disabled' />
)}
<Box sx={{ flex: 1 }}>
<Typography level='title-md'>
Real-time Updates (SSE)
{!isPlusAccount(userProfile) && (
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
Plus Feature
</Chip>
)}
</Typography>
<Typography level='body-sm' color='neutral'>
Get instant notifications via Server-Sent Events
</Typography>
</Box>
{isSSEEnabled() && isPlusAccount(userProfile) && (
<SSEConnectionStatus variant='chip' />
)}
</Box>
<FormControl orientation='horizontal' sx={{ mb: 2 }}>
<Box sx={{ flex: 1 }}>
<FormLabel>Enable Real-time Updates (SSE)</FormLabel>
<FormHelperText sx={{ mt: 0 }}>
{getStatusDescription()}
</FormHelperText>
</Box>
<Switch
checked={isSSEEnabled() && isPlusAccount(userProfile)}
onChange={handleToggle}
disabled={!isPlusAccount(userProfile)}
color={
isSSEEnabled() && isPlusAccount(userProfile) ? 'success' : 'neutral'
}
variant='solid'
endDecorator={
isSSEEnabled() && isPlusAccount(userProfile) ? 'On' : 'Off'
}
slotProps={{ endDecorator: { sx: { minWidth: 24 } } }}
/>
</FormControl>
{isSSEEnabled() && isPlusAccount(userProfile) && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
<Typography level='body-xs' color='neutral'>
Status:
</Typography>
<Chip
size='sm'
variant='soft'
color={
isConnected ? 'success' : isConnecting ? 'warning' : 'danger'
}
>
{getConnectionStatus()}
</Chip>
{error && (
<Typography level='body-xs' color='danger'>
{error}
</Typography>
)}
</Box>
)}
{!isPlusAccount(userProfile) && (
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
Real-time updates (SSE) are not available in the Basic plan. Upgrade
to Plus to receive instant notifications when you or other circle
members complete, skip, or modify chores.
</Typography>
)}
</Card>
)
}
export default SSESettings

View File

@@ -91,7 +91,7 @@ const WebSocketSettings = () => {
</FormHelperText>
</Box>
<Switch
checked={isWebSocketEnabled() && isPlusAccount(userProfile)}
checked={Boolean(isWebSocketEnabled() && isPlusAccount(userProfile))}
onChange={handleToggle}
disabled={!isPlusAccount(userProfile)}
color={

View File

@@ -1,5 +1,6 @@
import QueryContext from './QueryContext'
import RouterContext from './RouterContext'
import SSEProvider from './SSEContext'
import ThemeContext from './ThemeContext'
import WebSocketProvider from './WebSocketContext'
@@ -7,6 +8,7 @@ const Contexts = () => {
const contexts = [
ThemeContext,
QueryContext,
SSEProvider,
WebSocketProvider,
RouterContext,
]

View File

@@ -0,0 +1,25 @@
import { createContext, useContext } from 'react'
import { useSSE } from '../hooks/useSSE'
export const SSEContext = createContext({
connectionState: 2, // CLOSED
isConnected: false,
isConnecting: false,
lastEvent: null,
error: null,
connect: () => {},
disconnect: () => {},
getConnectionStatus: () => 'disconnected',
})
export const useSSEContext = () => {
return useContext(SSEContext)
}
export const SSEProvider = ({ children }) => {
const sseState = useSSE()
return <SSEContext.Provider value={sseState}>{children}</SSEContext.Provider>
}
export default SSEProvider

455
src/hooks/useSSE.js Normal file
View File

@@ -0,0 +1,455 @@
import { useQueryClient } from '@tanstack/react-query'
import { EventSourcePolyfill } from 'event-source-polyfill'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useUserProfile } from '../queries/UserQueries'
import { isPlusAccount } from '../utils/Helpers'
import { apiManager, isTokenValid } from '../utils/TokenManager'
const SSE_STATES = {
CONNECTING: 0,
OPEN: 1,
CLOSED: 2,
}
const RECONNECT_INTERVALS = [1000, 2000, 5000, 10000, 30000] // Progressive backoff
const MAX_RECONNECT_ATTEMPTS = 10 // Circuit breaker limit
const CIRCUIT_BREAKER_RESET_TIME = 300000 // 5 minutes
export const useSSE = () => {
const [connectionState, setConnectionState] = useState(SSE_STATES.CLOSED)
const [lastEvent, setLastEvent] = useState(null)
const [error, setError] = useState(null)
const [isCircuitBreakerOpen, setIsCircuitBreakerOpen] = useState(false)
const eventSourceRef = useRef(null)
const reconnectTimeoutRef = useRef(null)
const reconnectAttemptsRef = useRef(0)
const isManuallyClosedRef = useRef(false)
const lastHeartbeatRef = useRef(Date.now())
const heartbeatMonitorRef = useRef(null)
const queryClient = useQueryClient()
const { data: userProfile } = useUserProfile()
const getSSEUrl = useCallback(() => {
if (!userProfile?.circleID) {
console.log(
'SSE: User not part of any circle - real-time features unavailable',
)
return null
}
const token = localStorage.getItem('ca_token')
if (!token || !isTokenValid()) {
console.log('SSE: No valid authentication token')
return null
}
// Get the API URL from apiManager
const apiUrl = apiManager.getApiURL() // e.g., "http://localhost:8080/api/v1"
// Build SSE URL
const sseUrl = `${apiUrl}/realtime/sse?circleId=${userProfile.circleID}`
console.log('SSE: Generated URL:', sseUrl)
return { url: sseUrl, token }
}, [userProfile])
const handleSSEMessage = useCallback(
event => {
try {
const eventData = JSON.parse(event.data)
setLastEvent(eventData)
console.log('SSE event received:', eventData.type, eventData)
// Update heartbeat timestamp
if (eventData.type === 'heartbeat') {
lastHeartbeatRef.current = Date.now()
}
// Handle different event types and update React Query cache accordingly
switch (eventData.type) {
case 'chore.created':
case 'chore.updated':
case 'chore.completed':
case 'chore.skipped':
queryClient.invalidateQueries(['choresHistory', 7])
queryClient.invalidateQueries(['chores'])
// If it's a specific chore event, also invalidate that chore's details
if (eventData.data.chore?.id) {
queryClient.invalidateQueries(['chore', eventData.data.chore.id])
queryClient.invalidateQueries([
'choreDetails',
eventData.data.chore.id,
])
}
break
case 'chore.deleted':
// Invalidate chores queries to refetch data
queryClient.invalidateQueries(['chores'])
// If it's a specific chore event, also invalidate that chore's details
if (eventData.data.chore?.id) {
queryClient.invalidateQueries(['chore', eventData.data.chore.id])
queryClient.invalidateQueries([
'choreDetails',
eventData.data.chore.id,
])
}
break
case 'subtask.updated':
case 'subtask.completed':
// Invalidate the specific chore that contains this subtask
if (eventData.data.choreId) {
queryClient.invalidateQueries(['chore', eventData.data.choreId])
queryClient.invalidateQueries([
'choreDetails',
eventData.data.choreId,
])
}
// Also invalidate general chores list
queryClient.invalidateQueries(['chores'])
break
case 'heartbeat':
// Heartbeat events don't need cache invalidation
console.debug('SSE Heartbeat received')
break
case 'connection.established':
console.log('SSE connection established')
setError(null)
lastHeartbeatRef.current = Date.now()
break
case 'error':
console.error('SSE error event:', eventData.data)
setError(eventData.data.message || 'SSE error occurred')
break
default:
console.log('Unknown SSE event type:', eventData.type)
}
} catch (err) {
console.error('Failed to parse SSE message:', err)
setError('Failed to parse server message')
return // Stop processing if JSON parsing fails
}
},
[queryClient],
)
const stopHeartbeatMonitor = useCallback(() => {
if (heartbeatMonitorRef.current) {
clearInterval(heartbeatMonitorRef.current)
heartbeatMonitorRef.current = null
}
}, [])
// Create connect function that can be called from anywhere
const connect = useCallback(() => {
console.log('SSE connect called')
console.log('SSE current state:', eventSourceRef.current?.readyState)
// Circuit breaker: prevent infinite reconnection loops
if (isCircuitBreakerOpen) {
console.warn(
'SSE: Circuit breaker is open, preventing connection attempt',
)
setError(
'Connection blocked due to repeated failures. Please try again later.',
)
return
}
if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
console.error(
'SSE: Maximum reconnection attempts reached, opening circuit breaker',
)
setIsCircuitBreakerOpen(true)
setError(
'Maximum connection attempts reached. SSE disabled for 5 minutes.',
)
// Reset circuit breaker after timeout
setTimeout(() => {
console.log('SSE: Resetting circuit breaker')
setIsCircuitBreakerOpen(false)
reconnectAttemptsRef.current = 0
}, CIRCUIT_BREAKER_RESET_TIME)
return
}
// Prevent race conditions by checking if already connecting or connected
if (eventSourceRef.current?.readyState === SSE_STATES.OPEN) {
console.log('SSE: Already connected')
return // Already connected
}
if (eventSourceRef.current?.readyState === SSE_STATES.CONNECTING) {
console.log('SSE: Connection already in progress')
return // Already connecting
}
const sseConfig = getSSEUrl()
console.log('SSE connect - Config:', sseConfig)
if (!sseConfig) {
console.log('Cannot connect to SSE: missing URL, token, or user profile')
return
}
// Create connection logic inline to avoid circular dependency
try {
console.log('Connecting to SSE:', sseConfig.url)
setConnectionState(SSE_STATES.CONNECTING)
isManuallyClosedRef.current = false
// Option 1: Use EventSource polyfill with Authorization header (Recommended)
// This is the most secure and standard way to authenticate SSE connections
eventSourceRef.current = new EventSourcePolyfill(sseConfig.url, {
headers: {
Authorization: `Bearer ${sseConfig.token}`,
'Cache-Control': 'no-cache',
Accept: 'text/event-stream',
},
})
eventSourceRef.current.onopen = () => {
console.log('SSE connection opened')
setConnectionState(SSE_STATES.OPEN)
setError(null)
reconnectAttemptsRef.current = 0
lastHeartbeatRef.current = Date.now()
// Start heartbeat monitor
if (heartbeatMonitorRef.current) {
clearInterval(heartbeatMonitorRef.current)
}
heartbeatMonitorRef.current = setInterval(() => {
const timeSinceLastHeartbeat = Date.now() - lastHeartbeatRef.current
const heartbeatTimeout = 90000 // 90 seconds
if (timeSinceLastHeartbeat > heartbeatTimeout) {
console.warn(
'SSE: No heartbeat received, connection may be stale. Reconnecting...',
)
if (!isManuallyClosedRef.current) {
// Clear current heartbeat monitor before reconnecting
stopHeartbeatMonitor()
// Schedule reconnect
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
const attemptIndex = Math.min(
reconnectAttemptsRef.current,
RECONNECT_INTERVALS.length - 1,
)
const delay = RECONNECT_INTERVALS[attemptIndex]
console.log(
`Scheduling SSE reconnect in ${delay}ms (attempt ${
reconnectAttemptsRef.current + 1
})`,
)
reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttemptsRef.current++
connect()
}, delay)
}
}
}, 30000) // Check every 30 seconds
}
eventSourceRef.current.onmessage = handleSSEMessage
eventSourceRef.current.onerror = error => {
console.error('SSE error:', error)
setConnectionState(SSE_STATES.CLOSED)
stopHeartbeatMonitor()
if (!isManuallyClosedRef.current) {
setError('Connection error occurred')
// Schedule reconnect
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
const attemptIndex = Math.min(
reconnectAttemptsRef.current,
RECONNECT_INTERVALS.length - 1,
)
const delay = RECONNECT_INTERVALS[attemptIndex]
console.log(
`Scheduling SSE reconnect in ${delay}ms (attempt ${
reconnectAttemptsRef.current + 1
})`,
)
reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttemptsRef.current++
connect()
}, delay)
}
}
} catch (err) {
console.error('Failed to create SSE connection:', err)
setError('Failed to establish connection')
setConnectionState(SSE_STATES.CLOSED)
}
}, [getSSEUrl, handleSSEMessage, stopHeartbeatMonitor])
const disconnect = useCallback(() => {
isManuallyClosedRef.current = true
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
reconnectTimeoutRef.current = null
}
stopHeartbeatMonitor()
if (eventSourceRef.current) {
eventSourceRef.current.close()
eventSourceRef.current = null
}
setConnectionState(SSE_STATES.CLOSED)
}, [stopHeartbeatMonitor])
const toggleSSEEnabled = useCallback(
enabled => {
console.log('SSE toggleSSEEnabled called:', {
enabled,
userProfile: userProfile?.circleID,
isTokenValid: isTokenValid(),
isPlusAccount: isPlusAccount(userProfile),
})
localStorage.setItem('sse_enabled', enabled.toString())
if (enabled && userProfile?.circleID && isTokenValid()) {
console.log('SSE toggleSSEEnabled: Calling connect()')
connect()
} else {
console.log('SSE toggleSSEEnabled: Calling disconnect()')
disconnect()
}
},
[connect, disconnect, userProfile],
)
const isSSEEnabled = useCallback(() => {
return localStorage.getItem('sse_enabled') === 'true'
}, [])
// Auto-connect when user profile is available and token is valid
useEffect(() => {
console.log('SSE auto-connect effect triggered')
console.log('UserProfile:', userProfile)
console.log('circleID:', userProfile?.circleID)
console.log('Token valid:', isTokenValid())
console.log('Is Plus account:', isPlusAccount(userProfile))
// Check if SSE is enabled in settings
const isSSEEnabledSetting = localStorage.getItem('sse_enabled') === 'true'
console.log('SSE enabled in settings:', isSSEEnabledSetting)
if (
userProfile?.circleID &&
isTokenValid() &&
isSSEEnabledSetting &&
isPlusAccount(userProfile)
) {
console.log('SSE: Conditions met, attempting to connect')
connect()
} else {
console.log('SSE: Conditions not met, disconnecting')
if (!isPlusAccount(userProfile)) {
console.log('SSE: Not a Plus account - feature unavailable')
}
disconnect()
}
// Cleanup on unmount
return () => {
disconnect()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userProfile?.circleID, userProfile?.expiration]) // Only depend on essential userProfile fields
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
stopHeartbeatMonitor()
}
}, [stopHeartbeatMonitor])
// Handle visibility changes for better performance
useEffect(() => {
const handleVisibilityChange = () => {
if (document.hidden) {
// App went to background, maintain connection but log the state
console.log(
'SSE: App backgrounded, maintaining connection but reducing activity',
)
} else {
// App came to foreground, ensure connection is active
console.log('SSE: App foregrounded, ensuring connection is active')
const isSSEEnabledSetting =
localStorage.getItem('sse_enabled') === 'true'
if (
userProfile?.circleID &&
isTokenValid() &&
isSSEEnabledSetting &&
isPlusAccount(userProfile) &&
connectionState !== SSE_STATES.OPEN
) {
connect()
}
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userProfile?.circleID, userProfile?.expiration, connectionState])
return {
connectionState,
isConnected: connectionState === SSE_STATES.OPEN,
isConnecting: connectionState === SSE_STATES.CONNECTING,
lastEvent,
error,
connect,
disconnect,
toggleSSEEnabled,
isSSEEnabled,
// Helper function to check connection status
getConnectionStatus: () => {
switch (connectionState) {
case SSE_STATES.CONNECTING:
return 'connecting'
case SSE_STATES.OPEN:
return 'connected'
case SSE_STATES.CLOSED:
default:
return 'disconnected'
}
},
}
}

View File

@@ -0,0 +1,7 @@
import { useContext } from 'react'
import { SSEContext } from '../contexts/SSEContext'
export const useSSEContext = () => {
console.log('=== useSSEContext called ===')
return useContext(SSEContext)
}

View File

@@ -42,25 +42,21 @@ export const useWebSocket = () => {
return null
}
// Get the API URL from apiManager and convert to WebSocket URL
const apiUrl = apiManager.getApiURL() // e.g., "http://localhost:8080/api/v1"
const apiUrl = apiManager.getApiURL()
// Convert HTTP/HTTPS to WebSocket protocol and remove /api/v1 suffix
let wsUrl = apiUrl.replace(/\/api\/v1$/, '') // Remove /api/v1 suffix
let wsUrl = apiUrl.replace(/\/api\/v1$/, '')
if (wsUrl.startsWith('http://')) {
wsUrl = wsUrl.replace('http://', 'ws://')
} else if (wsUrl.startsWith('https://')) {
wsUrl = wsUrl.replace('https://', 'wss://')
} else {
// If no protocol specified, use the current page's protocol
const isHttps = window.location.protocol === 'https:'
wsUrl = `${isHttps ? 'wss:' : 'ws:'}//${wsUrl}`
}
// Add the WebSocket endpoint path
wsUrl = `${wsUrl}/api/v1/realtime/ws?token=${token}&circleId=${userProfile.circleID}`
console.log('WebSocket: Generated URL:', wsUrl)
return wsUrl
}, [userProfile])
@@ -70,16 +66,14 @@ export const useWebSocket = () => {
const eventData = JSON.parse(event.data)
setLastEvent(eventData)
console.log('WebSocket event received:', eventData.type, eventData)
console.debug('WebSocket event received:', eventData.type, eventData)
// Handle different event types and update React Query cache accordingly
switch (eventData.type) {
case 'chore.created':
case 'chore.updated':
case 'chore.completed':
queryClient.invalidateQueries(['choresHistory', 7])
case 'chore.skipped':
queryClient.invalidateQueries(['choresHistory', 7])
case 'chore.deleted':
// Invalidate chores queries to refetch data
queryClient.invalidateQueries(['chores'])
@@ -92,6 +86,11 @@ export const useWebSocket = () => {
eventData.data.chore.id,
])
}
// expire the history so feed on dashboard gert updated :
// need to find a better way to do this as we don't need to do it with every single update for anything
// but not sure if i can do it with the fall-through switch case in javascript :)
queryClient.invalidateQueries(['choresHistory', 7])
break
case 'subtask.updated':
@@ -110,7 +109,7 @@ export const useWebSocket = () => {
case 'heartbeat':
// Heartbeat events don't need cache invalidation
console.debug('Heartbeat received')
console.debug('Heartbeat!')
break
case 'connection.established':
@@ -136,10 +135,7 @@ export const useWebSocket = () => {
const createWebSocketConnection = useCallback(
wsUrl => {
const token = localStorage.getItem('ca_token')
try {
console.log('Connecting to WebSocket:', wsUrl)
setConnectionState(WEBSOCKET_STATES.CONNECTING)
isManuallyClosedRef.current = false
@@ -147,7 +143,6 @@ export const useWebSocket = () => {
wsRef.current = new WebSocket(wsUrl)
wsRef.current.onopen = () => {
console.log('WebSocket connection opened')
setConnectionState(WEBSOCKET_STATES.OPEN)
setError(null)
reconnectAttemptsRef.current = 0
@@ -218,9 +213,6 @@ export const useWebSocket = () => {
}, [scheduleReconnect])
const connect = useCallback(() => {
console.log('WebSocket connect called')
console.log('WebSocket current state:', wsRef.current?.readyState)
if (wsRef.current?.readyState === WEBSOCKET_STATES.OPEN) {
console.log('WebSocket: Already connected')
return // Already connected
@@ -273,12 +265,6 @@ export const useWebSocket = () => {
// Auto-connect when user profile is available and token is valid
useEffect(() => {
console.log('WebSocket auto-connect effect triggered')
console.log('UserProfile:', userProfile)
console.log('circleID:', userProfile?.circleID)
console.log('Token valid:', isTokenValid())
console.log('Is Plus account:', isPlusAccount(userProfile))
// Check if WebSocket is enabled in settings
const isWebSocketEnabledSetting =
localStorage.getItem('websocket_enabled') !== 'false'

View File

@@ -405,12 +405,20 @@ const ChoreCard = ({
border: '1px solid',
borderColor: 'divider',
transition: 'all 0.2s ease-in-out',
cursor: isMultiSelectMode ? 'pointer' : 'default',
'&:hover': {
boxShadow: 'md',
borderColor: 'primary.300',
borderColor: isMultiSelectMode ? 'primary.500' : 'primary.300',
},
// Add padding when in multi-select mode to account for checkbox
pl: isMultiSelectMode ? 6 : 2,
// Visual feedback when selected
...(isMultiSelectMode &&
isSelected && {
borderColor: 'primary.500',
backgroundColor: 'primary.softBg',
boxShadow: 'sm',
}),
}}
>
{/* Multi-select checkbox */}
@@ -420,13 +428,12 @@ const ChoreCard = ({
onChange={onSelectionToggle}
sx={{
position: 'absolute',
top: 12,
top: '50%',
left: 12,
transform: 'translateY(-50%)',
zIndex: 2,
bgcolor: 'background.surface',
borderRadius: 'md',
boxShadow: 'sm',
border: '2px solid',
borderColor: 'divider',
'&:hover': {
bgcolor: 'background.level1',
@@ -448,8 +455,13 @@ const ChoreCard = ({
<Grid container>
<Grid
xs={9}
sx={{ cursor: 'pointer' }}
onClick={() => {
navigate(`/chores/${chore.id}`)
if (isMultiSelectMode) {
onSelectionToggle()
} else {
navigate(`/chores/${chore.id}`)
}
}}
>
{/* Box in top right with Chip showing next due date */}

View File

@@ -1,5 +1,6 @@
import {
Add,
Archive,
Bolt,
CancelRounded,
CheckBox,
@@ -40,7 +41,7 @@ import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useChores } from '../../queries/ChoreQueries'
import { useNotification } from '../../service/NotificationProvider'
import { GetArchivedChores } from '../../utils/Fetcher'
import { ArchiveChore, GetArchivedChores } from '../../utils/Fetcher'
import Priorities from '../../utils/Priorities'
import LoadingComponent from '../components/Loading'
import { useLabels } from '../Labels/LabelQueries'
@@ -637,7 +638,63 @@ const MyChores = () => {
},
})
}
const handleBulkArchive = async () => {
const selectedData = getSelectedChoresData()
if (selectedData.length === 0) return
setConfirmModelConfig({
isOpen: true,
title: 'Archive Tasks',
confirmText: 'Archive',
cancelText: 'Cancel',
message: `Archive ${selectedData.length} task${selectedData.length > 1 ? 's' : ''}?`,
onClose: async isConfirmed => {
if (isConfirmed === true) {
try {
const archivedTasks = []
const failedTasks = []
for (const chore of selectedData) {
try {
const archivedChore = await ArchiveChore(chore.id)
archivedTasks.push(archivedChore)
// Remove from chores and filteredChores
setChores(chores.filter(c => c.id !== chore.id))
setFilteredChores(filteredChores.filter(c => c.id !== chore.id))
} catch (error) {
failedTasks.push(chore)
}
}
if (archivedTasks.length > 0) {
showSuccess({
title: '📦 Tasks Archived',
message: `Successfully archived ${archivedTasks.length} task${archivedTasks.length > 1 ? 's' : ''}.`,
})
// Update archived chores state
setArchivedChores([
...(archivedChores || []),
...archivedTasks.map(c => ({
...c,
archived: true,
})),
])
}
if (failedTasks.length > 0) {
showError({
title: 'Some Tasks Failed',
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be archived.`,
})
}
clearSelection()
} catch (error) {
showError({
title: 'Bulk Archive Failed',
message: 'An unexpected error occurred. Please try again.',
})
}
}
setConfirmModelConfig({})
},
})
}
const handleBulkDelete = async () => {
const selectedData = getSelectedChoresData()
if (selectedData.length === 0) return
@@ -1056,17 +1113,22 @@ const MyChores = () => {
{isMultiSelectMode && (
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 1000,
backgroundColor: 'background.surface',
backdropFilter: 'blur(8px)',
borderRadius: 'lg',
p: 2,
mb: 2,
border: '1px solid',
borderColor: 'divider',
// boxShadow: 'sm',
boxShadow: 'm',
gap: 2,
display: 'flex',
flexDirection: {
xs: 'column', // Stack vertically on mobile
sm: 'row', // Horizontal on tablet and larger
sm: 'column', // Stack vertically on mobile
md: 'row', // Horizontal on tablet and larger
},
alignItems: {
xs: 'stretch', // Full width on mobile
@@ -1165,6 +1227,7 @@ const MyChores = () => {
},
}}
>
{/* Primary Actions - Safe operations */}
<Button
size='sm'
variant='solid'
@@ -1174,13 +1237,15 @@ const MyChores = () => {
disabled={selectedChores.size === 0}
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
fontWeight: 'md',
}}
>
Complete
</Button>
<Button
size='sm'
variant='soft'
variant='outlined'
color='warning'
onClick={handleBulkSkip}
startDecorator={<SkipNext />}
@@ -1191,19 +1256,53 @@ const MyChores = () => {
>
Skip
</Button>
{/* Visual separator for destructive actions */}
<Divider
orientation='vertical'
sx={{
height: '24px',
display: { xs: 'none', sm: 'block' },
mx: 0.5,
}}
/>
{/* Secondary Actions - Less destructive */}
<Button
size='sm'
variant='soft'
variant='outlined'
color='neutral'
onClick={handleBulkArchive}
startDecorator={<Archive />}
disabled={selectedChores.size === 0}
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
borderStyle: 'dashed',
}}
>
Archive
</Button>
{/* Most destructive action - visually distinct */}
<Button
size='sm'
variant='outlined'
color='danger'
onClick={handleBulkDelete}
startDecorator={<Delete />}
disabled={selectedChores.size === 0}
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
borderWidth: '2px',
'&:hover': {
borderWidth: '2px',
backgroundColor: 'danger.softBg',
},
}}
>
Delete
</Button>
{/*
<Divider
orientation='vertical'

View File

@@ -68,13 +68,17 @@ const NotificationSetting = () => {
useEffect(() => {
getNotificationPreferences().then(resp => {
setDeviceNotification(resp.granted)
setDueNotification(resp.dueNotification)
setPreDueNotification(resp.preDueNotification)
setNaggingNotification(resp.naggingNotification)
if (resp) {
setDeviceNotification(Boolean(resp.granted))
setDueNotification(Boolean(resp.dueNotification ?? true))
setPreDueNotification(Boolean(resp.preDueNotification))
setNaggingNotification(Boolean(resp.naggingNotification))
}
})
getPushNotificationPreferences().then(resp => {
setPushNotification(resp.granted)
if (resp) {
setPushNotification(Boolean(resp.granted))
}
})
}, [])
@@ -85,7 +89,7 @@ const NotificationSetting = () => {
)
const [chatID, setChatID] = useState(
userProfile?.notification_target?.target_id,
userProfile?.notification_target?.target_id ?? 0,
)
const [error, setError] = useState('')
const SaveValidation = () => {
@@ -317,7 +321,7 @@ const NotificationSetting = () => {
<FormControl orientation='horizontal'>
<Switch
checked={chatID !== 0}
checked={Boolean(chatID !== 0)}
onClick={event => {
event.preventDefault()
if (chatID !== 0) {

View File

@@ -10,13 +10,13 @@ import {
FormControl,
FormHelperText,
Input,
ListItem,
Option,
Select,
Typography,
} from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import RealTimeSettings from '../../components/RealTimeSettings'
import Logo from '../../Logo'
import { useUserProfile } from '../../queries/UserQueries'
import {
@@ -35,7 +35,6 @@ import {
} from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers'
import PassowrdChangeModal from '../Modals/Inputs/PasswordChangeModal'
import WebSocketSettings from '../../components/WebSocketSettings'
import APITokenSettings from './APITokenSettings'
import MFASettings from './MFASettings'
import NotificationSetting from './NotificationSetting'
@@ -279,7 +278,7 @@ const Settings = () => {
},
].map((option, index) => (
<Option value={option.value} key={index}>
<ListItem
<Box
sx={{
display: 'flex',
flexDirection: 'column',
@@ -302,7 +301,7 @@ const Settings = () => {
>
{option.description}
</Typography>
</ListItem>
</Box>
</Option>
))}
</Select>
@@ -496,7 +495,8 @@ const Settings = () => {
)}
{/* WebSocket Settings */}
<WebSocketSettings />
{/* <WebSocketSettings /> */}
<RealTimeSettings />
</div>
<div className='grid gap-4 py-4' id='account'>