refactor: Remove unused user profile references and improve SSE/WebSocket connection logic

This commit is contained in:
Mo Tarbin
2025-06-18 00:19:18 -04:00
parent 13b0533277
commit fb8c45a86d
8 changed files with 77 additions and 219 deletions

View File

@@ -1,46 +1,26 @@
import { Sync, SyncDisabled } from '@mui/icons-material'
import {
Box,
Card,
Chip,
FormControl,
FormHelperText,
FormLabel,
Option,
Select,
Typography,
} from '@mui/joy'
import { Box, Card, Chip, FormHelperText, Switch, 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
return sseEnabled ? REALTIME_TYPES.SSE : REALTIME_TYPES.DISABLED
}
const [realtimeType, setRealtimeType] = useState(getCurrentRealtimeType())
@@ -55,21 +35,11 @@ const RealTimeSettings = () => {
// 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
}
@@ -77,8 +47,6 @@ const RealTimeSettings = () => {
const getCurrentContext = () => {
switch (realtimeType) {
case REALTIME_TYPES.WEBSOCKET:
return webSocketContext
case REALTIME_TYPES.SSE:
return sseContext
default:
@@ -99,31 +67,26 @@ const RealTimeSettings = () => {
}
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.'
return 'Real-time updates are disabled. Enable them 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.`
return "Real-time updates 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})...`
return 'Connecting to real-time updates...'
}
if (context.error) {
return `Real-time updates (${typeLabel}) are enabled but not working: ${context.error}`
return `Real-time updates are enabled but not working: ${context.error}`
}
return `Real-time updates (${typeLabel}) are enabled but not currently connected.`
return 'Real-time updates 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:
@@ -133,7 +96,7 @@ const RealTimeSettings = () => {
return (
<Card sx={{ mt: 2, p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, mb: 2 }}>
{realtimeType !== REALTIME_TYPES.DISABLED &&
isPlusAccount(userProfile) ? (
<Sync color={context.isConnected ? 'success' : 'disabled'} />
@@ -141,24 +104,44 @@ const RealTimeSettings = () => {
<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>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 0.5,
}}
>
<Typography level='title-md'>
Real-time Updates
{!isPlusAccount(userProfile) && (
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
Plus Feature
</Chip>
)}
</Typography>
<Switch
checked={realtimeType !== REALTIME_TYPES.DISABLED}
onChange={e => {
handleRealtimeTypeChange(
null,
e.target.checked
? REALTIME_TYPES.SSE
: REALTIME_TYPES.DISABLED,
)
}}
disabled={!isPlusAccount(userProfile)}
inputProps={{ 'aria-label': 'Enable Real-time Updates' }}
/>
</Box>
<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 }}>
{/* <FormControl orientation='horizontal' sx={{ mb: 2 }}>
<Box sx={{ flex: 1 }}>
<FormLabel>Real-time Connection Type</FormLabel>
<FormHelperText sx={{ mt: 0 }}>
@@ -175,7 +158,7 @@ const RealTimeSettings = () => {
<Option value={REALTIME_TYPES.WEBSOCKET}>WebSocket</Option>
<Option value={REALTIME_TYPES.SSE}>SSE</Option>
</Select>
</FormControl>
</FormControl> */}
<FormHelperText sx={{ mb: 2 }}>{getStatusDescription()}</FormHelperText>
@@ -185,19 +168,7 @@ const RealTimeSettings = () => {
<Typography level='body-xs' color='neutral'>
Status:
</Typography>
<Chip
size='sm'
variant='soft'
color={
context.isConnected
? 'success'
: context.isConnecting
? 'warning'
: 'danger'
}
>
{context.getConnectionStatus()}
</Chip>
{getConnectionStatusComponent()}
{context.error && (
<Typography level='body-xs' color='danger'>
{context.error}
@@ -213,30 +184,6 @@ const RealTimeSettings = () => {
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>
)
}

View File

@@ -1,8 +1,6 @@
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 = {
@@ -29,16 +27,8 @@ export const useSSE = () => {
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')
@@ -48,12 +38,11 @@ export const useSSE = () => {
// 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}`
// Build SSE URL - let backend determine circle from authenticated user
const sseUrl = `${apiUrl}/realtime/sse`
console.log('SSE: Generated URL:', sseUrl)
return { url: sseUrl, token }
}, [userProfile])
}, [])
const handleSSEMessage = useCallback(
event => {
@@ -61,8 +50,6 @@ export const useSSE = () => {
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()
@@ -152,14 +139,8 @@ export const useSSE = () => {
// 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',
)
console.log('SSE: Circuit breaker is open, preventing connection attempt')
setError(
'Connection blocked due to repeated failures. Please try again later.',
)
@@ -209,8 +190,9 @@ export const useSSE = () => {
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
// here use EventSource polyfill with Authorization header as the native EventSource does not support headers
// the other option was to pass via query param which is less secure and also there.
// TODO: use cookie-based once/if at all i move from local storage to httpOnly cookies.
eventSourceRef.current = new EventSourcePolyfill(sseConfig.url, {
headers: {
Authorization: `Bearer ${sseConfig.token}`,
@@ -306,7 +288,7 @@ export const useSSE = () => {
setError('Failed to establish connection')
setConnectionState(SSE_STATES.CLOSED)
}
}, [getSSEUrl, handleSSEMessage, stopHeartbeatMonitor])
}, [getSSEUrl, handleSSEMessage, stopHeartbeatMonitor, isCircuitBreakerOpen])
const disconnect = useCallback(() => {
isManuallyClosedRef.current = true
@@ -330,12 +312,10 @@ export const useSSE = () => {
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()) {
if (enabled && isTokenValid()) {
console.log('SSE toggleSSEEnabled: Calling connect()')
connect()
} else {
@@ -343,38 +323,27 @@ export const useSSE = () => {
disconnect()
}
},
[connect, disconnect, userProfile],
[connect, disconnect],
)
const isSSEEnabled = useCallback(() => {
return localStorage.getItem('sse_enabled') === 'true'
}, [])
// Auto-connect when user profile is available and token is valid
// Auto-connect when SSE is enabled 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)
) {
if (isTokenValid() && isSSEEnabledSetting) {
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()
}
@@ -383,7 +352,7 @@ export const useSSE = () => {
disconnect()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userProfile?.circleID, userProfile?.expiration]) // Only depend on essential userProfile fields
}, []) // Only run once on mount
// Cleanup timeouts on unmount
useEffect(() => {
@@ -410,10 +379,8 @@ export const useSSE = () => {
const isSSEEnabledSetting =
localStorage.getItem('sse_enabled') === 'true'
if (
userProfile?.circleID &&
isTokenValid() &&
isSSEEnabledSetting &&
isPlusAccount(userProfile) &&
connectionState !== SSE_STATES.OPEN
) {
connect()
@@ -426,8 +393,7 @@ export const useSSE = () => {
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userProfile?.circleID, userProfile?.expiration, connectionState])
}, [connectionState, connect])
return {
connectionState,

View File

@@ -1,7 +1,5 @@
import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useUserProfile } from '../queries/UserQueries'
import { isPlusAccount } from '../utils/Helpers'
import { apiManager, isTokenValid } from '../utils/TokenManager'
const WEBSOCKET_STATES = {
@@ -26,16 +24,8 @@ export const useWebSocket = () => {
const isManuallyClosedRef = useRef(false)
const queryClient = useQueryClient()
const { data: userProfile } = useUserProfile()
const getWebSocketUrl = useCallback(() => {
if (!userProfile?.circleID) {
console.log(
'WebSocket: User not part of any circle - real-time features unavailable',
)
return null
}
const token = localStorage.getItem('ca_token')
if (!token || !isTokenValid()) {
console.log('WebSocket: No valid authentication token')
@@ -55,10 +45,11 @@ export const useWebSocket = () => {
wsUrl = `${isHttps ? 'wss:' : 'ws:'}//${wsUrl}`
}
wsUrl = `${wsUrl}/api/v1/realtime/ws?token=${token}&circleId=${userProfile.circleID}`
// Let backend determine circle from authenticated user
wsUrl = `${wsUrl}/api/v1/realtime/ws?token=${token}`
return wsUrl
}, [userProfile])
}, [])
const handleWebSocketMessage = useCallback(
event => {
@@ -250,39 +241,31 @@ export const useWebSocket = () => {
const toggleWebSocketEnabled = useCallback(
enabled => {
localStorage.setItem('websocket_enabled', enabled.toString())
if (enabled && userProfile?.circleID && isTokenValid()) {
if (enabled && isTokenValid()) {
connect()
} else {
disconnect()
}
},
[connect, disconnect, userProfile],
[connect, disconnect],
)
const isWebSocketEnabled = useCallback(() => {
return localStorage.getItem('websocket_enabled') !== 'false'
}, [])
// Auto-connect when user profile is available and token is valid
// Auto-connect when WebSocket is enabled and token is valid
useEffect(() => {
// Check if WebSocket is enabled in settings
const isWebSocketEnabledSetting =
localStorage.getItem('websocket_enabled') !== 'false'
console.log('WebSocket enabled in settings:', isWebSocketEnabledSetting)
if (
userProfile?.circleID &&
isTokenValid() &&
isWebSocketEnabledSetting &&
isPlusAccount(userProfile)
) {
if (isTokenValid() && isWebSocketEnabledSetting) {
console.log('WebSocket: Conditions met, attempting to connect')
connect()
} else {
console.log('WebSocket: Conditions not met, disconnecting')
if (!isPlusAccount(userProfile)) {
console.log('WebSocket: Not a Plus account - feature unavailable')
}
disconnect()
}
@@ -290,7 +273,8 @@ export const useWebSocket = () => {
return () => {
disconnect()
}
}, [userProfile, connect, disconnect])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) // Only run once on mount
// Cleanup timeouts on unmount
useEffect(() => {

View File

@@ -21,7 +21,6 @@ import { LoginSocialGoogle } from 'reactjs-social-login'
import { GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
import Logo from '../../Logo'
import { useResource } from '../../queries/ResourceQueries'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { login } from '../../utils/Fetcher'
import { apiManager } from '../../utils/TokenManager'
@@ -29,7 +28,7 @@ import MFAVerificationModal from './MFAVerificationModal'
const LoginView = () => {
// Only fetch user profile if token is valid to prevent unnecessary queries
const { data: userProfileData } = useUserProfile()
// const { data: userProfileData } = useUserProfile()
const [userProfile, setUserProfile] = useState(null)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')

View File

@@ -7,9 +7,6 @@ import {
Chip,
FormControl,
Input,
ListItem,
ListItemContent,
ListItemDecorator,
Option,
Select,
TextField,
@@ -113,7 +110,7 @@ const ThingTriggerSection = ({
onChange={(e, newValue) => setSelectedThing(newValue)}
getOptionLabel={option => option.name}
renderOption={(props, option) => (
<ListItem {...props}>
<Box {...props}>
<Box
sx={{
display: 'flex',
@@ -123,19 +120,19 @@ const ThingTriggerSection = ({
p: 1,
}}
>
<ListItemDecorator sx={{ alignSelf: 'flex-start' }}>
<Box sx={{ alignSelf: 'flex-start' }}>
<Typography level='body-lg' textColor='primary'>
{option.name}
</Typography>
</ListItemDecorator>
<ListItemContent>
</Box>
<Box>
<Typography level='body2' textColor='text.secondary'>
<Chip>type: {option.type}</Chip>{' '}
<Chip>state: {option.state}</Chip>
</Typography>
</ListItemContent>
</Box>
</Box>
</ListItem>
</Box>
)}
renderInput={params => (
<TextField {...params} label='Select a thing' />

View File

@@ -110,7 +110,7 @@ const MultiSelectHelp = ({ isVisible = true }) => {
description='Mark selected tasks as completed'
/>
<ShortcutItem
keys={['Del']}
keys={['Del', '⌫']}
description='Delete selected tasks'
/>
</Box>

View File

@@ -825,18 +825,6 @@ const MyChores = () => {
return (
<>
<Typography level='title-lg' sx={{ mt: 2, mb: 2 }}>
{JSON.stringify(userProfile) === 'null'}
</Typography>
<Typography level='title-lg' sx={{ mt: 2, mb: 2 }}>
{userLabelsLoading}
</Typography>
<Typography level='title-lg' sx={{ mt: 2, mb: 2 }}>
{performers.length === 0}
</Typography>
<Typography level='title-lg' sx={{ mt: 2, mb: 2 }}>
{choresLoading}
</Typography>
<LoadingComponent />
</>
)
@@ -1227,7 +1215,6 @@ const MyChores = () => {
},
}}
>
{/* Primary Actions - Safe operations */}
<Button
size='sm'
variant='solid'
@@ -1237,15 +1224,13 @@ const MyChores = () => {
disabled={selectedChores.size === 0}
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
fontWeight: 'md',
}}
>
Complete
</Button>
<Button
size='sm'
variant='outlined'
variant='soft'
color='warning'
onClick={handleBulkSkip}
startDecorator={<SkipNext />}
@@ -1256,48 +1241,29 @@ 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='outlined'
color='neutral'
variant='soft'
color='danger'
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'
variant='soft'
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

View File

@@ -165,7 +165,6 @@ const CalendarView = ({ chores }) => {
return legendItems.map((item, index) => (
<Grid
key={index}
item
xs={12}
sx={{
display: 'flex',