Files
Tracearr/apps/web/src/hooks/useSocket.tsx
Rephl3x 3015f48118
Some checks failed
CI / Lint & Typecheck (push) Has been cancelled
CI / Test (routes) (push) Has been cancelled
CI / Test (security) (push) Has been cancelled
CI / Test (services) (push) Has been cancelled
CI / Test (unit) (push) Has been cancelled
CI / Test (integration) (push) Has been cancelled
CI / Test Coverage (push) Has been cancelled
CI / Build (push) Has been cancelled
Initial Upload
2025-12-17 12:32:50 +13:00

197 lines
6.5 KiB
TypeScript

import {
createContext,
useContext,
useEffect,
useState,
useCallback,
useMemo,
useRef,
type ReactNode,
} from 'react';
import { io, type Socket } from 'socket.io-client';
import { useQueryClient } from '@tanstack/react-query';
import type {
ServerToClientEvents,
ClientToServerEvents,
ActiveSession,
ViolationWithDetails,
DashboardStats,
NotificationChannelRouting,
NotificationEventType,
} from '@tracearr/shared';
import { WS_EVENTS } from '@tracearr/shared';
import { useAuth } from './useAuth';
import { toast } from 'sonner';
import { tokenStorage } from '@/lib/api';
import { useChannelRouting } from './queries';
type TypedSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
interface SocketContextValue {
socket: TypedSocket | null;
isConnected: boolean;
subscribeSessions: () => void;
unsubscribeSessions: () => void;
}
const SocketContext = createContext<SocketContextValue | null>(null);
export function SocketProvider({ children }: { children: ReactNode }) {
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const [socket, setSocket] = useState<TypedSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
// Get channel routing for web toast preferences
const { data: routingData } = useChannelRouting();
// Build a ref to the routing map for access in event handlers
const routingMapRef = useRef<Map<NotificationEventType, NotificationChannelRouting>>(new Map());
// Update the ref when routing data changes
useEffect(() => {
const newMap = new Map<NotificationEventType, NotificationChannelRouting>();
routingData?.forEach((r) => newMap.set(r.eventType, r));
routingMapRef.current = newMap;
}, [routingData]);
// Helper to check if web toast is enabled for an event type
const isWebToastEnabled = useCallback((eventType: NotificationEventType): boolean => {
const routing = routingMapRef.current.get(eventType);
// Default to true if routing not yet loaded
return routing?.webToastEnabled ?? true;
}, []);
useEffect(() => {
if (!isAuthenticated) {
if (socket) {
socket.disconnect();
setSocket(null);
setIsConnected(false);
}
return;
}
// Get JWT token for authentication
const token = tokenStorage.getAccessToken();
if (!token) {
return;
}
// Create socket connection with auth token
const newSocket: TypedSocket = io({
path: '/socket.io',
withCredentials: true,
autoConnect: true,
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
auth: {
token,
},
});
newSocket.on('connect', () => {
setIsConnected(true);
});
newSocket.on('disconnect', () => {
setIsConnected(false);
});
newSocket.on('connect_error', (error) => {
console.error('[Socket] Connection error:', error);
});
// Handle real-time events
// Note: Since users can filter by server, we invalidate all matching query patterns
// and let react-query refetch with the appropriate server filter
newSocket.on(WS_EVENTS.SESSION_STARTED as 'session:started', (session: ActiveSession) => {
// Invalidate all active sessions queries (regardless of server filter)
void queryClient.invalidateQueries({ queryKey: ['sessions', 'active'] });
// Invalidate dashboard stats and session history
void queryClient.invalidateQueries({ queryKey: ['stats', 'dashboard'] });
void queryClient.invalidateQueries({ queryKey: ['sessions', 'list'] });
// Show toast if web notifications are enabled for stream_started
if (isWebToastEnabled('stream_started')) {
toast.info('New Stream Started', {
description: `${session.user.identityName ?? session.user.username} is watching ${session.mediaTitle}`,
});
}
});
newSocket.on(WS_EVENTS.SESSION_STOPPED as 'session:stopped', (_sessionId: string) => {
// Invalidate all active sessions queries (regardless of server filter)
void queryClient.invalidateQueries({ queryKey: ['sessions', 'active'] });
// Invalidate dashboard stats and session history (stopped session now has duration)
void queryClient.invalidateQueries({ queryKey: ['stats', 'dashboard'] });
void queryClient.invalidateQueries({ queryKey: ['sessions', 'list'] });
});
newSocket.on(WS_EVENTS.SESSION_UPDATED as 'session:updated', (_session: ActiveSession) => {
// Invalidate all active sessions queries (regardless of server filter)
void queryClient.invalidateQueries({ queryKey: ['sessions', 'active'] });
});
newSocket.on(WS_EVENTS.VIOLATION_NEW as 'violation:new', (violation: ViolationWithDetails) => {
// Invalidate violations query
void queryClient.invalidateQueries({ queryKey: ['violations'] });
void queryClient.invalidateQueries({ queryKey: ['stats', 'dashboard'] });
// Show toast notification if web notifications are enabled for violation_detected
if (isWebToastEnabled('violation_detected')) {
const toastFn = violation.severity === 'high' ? toast.error : toast.warning;
toastFn(`New Violation: ${violation.rule.name}`, {
description: `${violation.user.identityName ?? violation.user.username} triggered ${violation.rule.type}`,
});
}
});
newSocket.on(WS_EVENTS.STATS_UPDATED as 'stats:updated', (_stats: DashboardStats) => {
// Invalidate all dashboard stats queries (they now have server-specific cache keys)
void queryClient.invalidateQueries({ queryKey: ['stats', 'dashboard'] });
});
setSocket(newSocket);
return () => {
newSocket.disconnect();
};
}, [isAuthenticated, queryClient, isWebToastEnabled]);
const subscribeSessions = useCallback(() => {
if (socket && isConnected) {
socket.emit('subscribe:sessions');
}
}, [socket, isConnected]);
const unsubscribeSessions = useCallback(() => {
if (socket && isConnected) {
socket.emit('unsubscribe:sessions');
}
}, [socket, isConnected]);
const value = useMemo<SocketContextValue>(
() => ({
socket,
isConnected,
subscribeSessions,
unsubscribeSessions,
}),
[socket, isConnected, subscribeSessions, unsubscribeSessions]
);
return (
<SocketContext.Provider value={value}>{children}</SocketContext.Provider>
);
}
export function useSocket(): SocketContextValue {
const context = useContext(SocketContext);
if (!context) {
throw new Error('useSocket must be used within a SocketProvider');
}
return context;
}