Implement Agent 6: Real-Time Updates feature for LocalGreenChain: - Add Socket.io server with room-based subscriptions - Create client-side hooks (useSocket, useLiveFeed, usePlantUpdates) - Add SocketProvider context for application-wide state - Implement UI components: - ConnectionStatus: Shows WebSocket connection state - LiveFeed: Real-time event feed display - NotificationToast: Toast notifications with auto-dismiss - LiveChart: Real-time data visualization - Add event type definitions and formatting utilities - Create socket API endpoint for WebSocket initialization - Add socket stats endpoint for monitoring - Extend tailwind with fadeIn/slideIn animations Integrates with existing EventStream SSE system for fallback.
235 lines
6.1 KiB
TypeScript
235 lines
6.1 KiB
TypeScript
/**
|
|
* Socket.io Context Provider for LocalGreenChain
|
|
*
|
|
* Provides socket connection state to the entire application.
|
|
*/
|
|
|
|
import React, { createContext, useContext, useEffect, useState, useCallback, useRef, ReactNode } from 'react';
|
|
import { getSocketClient, RealtimeSocketClient } from './socketClient';
|
|
import type {
|
|
ConnectionStatus,
|
|
TransparencyEvent,
|
|
RoomType,
|
|
TransparencyEventType,
|
|
ConnectionMetrics,
|
|
RealtimeNotification,
|
|
} from './types';
|
|
import { toFeedItem } from './events';
|
|
|
|
/**
|
|
* Socket context value type
|
|
*/
|
|
interface SocketContextValue {
|
|
// Connection state
|
|
status: ConnectionStatus;
|
|
isConnected: boolean;
|
|
metrics: ConnectionMetrics;
|
|
|
|
// Events
|
|
events: TransparencyEvent[];
|
|
latestEvent: TransparencyEvent | null;
|
|
|
|
// Notifications
|
|
notifications: RealtimeNotification[];
|
|
unreadCount: number;
|
|
|
|
// Actions
|
|
connect: () => void;
|
|
disconnect: () => void;
|
|
joinRoom: (room: RoomType) => Promise<boolean>;
|
|
leaveRoom: (room: RoomType) => Promise<boolean>;
|
|
subscribeToTypes: (types: TransparencyEventType[]) => Promise<boolean>;
|
|
clearEvents: () => void;
|
|
markNotificationRead: (id: string) => void;
|
|
dismissNotification: (id: string) => void;
|
|
markAllRead: () => void;
|
|
}
|
|
|
|
const SocketContext = createContext<SocketContextValue | null>(null);
|
|
|
|
/**
|
|
* Provider props
|
|
*/
|
|
interface SocketProviderProps {
|
|
children: ReactNode;
|
|
userId?: string;
|
|
autoConnect?: boolean;
|
|
maxEvents?: number;
|
|
maxNotifications?: number;
|
|
}
|
|
|
|
/**
|
|
* Convert event to notification
|
|
*/
|
|
function eventToNotification(event: TransparencyEvent): RealtimeNotification {
|
|
const feedItem = toFeedItem(event);
|
|
|
|
let notificationType: RealtimeNotification['type'] = 'info';
|
|
if (event.priority === 'CRITICAL') notificationType = 'error';
|
|
else if (event.priority === 'HIGH') notificationType = 'warning';
|
|
else if (event.type.includes('error')) notificationType = 'error';
|
|
else if (event.type.includes('completed') || event.type.includes('verified')) notificationType = 'success';
|
|
|
|
return {
|
|
id: event.id,
|
|
type: notificationType,
|
|
title: feedItem.formatted.title,
|
|
message: feedItem.formatted.description,
|
|
timestamp: feedItem.timestamp,
|
|
eventType: event.type,
|
|
data: event.data,
|
|
read: false,
|
|
dismissed: false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Socket Provider component
|
|
*/
|
|
export function SocketProvider({
|
|
children,
|
|
userId,
|
|
autoConnect = true,
|
|
maxEvents = 100,
|
|
maxNotifications = 50,
|
|
}: SocketProviderProps) {
|
|
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
|
|
const [events, setEvents] = useState<TransparencyEvent[]>([]);
|
|
const [latestEvent, setLatestEvent] = useState<TransparencyEvent | null>(null);
|
|
const [notifications, setNotifications] = useState<RealtimeNotification[]>([]);
|
|
const [metrics, setMetrics] = useState<ConnectionMetrics>({
|
|
status: 'disconnected',
|
|
eventsReceived: 0,
|
|
reconnectAttempts: 0,
|
|
rooms: [],
|
|
});
|
|
|
|
const clientRef = useRef<RealtimeSocketClient | null>(null);
|
|
|
|
// Initialize client
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
const client = getSocketClient({ auth: { userId } });
|
|
clientRef.current = client;
|
|
|
|
// Set up listeners
|
|
const unsubStatus = client.onStatusChange((newStatus) => {
|
|
setStatus(newStatus);
|
|
setMetrics(client.getMetrics());
|
|
});
|
|
|
|
const unsubEvent = client.onEvent((event) => {
|
|
setLatestEvent(event);
|
|
setEvents((prev) => [event, ...prev].slice(0, maxEvents));
|
|
setMetrics(client.getMetrics());
|
|
|
|
// Create notification for important events
|
|
if (event.priority === 'HIGH' || event.priority === 'CRITICAL') {
|
|
const notification = eventToNotification(event);
|
|
setNotifications((prev) => [notification, ...prev].slice(0, maxNotifications));
|
|
}
|
|
});
|
|
|
|
// Auto connect
|
|
if (autoConnect) {
|
|
client.connect();
|
|
}
|
|
|
|
// Initial metrics
|
|
setMetrics(client.getMetrics());
|
|
|
|
return () => {
|
|
unsubStatus();
|
|
unsubEvent();
|
|
};
|
|
}, [autoConnect, userId, maxEvents, maxNotifications]);
|
|
|
|
const connect = useCallback(() => {
|
|
clientRef.current?.connect();
|
|
}, []);
|
|
|
|
const disconnect = useCallback(() => {
|
|
clientRef.current?.disconnect();
|
|
}, []);
|
|
|
|
const joinRoom = useCallback(async (room: RoomType) => {
|
|
return clientRef.current?.joinRoom(room) ?? false;
|
|
}, []);
|
|
|
|
const leaveRoom = useCallback(async (room: RoomType) => {
|
|
return clientRef.current?.leaveRoom(room) ?? false;
|
|
}, []);
|
|
|
|
const subscribeToTypes = useCallback(async (types: TransparencyEventType[]) => {
|
|
return clientRef.current?.subscribeToTypes(types) ?? false;
|
|
}, []);
|
|
|
|
const clearEvents = useCallback(() => {
|
|
setEvents([]);
|
|
setLatestEvent(null);
|
|
}, []);
|
|
|
|
const markNotificationRead = useCallback((id: string) => {
|
|
setNotifications((prev) =>
|
|
prev.map((n) => (n.id === id ? { ...n, read: true } : n))
|
|
);
|
|
}, []);
|
|
|
|
const dismissNotification = useCallback((id: string) => {
|
|
setNotifications((prev) =>
|
|
prev.map((n) => (n.id === id ? { ...n, dismissed: true } : n))
|
|
);
|
|
}, []);
|
|
|
|
const markAllRead = useCallback(() => {
|
|
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
|
}, []);
|
|
|
|
const unreadCount = notifications.filter((n) => !n.read && !n.dismissed).length;
|
|
|
|
const value: SocketContextValue = {
|
|
status,
|
|
isConnected: status === 'connected',
|
|
metrics,
|
|
events,
|
|
latestEvent,
|
|
notifications: notifications.filter((n) => !n.dismissed),
|
|
unreadCount,
|
|
connect,
|
|
disconnect,
|
|
joinRoom,
|
|
leaveRoom,
|
|
subscribeToTypes,
|
|
clearEvents,
|
|
markNotificationRead,
|
|
dismissNotification,
|
|
markAllRead,
|
|
};
|
|
|
|
return (
|
|
<SocketContext.Provider value={value}>
|
|
{children}
|
|
</SocketContext.Provider>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Hook to use socket context
|
|
*/
|
|
export function useSocketContext(): SocketContextValue {
|
|
const context = useContext(SocketContext);
|
|
if (!context) {
|
|
throw new Error('useSocketContext must be used within a SocketProvider');
|
|
}
|
|
return context;
|
|
}
|
|
|
|
/**
|
|
* Hook to optionally use socket context (returns null if not in provider)
|
|
*/
|
|
export function useOptionalSocketContext(): SocketContextValue | null {
|
|
return useContext(SocketContext);
|
|
}
|
|
|
|
export default SocketContext;
|