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:
244
src/components/RealTimeSettings.jsx
Normal file
244
src/components/RealTimeSettings.jsx
Normal 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
|
||||
106
src/components/SSEConnectionStatus.jsx
Normal file
106
src/components/SSEConnectionStatus.jsx
Normal 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
|
||||
149
src/components/SSESettings.jsx
Normal file
149
src/components/SSESettings.jsx
Normal 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
|
||||
@@ -91,7 +91,7 @@ const WebSocketSettings = () => {
|
||||
</FormHelperText>
|
||||
</Box>
|
||||
<Switch
|
||||
checked={isWebSocketEnabled() && isPlusAccount(userProfile)}
|
||||
checked={Boolean(isWebSocketEnabled() && isPlusAccount(userProfile))}
|
||||
onChange={handleToggle}
|
||||
disabled={!isPlusAccount(userProfile)}
|
||||
color={
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
25
src/contexts/SSEContext.jsx
Normal file
25
src/contexts/SSEContext.jsx
Normal 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
455
src/hooks/useSSE.js
Normal 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'
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
7
src/hooks/useSSEContext.js
Normal file
7
src/hooks/useSSEContext.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { useContext } from 'react'
|
||||
import { SSEContext } from '../contexts/SSEContext'
|
||||
|
||||
export const useSSEContext = () => {
|
||||
console.log('=== useSSEContext called ===')
|
||||
return useContext(SSEContext)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'>
|
||||
|
||||
Reference in New Issue
Block a user