refactor: migrate chat state to use new SSE client infrastructure
- Refactor ChatState to use new SSEClient instead of direct EventSource - Remove manual connection management, reconnection logic, and event handling - Update chat.svelte.ts with improved type safety and documentation - Update +layout.svelte to work with new SSE architecture - Update chat messages API to integrate with new system - Remove legacy sseConnectionTracker.ts and sseEmitter.ts files - Reduce overall codebase complexity with centralized SSE management
- commit
- 6c2663e4c12f35b0c1ba7cae9f04f98f5394108e
- author
- Kit · · 2025-12-15 10:22 UTC
- parent
- 0270c67
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
5 files · +201 -376
D src/lib/sseConnectionTracker.ts +0 -90
| @@ -1,90 +0,0 @@ | |||
| 1 | - | import { createLogger } from './utils/logger.server'; | |
| 2 | - | ||
| 3 | - | const log = createLogger('sse-connection-tracker'); | |
| 4 | - | ||
| 5 | - | interface ConnectionInfo { | |
| 6 | - | userId: string; | |
| 7 | - | connectedAt: number; | |
| 8 | - | lastActivity: number; | |
| 9 | - | } | |
| 10 | - | ||
| 11 | - | class SSEConnectionTracker { | |
| 12 | - | private connections = new Map<string, ConnectionInfo>(); | |
| 13 | - | ||
| 14 | - | addConnection(userId: string): boolean { | |
| 15 | - | const existing = this.connections.get(userId); | |
| 16 | - | if (existing) { | |
| 17 | - | log.debug('User already has active connection', { userId }); | |
| 18 | - | // Update last activity but don't trigger status update | |
| 19 | - | existing.lastActivity = Date.now(); | |
| 20 | - | return false; // Connection already exists | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | this.connections.set(userId, { | |
| 24 | - | userId, | |
| 25 | - | connectedAt: Date.now(), | |
| 26 | - | lastActivity: Date.now() | |
| 27 | - | }); | |
| 28 | - | ||
| 29 | - | log.debug('New SSE connection tracked', { | |
| 30 | - | userId, | |
| 31 | - | totalConnections: this.connections.size | |
| 32 | - | }); | |
| 33 | - | return true; // New connection | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | removeConnection(userId: string): boolean { | |
| 37 | - | const deleted = this.connections.delete(userId); | |
| 38 | - | if (deleted) { | |
| 39 | - | log.debug('SSE connection removed', { | |
| 40 | - | userId, | |
| 41 | - | remainingConnections: this.connections.size | |
| 42 | - | }); | |
| 43 | - | } | |
| 44 | - | return deleted; | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | hasConnection(userId: string): boolean { | |
| 48 | - | return this.connections.has(userId); | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | updateActivity(userId: string): void { | |
| 52 | - | const connection = this.connections.get(userId); | |
| 53 | - | if (connection) { | |
| 54 | - | connection.lastActivity = Date.now(); | |
| 55 | - | } | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | getActiveUserIds(): string[] { | |
| 59 | - | return Array.from(this.connections.keys()); | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | getConnectionCount(): number { | |
| 63 | - | return this.connections.size; | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | // Clean up stale connections (e.g., if server didn't properly close them) | |
| 67 | - | cleanupStaleConnections(maxInactivityMs: number = 5 * 60 * 1000): string[] { | |
| 68 | - | const now = Date.now(); | |
| 69 | - | const staleUserIds: string[] = []; | |
| 70 | - | ||
| 71 | - | for (const [userId, info] of this.connections.entries()) { | |
| 72 | - | if (now - info.lastActivity > maxInactivityMs) { | |
| 73 | - | this.connections.delete(userId); | |
| 74 | - | staleUserIds.push(userId); | |
| 75 | - | } | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | if (staleUserIds.length > 0) { | |
| 79 | - | log.info('Cleaned up stale connections', { | |
| 80 | - | count: staleUserIds.length, | |
| 81 | - | userIds: staleUserIds | |
| 82 | - | }); | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | return staleUserIds; | |
| 86 | - | } | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | // Export singleton instance | |
| 90 | - | export const sseConnectionTracker = new SSEConnectionTracker(); | |
| @@ -1,90 +0,0 @@ | |||||
| 1 | - | import { createLogger } from './utils/logger.server'; | |||
| 2 | - | ||||
| 3 | - | const log = createLogger('sse-connection-tracker'); | |||
| 4 | - | ||||
| 5 | - | interface ConnectionInfo { | |||
| 6 | - | userId: string; | |||
| 7 | - | connectedAt: number; | |||
| 8 | - | lastActivity: number; | |||
| 9 | - | } | |||
| 10 | - | ||||
| 11 | - | class SSEConnectionTracker { | |||
| 12 | - | private connections = new Map<string, ConnectionInfo>(); | |||
| 13 | - | ||||
| 14 | - | addConnection(userId: string): boolean { | |||
| 15 | - | const existing = this.connections.get(userId); | |||
| 16 | - | if (existing) { | |||
| 17 | - | log.debug('User already has active connection', { userId }); | |||
| 18 | - | // Update last activity but don't trigger status update | |||
| 19 | - | existing.lastActivity = Date.now(); | |||
| 20 | - | return false; // Connection already exists | |||
| 21 | - | } | |||
| 22 | - | ||||
| 23 | - | this.connections.set(userId, { | |||
| 24 | - | userId, | |||
| 25 | - | connectedAt: Date.now(), | |||
| 26 | - | lastActivity: Date.now() | |||
| 27 | - | }); | |||
| 28 | - | ||||
| 29 | - | log.debug('New SSE connection tracked', { | |||
| 30 | - | userId, | |||
| 31 | - | totalConnections: this.connections.size | |||
| 32 | - | }); | |||
| 33 | - | return true; // New connection | |||
| 34 | - | } | |||
| 35 | - | ||||
| 36 | - | removeConnection(userId: string): boolean { | |||
| 37 | - | const deleted = this.connections.delete(userId); | |||
| 38 | - | if (deleted) { | |||
| 39 | - | log.debug('SSE connection removed', { | |||
| 40 | - | userId, | |||
| 41 | - | remainingConnections: this.connections.size | |||
| 42 | - | }); | |||
| 43 | - | } | |||
| 44 | - | return deleted; | |||
| 45 | - | } | |||
| 46 | - | ||||
| 47 | - | hasConnection(userId: string): boolean { | |||
| 48 | - | return this.connections.has(userId); | |||
| 49 | - | } | |||
| 50 | - | ||||
| 51 | - | updateActivity(userId: string): void { | |||
| 52 | - | const connection = this.connections.get(userId); | |||
| 53 | - | if (connection) { | |||
| 54 | - | connection.lastActivity = Date.now(); | |||
| 55 | - | } | |||
| 56 | - | } | |||
| 57 | - | ||||
| 58 | - | getActiveUserIds(): string[] { | |||
| 59 | - | return Array.from(this.connections.keys()); | |||
| 60 | - | } | |||
| 61 | - | ||||
| 62 | - | getConnectionCount(): number { | |||
| 63 | - | return this.connections.size; | |||
| 64 | - | } | |||
| 65 | - | ||||
| 66 | - | // Clean up stale connections (e.g., if server didn't properly close them) | |||
| 67 | - | cleanupStaleConnections(maxInactivityMs: number = 5 * 60 * 1000): string[] { | |||
| 68 | - | const now = Date.now(); | |||
| 69 | - | const staleUserIds: string[] = []; | |||
| 70 | - | ||||
| 71 | - | for (const [userId, info] of this.connections.entries()) { | |||
| 72 | - | if (now - info.lastActivity > maxInactivityMs) { | |||
| 73 | - | this.connections.delete(userId); | |||
| 74 | - | staleUserIds.push(userId); | |||
| 75 | - | } | |||
| 76 | - | } | |||
| 77 | - | ||||
| 78 | - | if (staleUserIds.length > 0) { | |||
| 79 | - | log.info('Cleaned up stale connections', { | |||
| 80 | - | count: staleUserIds.length, | |||
| 81 | - | userIds: staleUserIds | |||
| 82 | - | }); | |||
| 83 | - | } | |||
| 84 | - | ||||
| 85 | - | return staleUserIds; | |||
| 86 | - | } | |||
| 87 | - | } | |||
| 88 | - | ||||
| 89 | - | // Export singleton instance | |||
| 90 | - | export const sseConnectionTracker = new SSEConnectionTracker(); | |||
D src/lib/sseEmitter.ts +0 -65
| @@ -1,65 +0,0 @@ | |||
| 1 | - | type SSEEventData = { | |
| 2 | - | type: string; | |
| 3 | - | data: unknown; | |
| 4 | - | }; | |
| 5 | - | ||
| 6 | - | class CustomEventEmitter { | |
| 7 | - | private listeners: Map<string, Array<(data: SSEEventData) => void>> = new Map(); | |
| 8 | - | private maxListeners: number = 100; | |
| 9 | - | ||
| 10 | - | constructor() { | |
| 11 | - | console.log('[SSE-Emitter] Initialized'); | |
| 12 | - | } | |
| 13 | - | ||
| 14 | - | emit(event: string, data: SSEEventData) { | |
| 15 | - | const eventListeners = this.listeners.get(event); | |
| 16 | - | if (eventListeners) { | |
| 17 | - | // Only log non-sensitive event types | |
| 18 | - | if (!['userStatusUpdate', 'chatMessage'].includes(data.type)) { | |
| 19 | - | console.log('[SSE-Emitter] Broadcasting event:', { | |
| 20 | - | type: data.type, | |
| 21 | - | listenersCount: eventListeners.length | |
| 22 | - | }); | |
| 23 | - | } | |
| 24 | - | eventListeners.forEach((listener) => listener(data)); | |
| 25 | - | } | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | addListener(event: string, listener: (data: SSEEventData) => void) { | |
| 29 | - | if (!this.listeners.has(event)) { | |
| 30 | - | this.listeners.set(event, []); | |
| 31 | - | } | |
| 32 | - | const eventListeners = this.listeners.get(event)!; | |
| 33 | - | ||
| 34 | - | if (eventListeners.length >= this.maxListeners) { | |
| 35 | - | console.log( | |
| 36 | - | `[SSE-Emitter] Warning: Event '${event}' has exceeded the maximum number of listeners (${this.maxListeners})` | |
| 37 | - | ); | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | eventListeners.push(listener); | |
| 41 | - | console.log('[SSE-Emitter] New listener added:', { | |
| 42 | - | event, | |
| 43 | - | totalListeners: eventListeners.length | |
| 44 | - | }); | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | removeListener(event: string, listenerToRemove: (data: SSEEventData) => void) { | |
| 48 | - | const eventListeners = this.listeners.get(event); | |
| 49 | - | if (eventListeners) { | |
| 50 | - | const newListeners = eventListeners.filter((listener) => listener !== listenerToRemove); | |
| 51 | - | this.listeners.set(event, newListeners); | |
| 52 | - | console.log('[SSE-Emitter] Listener removed:', { | |
| 53 | - | event, | |
| 54 | - | remainingListeners: newListeners.length | |
| 55 | - | }); | |
| 56 | - | } | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | setMaxListeners(n: number) { | |
| 60 | - | this.maxListeners = n; | |
| 61 | - | console.log('[SSE-Emitter] Max listeners updated:', { newLimit: n }); | |
| 62 | - | } | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | export const sseEmitter = new CustomEventEmitter(); | |
| @@ -1,65 +0,0 @@ | |||||
| 1 | - | type SSEEventData = { | |||
| 2 | - | type: string; | |||
| 3 | - | data: unknown; | |||
| 4 | - | }; | |||
| 5 | - | ||||
| 6 | - | class CustomEventEmitter { | |||
| 7 | - | private listeners: Map<string, Array<(data: SSEEventData) => void>> = new Map(); | |||
| 8 | - | private maxListeners: number = 100; | |||
| 9 | - | ||||
| 10 | - | constructor() { | |||
| 11 | - | console.log('[SSE-Emitter] Initialized'); | |||
| 12 | - | } | |||
| 13 | - | ||||
| 14 | - | emit(event: string, data: SSEEventData) { | |||
| 15 | - | const eventListeners = this.listeners.get(event); | |||
| 16 | - | if (eventListeners) { | |||
| 17 | - | // Only log non-sensitive event types | |||
| 18 | - | if (!['userStatusUpdate', 'chatMessage'].includes(data.type)) { | |||
| 19 | - | console.log('[SSE-Emitter] Broadcasting event:', { | |||
| 20 | - | type: data.type, | |||
| 21 | - | listenersCount: eventListeners.length | |||
| 22 | - | }); | |||
| 23 | - | } | |||
| 24 | - | eventListeners.forEach((listener) => listener(data)); | |||
| 25 | - | } | |||
| 26 | - | } | |||
| 27 | - | ||||
| 28 | - | addListener(event: string, listener: (data: SSEEventData) => void) { | |||
| 29 | - | if (!this.listeners.has(event)) { | |||
| 30 | - | this.listeners.set(event, []); | |||
| 31 | - | } | |||
| 32 | - | const eventListeners = this.listeners.get(event)!; | |||
| 33 | - | ||||
| 34 | - | if (eventListeners.length >= this.maxListeners) { | |||
| 35 | - | console.log( | |||
| 36 | - | `[SSE-Emitter] Warning: Event '${event}' has exceeded the maximum number of listeners (${this.maxListeners})` | |||
| 37 | - | ); | |||
| 38 | - | } | |||
| 39 | - | ||||
| 40 | - | eventListeners.push(listener); | |||
| 41 | - | console.log('[SSE-Emitter] New listener added:', { | |||
| 42 | - | event, | |||
| 43 | - | totalListeners: eventListeners.length | |||
| 44 | - | }); | |||
| 45 | - | } | |||
| 46 | - | ||||
| 47 | - | removeListener(event: string, listenerToRemove: (data: SSEEventData) => void) { | |||
| 48 | - | const eventListeners = this.listeners.get(event); | |||
| 49 | - | if (eventListeners) { | |||
| 50 | - | const newListeners = eventListeners.filter((listener) => listener !== listenerToRemove); | |||
| 51 | - | this.listeners.set(event, newListeners); | |||
| 52 | - | console.log('[SSE-Emitter] Listener removed:', { | |||
| 53 | - | event, | |||
| 54 | - | remainingListeners: newListeners.length | |||
| 55 | - | }); | |||
| 56 | - | } | |||
| 57 | - | } | |||
| 58 | - | ||||
| 59 | - | setMaxListeners(n: number) { | |||
| 60 | - | this.maxListeners = n; | |||
| 61 | - | console.log('[SSE-Emitter] Max listeners updated:', { newLimit: n }); | |||
| 62 | - | } | |||
| 63 | - | } | |||
| 64 | - | ||||
| 65 | - | export const sseEmitter = new CustomEventEmitter(); | |||
M src/lib/states/chat.svelte.ts +194 -215
| @@ -1,3 +1,10 @@ | |||
| 1 | + | /** | |
| 2 | + | * Chat State Management | |
| 3 | + | * | |
| 4 | + | * Central state manager for chat functionality using Svelte 5 runes. | |
| 5 | + | * Handles SSE connections, messages, and user state. | |
| 6 | + | */ | |
| 7 | + | ||
| 1 | 8 | import type { User, Message, EnrichedMessage, SafeUser } from '../types/chat'; | |
| 2 | 9 | import { createSafeUser } from '../types/chat'; | |
| 3 | 10 | import type { | |
| @@ -8,38 +15,53 @@ import type { | |||
| 8 | 15 | import type { TextStyle } from '../types/text-formatting'; | |
| 9 | 16 | import { invalidate } from '$app/navigation'; | |
| 10 | 17 | import { env } from '$env/dynamic/public'; | |
| 18 | + | import { SSEClient, type SSEClientConfig } from '../sse/SSEClient.svelte'; | |
| 19 | + | import type { SSEEventMap } from '../sse/SSEEventEmitter'; | |
| 20 | + | import type { ConnectionStatus } from '../sse/config'; | |
| 11 | 21 | ||
| 12 | 22 | // Use the public environment variable with a fallback | |
| 13 | 23 | const DEFAULT_CHAT_ROOM_ID = | |
| 14 | 24 | env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001'; | |
| 15 | 25 | ||
| 16 | 26 | class ChatState { | |
| 27 | + | // User and message state | |
| 17 | 28 | private users = $state<SafeUser[]>([]); | |
| 18 | 29 | private messages = $state<Message[]>([]); | |
| 19 | 30 | private currentUser = $state<SafeUser | null>(null); | |
| 20 | - | private eventSource: EventSource | null = null; | |
| 21 | - | private reconnectAttempts = 0; | |
| 22 | - | private maxReconnectAttempts = 5; | |
| 23 | - | private reconnectDelay = 1000; // Start with 1 second | |
| 24 | - | private isInitializing = $state(false); | |
| 25 | 31 | private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID); | |
| 26 | 32 | private userCache = $state<Record<string, SafeUser>>({}); | |
| 27 | - | private sseError = $state<string | null>(null); | |
| 28 | - | private sseRetryAfter = $state<number | null>(null); | |
| 29 | - | private connectionTimeout: ReturnType<typeof setTimeout> | null = null; | |
| 30 | - | private isReconnecting = $state(false); | |
| 33 | + | private hasMoreMessages = $state(false); | |
| 34 | + | ||
| 35 | + | // SSE Client | |
| 36 | + | private sseClient: SSEClient | null = null; | |
| 37 | + | ||
| 38 | + | // Connection state | |
| 39 | + | private _connectionStatus = $state<ConnectionStatus>('disconnected'); | |
| 40 | + | private _sseError = $state<string | null>(null); | |
| 41 | + | ||
| 42 | + | // Internal state flags | |
| 43 | + | private isInitializing = $state(false); | |
| 31 | 44 | private isSettingUser = $state(false); | |
| 32 | - | private isConnecting = $state(false); | |
| 33 | - | private connectionAttemptTimeout: ReturnType<typeof setTimeout> | null = null; | |
| 45 | + | ||
| 46 | + | // Memoization for enriched messages | |
| 34 | 47 | private _memoizedEnrichedMessages: EnrichedMessage[] | null = null; | |
| 35 | 48 | private _lastEnrichmentKey: string = ''; | |
| 36 | - | // Flag to avoid re-registering SSE event handlers | |
| 37 | - | private sseHandlersRegistered = false; | |
| 38 | - | private lastBuddyListUpdate = 0; | |
| 49 | + | ||
| 50 | + | // Buddy list tracking | |
| 39 | 51 | private lastBuddyListHash = ''; | |
| 40 | - | private hasMoreMessages = $state(false); | |
| 52 | + | ||
| 53 | + | // Public polling for unauthenticated users | |
| 41 | 54 | private publicPollingInterval: ReturnType<typeof setInterval> | null = null; | |
| 42 | 55 | ||
| 56 | + | // Derived state | |
| 57 | + | public connectionStatus = $derived(this._connectionStatus); | |
| 58 | + | public sseError = $derived(this._sseError); | |
| 59 | + | public isConnected = $derived(this._connectionStatus === 'connected'); | |
| 60 | + | public isReconnecting = $derived(this._connectionStatus === 'reconnecting'); | |
| 61 | + | ||
| 62 | + | /** | |
| 63 | + | * Reinitialize the chat state (e.g., after login) | |
| 64 | + | */ | |
| 43 | 65 | public async reinitialize() { | |
| 44 | 66 | if (this.isInitializing) { | |
| 45 | 67 | console.debug('Reinitialize already in progress, skipping.'); | |
| @@ -47,43 +69,54 @@ class ChatState { | |||
| 47 | 69 | } | |
| 48 | 70 | this.isInitializing = true; | |
| 49 | 71 | console.debug('Reinitializing chat state...'); | |
| 50 | - | if (this.eventSource) { | |
| 51 | - | this.eventSource.close(); | |
| 52 | - | this.eventSource = null; | |
| 72 | + | ||
| 73 | + | // Disconnect existing SSE client | |
| 74 | + | if (this.sseClient) { | |
| 75 | + | this.sseClient.disconnect(); | |
| 76 | + | this.sseClient = null; | |
| 53 | 77 | } | |
| 54 | - | this.reconnectAttempts = 0; | |
| 55 | - | this.reconnectDelay = 1000; | |
| 78 | + | ||
| 56 | 79 | this.messages = []; | |
| 57 | - | // Ensure currentRoomId is set | |
| 58 | 80 | this.currentRoomId = DEFAULT_CHAT_ROOM_ID; | |
| 59 | 81 | ||
| 60 | 82 | if (this.currentUser) { | |
| 61 | 83 | console.debug('Waiting for session cookie update before setting up SSE...'); | |
| 62 | 84 | await new Promise((resolve) => setTimeout(resolve, 1000)); | |
| 63 | - | // First set up SSE to receive status updates | |
| 85 | + | ||
| 86 | + | // Set up SSE connection | |
| 64 | 87 | this.setupSSE(); | |
| 65 | - | // Then initialize messages and room users | |
| 88 | + | ||
| 89 | + | // Initialize messages and room users | |
| 66 | 90 | await Promise.all([this.initializeMessages(), this.initializeRoomUsers()]); | |
| 67 | 91 | } | |
| 92 | + | ||
| 68 | 93 | this.isInitializing = false; | |
| 69 | 94 | } | |
| 70 | 95 | ||
| 96 | + | /** | |
| 97 | + | * Get SSE error information | |
| 98 | + | */ | |
| 71 | 99 | getSSEError() { | |
| 72 | 100 | return { | |
| 73 | - | error: this.sseError, | |
| 74 | - | retryAfter: this.sseRetryAfter | |
| 101 | + | error: this._sseError, | |
| 102 | + | status: this._connectionStatus, | |
| 103 | + | retryAfter: this.sseClient?.getReconnectDelay() ?? null | |
| 75 | 104 | }; | |
| 76 | 105 | } | |
| 77 | 106 | ||
| 78 | - | // User methods | |
| 107 | + | /** | |
| 108 | + | * Get the current user | |
| 109 | + | */ | |
| 79 | 110 | getCurrentUser() { | |
| 80 | 111 | return this.currentUser; | |
| 81 | 112 | } | |
| 82 | 113 | ||
| 114 | + | /** | |
| 115 | + | * Set the current user | |
| 116 | + | */ | |
| 83 | 117 | async setCurrentUser(user: User | SafeUser | null) { | |
| 84 | 118 | console.debug('setCurrentUser called with', user ? { ...user, password: '[REDACTED]' } : null); | |
| 85 | 119 | ||
| 86 | - | // Prevent concurrent setCurrentUser calls | |
| 87 | 120 | if (this.isSettingUser) { | |
| 88 | 121 | console.debug('Already setting user, skipping'); | |
| 89 | 122 | return; | |
| @@ -92,18 +125,16 @@ class ChatState { | |||
| 92 | 125 | this.isSettingUser = true; | |
| 93 | 126 | ||
| 94 | 127 | try { | |
| 95 | - | // If user is null and we already have no user, skip | |
| 96 | 128 | if (!user && !this.currentUser) { | |
| 97 | 129 | console.debug('No user to set and no current user, skipping'); | |
| 98 | 130 | return; | |
| 99 | 131 | } | |
| 100 | 132 | ||
| 101 | - | // Convert to SafeUser if full User object is passed | |
| 102 | 133 | const safeUser = user ? ('password' in user ? createSafeUser(user) : user) : null; | |
| 103 | 134 | ||
| 104 | - | // If same user is being set, check SSE connection | |
| 135 | + | // Check if same user with active connection | |
| 105 | 136 | if (safeUser?.id === this.currentUser?.id) { | |
| 106 | - | if (!this.eventSource) { | |
| 137 | + | if (!this.sseClient || !this.sseClient.isConnected) { | |
| 107 | 138 | console.debug('Same user being set but no active SSE connection, reinitializing.'); | |
| 108 | 139 | } else { | |
| 109 | 140 | console.debug('Same user being set with active connection, skipping.'); | |
| @@ -111,31 +142,31 @@ class ChatState { | |||
| 111 | 142 | } | |
| 112 | 143 | } | |
| 113 | 144 | ||
| 114 | - | // Clear existing messages before setting new user | |
| 115 | 145 | this.messages = []; | |
| 116 | 146 | this.currentUser = safeUser; | |
| 117 | 147 | ||
| 118 | 148 | if (safeUser) { | |
| 119 | - | // Update user cache with current user | |
| 120 | 149 | this.userCache[safeUser.id] = safeUser; | |
| 121 | - | ||
| 122 | - | // Only reinitialize if we have a valid user | |
| 123 | 150 | await this.reinitialize(); | |
| 124 | 151 | } else { | |
| 125 | 152 | // Cleanup when user is removed | |
| 126 | - | if (this.eventSource) { | |
| 127 | - | this.eventSource.close(); | |
| 128 | - | this.eventSource = null; | |
| 153 | + | if (this.sseClient) { | |
| 154 | + | this.sseClient.disconnect(); | |
| 155 | + | this.sseClient = null; | |
| 129 | 156 | } | |
| 130 | 157 | this.messages = []; | |
| 131 | 158 | this.users = []; | |
| 132 | 159 | this.userCache = {}; | |
| 160 | + | this._connectionStatus = 'disconnected'; | |
| 133 | 161 | } | |
| 134 | 162 | } finally { | |
| 135 | 163 | this.isSettingUser = false; | |
| 136 | 164 | } | |
| 137 | 165 | } | |
| 138 | 166 | ||
| 167 | + | /** | |
| 168 | + | * Initialize room users | |
| 169 | + | */ | |
| 139 | 170 | private async initializeRoomUsers() { | |
| 140 | 171 | console.debug('Initializing room users for room:', this.currentRoomId); | |
| 141 | 172 | try { | |
| @@ -143,9 +174,7 @@ class ChatState { | |||
| 143 | 174 | const url = `/api/rooms/${this.currentRoomId}${isPublic ? '?public=true' : ''}`; | |
| 144 | 175 | console.debug('Fetching room users from:', url); | |
| 145 | 176 | ||
| 146 | - | const response = await fetch(url, { | |
| 147 | - | credentials: 'include' | |
| 148 | - | }); | |
| 177 | + | const response = await fetch(url, { credentials: 'include' }); | |
| 149 | 178 | ||
| 150 | 179 | if (!response.ok) { | |
| 151 | 180 | throw new Error('Failed to fetch room users'); | |
| @@ -157,20 +186,14 @@ class ChatState { | |||
| 157 | 186 | } | |
| 158 | 187 | ||
| 159 | 188 | console.debug('Fetched room buddy list:', data.buddyList); | |
| 160 | - | // Merge fetched buddy list with existing cached users to preserve user details even if offline | |
| 161 | - | // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary Map for merging, not reactive state | |
| 162 | - | const mergedUsers = new Map<string, (typeof data.buddyList)[0]>(); | |
| 163 | - | // Add existing cached users | |
| 164 | - | Object.values(this.userCache).forEach((user) => mergedUsers.set(user.id, user)); | |
| 165 | 189 | ||
| 166 | - | // Overwrite/add with fetched buddy list | |
| 167 | - | data.buddyList.forEach((user: (typeof data.buddyList)[0]) => { | |
| 168 | - | mergedUsers.set(user.id, user); | |
| 169 | - | }); | |
| 190 | + | // Merge fetched buddy list with existing cached users | |
| 191 | + | // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary Map for merging | |
| 192 | + | const mergedUsers = new Map<string, SafeUser>(); | |
| 193 | + | Object.values(this.userCache).forEach((user) => mergedUsers.set(user.id, user)); | |
| 194 | + | data.buddyList.forEach((user: SafeUser) => mergedUsers.set(user.id, user)); | |
| 170 | 195 | ||
| 171 | 196 | this.users = Array.from(mergedUsers.values()); | |
| 172 | - | ||
| 173 | - | // Update cache with merged users | |
| 174 | 197 | this.users.forEach((user) => { | |
| 175 | 198 | this.userCache[user.id] = user; | |
| 176 | 199 | }); | |
| @@ -180,8 +203,10 @@ class ChatState { | |||
| 180 | 203 | } | |
| 181 | 204 | } | |
| 182 | 205 | ||
| 206 | + | /** | |
| 207 | + | * Get online users sorted by status | |
| 208 | + | */ | |
| 183 | 209 | getOnlineUsers() { | |
| 184 | - | // Sort users by status: online first, then away, then busy, then offline | |
| 185 | 210 | return [...this.users].sort((a: SafeUser, b: SafeUser) => { | |
| 186 | 211 | const statusOrder: Record<SafeUser['status'], number> = { | |
| 187 | 212 | online: 0, | |
| @@ -193,13 +218,14 @@ class ChatState { | |||
| 193 | 218 | }); | |
| 194 | 219 | } | |
| 195 | 220 | ||
| 221 | + | /** | |
| 222 | + | * Get user by ID | |
| 223 | + | */ | |
| 196 | 224 | getUserById(userId: string) { | |
| 197 | - | // First check the cache | |
| 198 | 225 | if (this.userCache[userId]) { | |
| 199 | 226 | return this.userCache[userId]; | |
| 200 | 227 | } | |
| 201 | 228 | ||
| 202 | - | // Return a fallback object and cache it to avoid repeated fetches | |
| 203 | 229 | const fallback: SafeUser = { | |
| 204 | 230 | id: userId, | |
| 205 | 231 | nickname: 'Unknown User', | |
| @@ -212,6 +238,9 @@ class ChatState { | |||
| 212 | 238 | return fallback; | |
| 213 | 239 | } | |
| 214 | 240 | ||
| 241 | + | /** | |
| 242 | + | * Update user status | |
| 243 | + | */ | |
| 215 | 244 | updateUserStatus(userId: string, status: User['status'], lastSeen?: number) { | |
| 216 | 245 | console.debug('Updating user status with:', { | |
| 217 | 246 | userId, | |
| @@ -226,16 +255,14 @@ class ChatState { | |||
| 226 | 255 | existingUser && | |
| 227 | 256 | existingUser.status === status && | |
| 228 | 257 | lastSeen && | |
| 229 | - | Math.abs(existingUser.lastSeen ?? 0 - lastSeen) < 5000 | |
| 258 | + | Math.abs((existingUser.lastSeen ?? 0) - lastSeen) < 5000 | |
| 230 | 259 | ) { | |
| 231 | 260 | console.debug('Skipping redundant status update for user:', userId); | |
| 232 | 261 | return; | |
| 233 | 262 | } | |
| 234 | 263 | ||
| 235 | - | // Try to get the user from cache first | |
| 236 | 264 | let user: SafeUser | undefined = this.userCache[userId]; | |
| 237 | 265 | ||
| 238 | - | // If not in cache, try to find in users array | |
| 239 | 266 | if (!user) { | |
| 240 | 267 | const foundUser = this.users.find((u: SafeUser) => u.id === userId); | |
| 241 | 268 | if (foundUser) { | |
| @@ -251,13 +278,10 @@ class ChatState { | |||
| 251 | 278 | lastSeen: lastSeen ?? (status === 'offline' ? now : user.lastSeen) | |
| 252 | 279 | }; | |
| 253 | 280 | ||
| 254 | - | // Update cache first | |
| 255 | 281 | this.userCache[userId] = updatedUser; | |
| 256 | 282 | ||
| 257 | - | // Then update or add to users array | |
| 258 | 283 | const index = this.users.findIndex((u: SafeUser) => u.id === userId); | |
| 259 | 284 | if (index !== -1) { | |
| 260 | - | // Create new array with updated user | |
| 261 | 285 | this.users = [...this.users.slice(0, index), updatedUser, ...this.users.slice(index + 1)]; | |
| 262 | 286 | } else { | |
| 263 | 287 | this.users = [...this.users, updatedUser]; | |
| @@ -266,12 +290,9 @@ class ChatState { | |||
| 266 | 290 | console.debug('User status updated:', { | |
| 267 | 291 | userId, | |
| 268 | 292 | newStatus: status, | |
| 269 | - | newLastSeen: new Date(updatedUser.lastSeen || now).toISOString(), | |
| 270 | - | updatedInCache: this.userCache[userId].status === status, | |
| 271 | - | updatedInList: this.users.find((u) => u.id === userId)?.status === status | |
| 293 | + | newLastSeen: new Date(updatedUser.lastSeen || now).toISOString() | |
| 272 | 294 | }); | |
| 273 | 295 | } else { | |
| 274 | - | // If user not found anywhere, fetch from API | |
| 275 | 296 | console.debug('User not found in cache or list, fetching from API:', userId); | |
| 276 | 297 | fetch(`/api/users/${userId}`, { credentials: 'include' }) | |
| 277 | 298 | .then((response) => response.json()) | |
| @@ -282,16 +303,12 @@ class ChatState { | |||
| 282 | 303 | status, | |
| 283 | 304 | lastSeen: lastSeen ?? (status === 'offline' ? now : data.user.lastSeen) | |
| 284 | 305 | }; | |
| 285 | - | // Update both cache and users array | |
| 286 | 306 | this.userCache[userId] = newUser; | |
| 287 | 307 | this.users = [...this.users, newUser]; | |
| 288 | 308 | ||
| 289 | 309 | console.debug('User fetched and status updated:', { | |
| 290 | 310 | userId, | |
| 291 | - | status: newUser.status, | |
| 292 | - | lastSeen: new Date(newUser.lastSeen || now).toISOString(), | |
| 293 | - | addedToCache: !!this.userCache[userId], | |
| 294 | - | addedToList: this.users.some((u) => u.id === userId) | |
| 311 | + | status: newUser.status | |
| 295 | 312 | }); | |
| 296 | 313 | } | |
| 297 | 314 | }) | |
| @@ -299,7 +316,9 @@ class ChatState { | |||
| 299 | 316 | } | |
| 300 | 317 | } | |
| 301 | 318 | ||
| 302 | - | // Message methods | |
| 319 | + | /** | |
| 320 | + | * Get enriched messages with user data | |
| 321 | + | */ | |
| 303 | 322 | getMessages() { | |
| 304 | 323 | const key = | |
| 305 | 324 | this.messages.map((m) => m.id).join(',') + '|' + Object.keys(this.userCache).sort().join(','); | |
| @@ -315,13 +334,15 @@ class ChatState { | |||
| 315 | 334 | return this._memoizedEnrichedMessages; | |
| 316 | 335 | } | |
| 317 | 336 | ||
| 337 | + | /** | |
| 338 | + | * Prepend messages (for loading older messages) | |
| 339 | + | */ | |
| 318 | 340 | public prependMessages(newMessages: Message[], hasMore?: boolean) { | |
| 319 | 341 | console.debug('Prepending messages:', { count: newMessages.length }); | |
| 320 | - | // Get unique user IDs from new messages that aren't in the cache | |
| 342 | + | ||
| 321 | 343 | const uniqueUserIds = new Set(newMessages.map((msg) => msg.senderId)); | |
| 322 | 344 | const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]); | |
| 323 | 345 | ||
| 324 | - | // Fetch missing user data in parallel | |
| 325 | 346 | if (missingUserIds.length > 0) { | |
| 326 | 347 | console.debug('Fetching missing user data for IDs:', missingUserIds); | |
| 327 | 348 | Promise.all( | |
| @@ -334,7 +355,6 @@ class ChatState { | |||
| 334 | 355 | const userData = await userResponse.json(); | |
| 335 | 356 | if (userData.success && userData.user) { | |
| 336 | 357 | this.userCache[userId] = userData.user; | |
| 337 | - | // Only add to users array if not already present | |
| 338 | 358 | if (!this.users.some((u) => u.id === userId)) { | |
| 339 | 359 | this.users = [...this.users, userData.user]; | |
| 340 | 360 | } | |
| @@ -347,31 +367,29 @@ class ChatState { | |||
| 347 | 367 | ); | |
| 348 | 368 | } | |
| 349 | 369 | ||
| 350 | - | // Ensure incoming messages are sorted in ascending order before prepending | |
| 351 | 370 | const sortedNewMessages = newMessages.slice().sort((a, b) => a.timestamp - b.timestamp); | |
| 352 | 371 | const uniqueNewMessages = sortedNewMessages.filter( | |
| 353 | 372 | (msg) => !this.messages.some((m) => m.id === msg.id) | |
| 354 | 373 | ); | |
| 355 | 374 | this.messages = [...uniqueNewMessages, ...this.messages]; | |
| 356 | 375 | ||
| 357 | - | // Update hasMore if provided | |
| 358 | 376 | if (hasMore !== undefined) { | |
| 359 | 377 | this.hasMoreMessages = hasMore; | |
| 360 | 378 | } | |
| 361 | 379 | } | |
| 362 | 380 | ||
| 363 | - | // Add method to update user cache for public access | |
| 381 | + | /** | |
| 382 | + | * Update user cache | |
| 383 | + | */ | |
| 364 | 384 | updateUserCache(users: (User | SafeUser)[]) { | |
| 365 | 385 | console.debug( | |
| 366 | 386 | 'Updating user cache with users:', | |
| 367 | 387 | users.map((u) => ({ ...u, password: '[REDACTED]' })) | |
| 368 | 388 | ); | |
| 369 | - | // Merge incoming users with the existing cache, ensuring we only store SafeUser objects | |
| 370 | 389 | users.forEach((user) => { | |
| 371 | 390 | const safeUser = 'password' in user ? createSafeUser(user) : user; | |
| 372 | 391 | if (!this.userCache[user.id] || safeUser.status !== this.userCache[user.id].status) { | |
| 373 | 392 | this.userCache[user.id] = safeUser; | |
| 374 | - | // Update users array if not already present or if status changed | |
| 375 | 393 | const existingIndex = this.users.findIndex((u) => u.id === user.id); | |
| 376 | 394 | if (existingIndex === -1) { | |
| 377 | 395 | this.users = [...this.users, safeUser]; | |
| @@ -386,14 +404,14 @@ class ChatState { | |||
| 386 | 404 | }); | |
| 387 | 405 | } | |
| 388 | 406 | ||
| 389 | - | // Add method to update online users for public access | |
| 407 | + | /** | |
| 408 | + | * Update online users (from buddy list updates) | |
| 409 | + | */ | |
| 390 | 410 | updateOnlineUsers(users: (User | SafeUser)[]) { | |
| 391 | 411 | const now = Date.now(); | |
| 392 | - | // Convert all users to SafeUser and create a hash | |
| 393 | 412 | const safeUsers = users.map((user) => ('password' in user ? createSafeUser(user) : user)); | |
| 394 | 413 | const newHash = JSON.stringify(safeUsers); | |
| 395 | 414 | ||
| 396 | - | // Only update if the data has changed | |
| 397 | 415 | if (newHash !== this.lastBuddyListHash) { | |
| 398 | 416 | const statusCounts = safeUsers.reduce( | |
| 399 | 417 | (acc, user) => { | |
| @@ -410,28 +428,27 @@ class ChatState { | |||
| 410 | 428 | }); | |
| 411 | 429 | ||
| 412 | 430 | this.users = safeUsers; | |
| 413 | - | // Also update cache | |
| 414 | 431 | safeUsers.forEach((user) => { | |
| 415 | 432 | this.userCache[user.id] = user; | |
| 416 | 433 | }); | |
| 417 | 434 | this.lastBuddyListHash = newHash; | |
| 418 | - | this.lastBuddyListUpdate = now; | |
| 419 | 435 | } | |
| 420 | 436 | } | |
| 421 | 437 | ||
| 422 | - | // Add method to update messages for public access | |
| 438 | + | /** | |
| 439 | + | * Update messages (for public/unauthenticated access) | |
| 440 | + | */ | |
| 423 | 441 | updateMessages(messages: Message[]) { | |
| 424 | - | // Deduplicate the messages by their id in case duplicates exist | |
| 425 | 442 | const uniqueMessages = Array.from(new Map(messages.map((msg) => [msg.id, msg])).values()); | |
| 426 | - | // Sort messages in ascending order (oldest first, newest at bottom) | |
| 427 | 443 | this.messages = uniqueMessages.sort((a, b) => a.timestamp - b.timestamp); | |
| 428 | 444 | } | |
| 429 | 445 | ||
| 430 | - | // Note: For authenticated users, we no longer use the message cache. Messages are fetched directly from the database, and real-time updates are handled via SSE. | |
| 446 | + | /** | |
| 447 | + | * Initialize messages from the server | |
| 448 | + | */ | |
| 431 | 449 | async initializeMessages() { | |
| 432 | 450 | try { | |
| 433 | - | // Build the URL with appropriate parameters | |
| 434 | - | // eslint-disable-next-line svelte/prefer-svelte-reactivity -- URLSearchParams for URL building, not reactive state | |
| 451 | + | // eslint-disable-next-line svelte/prefer-svelte-reactivity -- URLSearchParams for URL building | |
| 435 | 452 | const params = new URLSearchParams(); | |
| 436 | 453 | if (!this.currentUser) { | |
| 437 | 454 | params.append('public', 'true'); | |
| @@ -448,11 +465,9 @@ class ChatState { | |||
| 448 | 465 | throw new Error(data.error); | |
| 449 | 466 | } | |
| 450 | 467 | ||
| 451 | - | // Get unique user IDs from messages that aren't in the cache | |
| 452 | 468 | const uniqueUserIds = new Set(data.messages.map((msg) => msg.senderId)); | |
| 453 | 469 | const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]); | |
| 454 | 470 | ||
| 455 | - | // Fetch missing user data in parallel | |
| 456 | 471 | if (missingUserIds.length > 0) { | |
| 457 | 472 | console.debug('Fetching missing user data for IDs:', missingUserIds); | |
| 458 | 473 | await Promise.all( | |
| @@ -465,7 +480,6 @@ class ChatState { | |||
| 465 | 480 | const userData = await userResponse.json(); | |
| 466 | 481 | if (userData.success && userData.user) { | |
| 467 | 482 | this.userCache[userId] = userData.user; | |
| 468 | - | // Only add to users array if not already present | |
| 469 | 483 | if (!this.users.some((u) => u.id === userId)) { | |
| 470 | 484 | this.users = [...this.users, userData.user]; | |
| 471 | 485 | } | |
| @@ -478,10 +492,8 @@ class ChatState { | |||
| 478 | 492 | ); | |
| 479 | 493 | } | |
| 480 | 494 | ||
| 481 | - | // Sort messages in ascending order (oldest first, most recent at the bottom) | |
| 482 | 495 | this.messages = data.messages.slice().sort((a, b) => a.timestamp - b.timestamp); | |
| 483 | 496 | ||
| 484 | - | // Update hasMore based on response | |
| 485 | 497 | if ('hasMore' in data) { | |
| 486 | 498 | this.hasMoreMessages = data.hasMore; | |
| 487 | 499 | } | |
| @@ -498,6 +510,9 @@ class ChatState { | |||
| 498 | 510 | } | |
| 499 | 511 | } | |
| 500 | 512 | ||
| 513 | + | /** | |
| 514 | + | * Send a message | |
| 515 | + | */ | |
| 501 | 516 | async sendMessage( | |
| 502 | 517 | content: string, | |
| 503 | 518 | type: Message['type'] = 'chat', | |
| @@ -554,124 +569,91 @@ class ChatState { | |||
| 554 | 569 | } | |
| 555 | 570 | } | |
| 556 | 571 | ||
| 572 | + | /** | |
| 573 | + | * Set up SSE connection using the new SSEClient | |
| 574 | + | */ | |
| 557 | 575 | private setupSSE() { | |
| 558 | - | // Prevent creating multiple SSE connections if one is already active. | |
| 559 | - | if (this.eventSource && this.eventSource.readyState !== EventSource.CLOSED) { | |
| 576 | + | if (this.sseClient && this.sseClient.isConnected) { | |
| 560 | 577 | console.debug('setupSSE: Active SSE connection exists, skipping setup.'); | |
| 561 | 578 | return; | |
| 562 | 579 | } | |
| 563 | - | // Clear any existing timeouts | |
| 564 | - | if (this.connectionTimeout) { | |
| 565 | - | clearTimeout(this.connectionTimeout); | |
| 566 | - | this.connectionTimeout = null; | |
| 567 | - | } | |
| 568 | 580 | ||
| 569 | - | if (this.connectionAttemptTimeout) { | |
| 570 | - | clearTimeout(this.connectionAttemptTimeout); | |
| 571 | - | this.connectionAttemptTimeout = null; | |
| 581 | + | if (!this.currentUser) { | |
| 582 | + | console.debug('No current user, skipping SSE connection'); | |
| 583 | + | return; | |
| 572 | 584 | } | |
| 573 | 585 | ||
| 574 | - | // Debounce connection attempts | |
| 575 | - | this.connectionAttemptTimeout = setTimeout(() => { | |
| 576 | - | if (!this.currentUser) { | |
| 577 | - | console.debug('No current user, skipping SSE connection'); | |
| 578 | - | return; | |
| 579 | - | } | |
| 586 | + | console.debug('Setting up SSE connection...'); | |
| 580 | 587 | ||
| 581 | - | if (this.isConnecting) { | |
| 582 | - | console.debug('Already connecting to SSE, skipping'); | |
| 583 | - | return; | |
| 588 | + | const config: SSEClientConfig = { | |
| 589 | + | url: '/api/sse', | |
| 590 | + | onMessage: this.handleSSEMessage.bind(this), | |
| 591 | + | onStatusChange: this.handleConnectionStatusChange.bind(this), | |
| 592 | + | onError: (error) => { | |
| 593 | + | this._sseError = error; | |
| 594 | + | console.debug('SSE error:', error); | |
| 584 | 595 | } | |
| 596 | + | }; | |
| 585 | 597 | ||
| 586 | - | this.connectSSE(); | |
| 587 | - | }, 1000); | |
| 598 | + | this.sseClient = new SSEClient(config); | |
| 599 | + | this.sseClient.connect(); | |
| 588 | 600 | } | |
| 589 | 601 | ||
| 590 | - | private async connectSSE() { | |
| 591 | - | if (this.isConnecting) { | |
| 592 | - | console.debug('Already connecting to SSE, skipping'); | |
| 593 | - | return; | |
| 594 | - | } | |
| 595 | - | ||
| 596 | - | this.isConnecting = true; | |
| 597 | - | ||
| 598 | - | try { | |
| 599 | - | // Ensure clean disconnect of any existing connection | |
| 600 | - | if (this.eventSource) { | |
| 601 | - | console.debug('Closing existing SSE connection'); | |
| 602 | - | this.eventSource.close(); | |
| 603 | - | this.eventSource = null; | |
| 602 | + | /** | |
| 603 | + | * Handle incoming SSE messages | |
| 604 | + | */ | |
| 605 | + | private handleSSEMessage<T extends keyof SSEEventMap>(type: T, data: SSEEventMap[T]) { | |
| 606 | + | switch (type) { | |
| 607 | + | case 'chatMessage': | |
| 608 | + | this.handleChatMessage(data as SSEEventMap['chatMessage']); | |
| 609 | + | break; | |
| 610 | + | case 'buddyListUpdate': | |
| 611 | + | this.updateOnlineUsers(data as SSEEventMap['buddyListUpdate']); | |
| 612 | + | break; | |
| 613 | + | case 'userStatusUpdate': { | |
| 614 | + | const statusData = data as SSEEventMap['userStatusUpdate']; | |
| 615 | + | this.updateUserStatus(statusData.userId, statusData.status, statusData.lastSeen); | |
| 616 | + | break; | |
| 604 | 617 | } | |
| 605 | - | ||
| 606 | - | console.debug('Connecting to SSE...'); | |
| 607 | - | this.eventSource = new EventSource('/api/sse', { withCredentials: true }); | |
| 608 | - | // Reset the handlers flag for the new connection | |
| 609 | - | this.sseHandlersRegistered = false; | |
| 610 | - | ||
| 611 | - | this.eventSource.onopen = () => { | |
| 612 | - | console.debug('SSE connection established'); | |
| 613 | - | this.reconnectAttempts = 0; | |
| 614 | - | this.reconnectDelay = 1000; | |
| 615 | - | this.sseError = null; | |
| 616 | - | this.sseRetryAfter = null; | |
| 617 | - | this.isReconnecting = false; | |
| 618 | - | }; | |
| 619 | - | ||
| 620 | - | // Set up error handling | |
| 621 | - | this.eventSource.onerror = (error) => { | |
| 622 | - | console.debug('SSE connection error:', error); | |
| 623 | - | if (this.eventSource?.readyState === EventSource.CLOSED) { | |
| 624 | - | this.handleDisconnection(); | |
| 625 | - | } | |
| 626 | - | }; | |
| 627 | - | ||
| 628 | - | // Register event handlers (only once) | |
| 629 | - | this.setupMessageHandlers(); | |
| 630 | - | } catch (error) { | |
| 631 | - | console.debug('Error setting up SSE connection:', error); | |
| 632 | - | this.handleDisconnection(); | |
| 633 | - | } finally { | |
| 634 | - | this.isConnecting = false; | |
| 618 | + | case 'heartbeat': | |
| 619 | + | // Heartbeat is handled by SSEClient, no action needed here | |
| 620 | + | break; | |
| 621 | + | default: | |
| 622 | + | console.debug('Unknown SSE event type:', type); | |
| 635 | 623 | } | |
| 636 | 624 | } | |
| 637 | 625 | ||
| 638 | - | // Register SSE event handlers only once per connection. | |
| 639 | - | private setupMessageHandlers() { | |
| 640 | - | if (this.sseHandlersRegistered || !this.eventSource) { | |
| 641 | - | return; | |
| 642 | - | } | |
| 626 | + | /** | |
| 627 | + | * Handle chat message from SSE | |
| 628 | + | */ | |
| 629 | + | private async handleChatMessage(messageData: Message) { | |
| 630 | + | console.debug('Received chat message via SSE:', messageData); | |
| 643 | 631 | ||
| 644 | - | // Listen for chat messages | |
| 645 | - | this.eventSource.addEventListener('chatMessage', async (event: MessageEvent) => { | |
| 646 | - | try { | |
| 647 | - | const messageData = JSON.parse(event.data) as Message; | |
| 648 | - | console.debug('Received chat message via SSE:', messageData); | |
| 649 | - | // Deduplicate and update messages array with the new message | |
| 650 | - | this.messages = Array.from( | |
| 651 | - | new Map([...this.messages, messageData].map((m) => [m.id, m])).values() | |
| 652 | - | ); | |
| 653 | - | // Sort messages so the newest are at the bottom | |
| 654 | - | this.messages.sort((a, b) => a.timestamp - b.timestamp); | |
| 655 | - | // Ensure the sender's data is available | |
| 656 | - | await this.ensureUserData(messageData.senderId); | |
| 657 | - | } catch (error) { | |
| 658 | - | console.debug('Error handling SSE chat message:', error); | |
| 659 | - | } | |
| 660 | - | }); | |
| 632 | + | // Deduplicate and update messages array | |
| 633 | + | this.messages = Array.from( | |
| 634 | + | new Map([...this.messages, messageData].map((m) => [m.id, m])).values() | |
| 635 | + | ); | |
| 636 | + | this.messages.sort((a, b) => a.timestamp - b.timestamp); | |
| 661 | 637 | ||
| 662 | - | // Modify buddy list update handler | |
| 663 | - | this.eventSource.addEventListener('buddyListUpdate', async (event: MessageEvent) => { | |
| 664 | - | try { | |
| 665 | - | const buddyList = JSON.parse(event.data) as SafeUser[]; | |
| 666 | - | this.updateOnlineUsers(buddyList); | |
| 667 | - | } catch (error) { | |
| 668 | - | console.debug('Error handling buddy list update via SSE:', error); | |
| 669 | - | } | |
| 670 | - | }); | |
| 638 | + | // Ensure the sender's data is available | |
| 639 | + | await this.ensureUserData(messageData.senderId); | |
| 640 | + | } | |
| 671 | 641 | ||
| 672 | - | this.sseHandlersRegistered = true; | |
| 642 | + | /** | |
| 643 | + | * Handle connection status changes | |
| 644 | + | */ | |
| 645 | + | private handleConnectionStatusChange(status: ConnectionStatus) { | |
| 646 | + | this._connectionStatus = status; | |
| 647 | + | console.debug('SSE connection status changed:', status); | |
| 648 | + | ||
| 649 | + | if (status === 'connected') { | |
| 650 | + | this._sseError = null; | |
| 651 | + | } | |
| 673 | 652 | } | |
| 674 | 653 | ||
| 654 | + | /** | |
| 655 | + | * Ensure user data is loaded | |
| 656 | + | */ | |
| 675 | 657 | private async ensureUserData(userId: string) { | |
| 676 | 658 | if (this.userCache[userId]) return; | |
| 677 | 659 | ||
| @@ -694,29 +676,9 @@ class ChatState { | |||
| 694 | 676 | } | |
| 695 | 677 | } | |
| 696 | 678 | ||
| 697 | - | private handleDisconnection() { | |
| 698 | - | console.debug('SSE connection closed, reconnecting...'); | |
| 699 | - | this.isReconnecting = true; | |
| 700 | - | this.reconnectAttempts++; | |
| 701 | - | if (this.reconnectAttempts > this.maxReconnectAttempts) { | |
| 702 | - | console.debug('Max reconnect attempts reached, giving up'); | |
| 703 | - | if (this.eventSource) { | |
| 704 | - | this.eventSource.close(); | |
| 705 | - | } | |
| 706 | - | this.eventSource = null; | |
| 707 | - | return; | |
| 708 | - | } | |
| 709 | - | this.reconnectDelay *= 2; // Exponential backoff | |
| 710 | - | // Ensure any existing connection is properly closed before reconnecting | |
| 711 | - | if (this.eventSource) { | |
| 712 | - | this.eventSource.close(); | |
| 713 | - | this.eventSource = null; | |
| 714 | - | } | |
| 715 | - | // Reset the handlers flag so that the new connection gets its event listeners registered fresh. | |
| 716 | - | this.sseHandlersRegistered = false; | |
| 717 | - | this.setupSSE(); | |
| 718 | - | } | |
| 719 | - | ||
| 679 | + | /** | |
| 680 | + | * Enrich messages with user data | |
| 681 | + | */ | |
| 720 | 682 | enrichMessages(messages: Message[]): EnrichedMessage[] { | |
| 721 | 683 | console.debug('Enriching messages with user data', { | |
| 722 | 684 | messagesCount: messages.length, | |
| @@ -734,9 +696,26 @@ class ChatState { | |||
| 734 | 696 | })); | |
| 735 | 697 | } | |
| 736 | 698 | ||
| 699 | + | /** | |
| 700 | + | * Get the default chat room ID | |
| 701 | + | */ | |
| 737 | 702 | getDefaultChatRoomId() { | |
| 738 | 703 | return DEFAULT_CHAT_ROOM_ID; | |
| 739 | 704 | } | |
| 705 | + | ||
| 706 | + | /** | |
| 707 | + | * Disconnect SSE (for cleanup) | |
| 708 | + | */ | |
| 709 | + | disconnect() { | |
| 710 | + | if (this.sseClient) { | |
| 711 | + | this.sseClient.disconnect(); | |
| 712 | + | this.sseClient = null; | |
| 713 | + | } | |
| 714 | + | if (this.publicPollingInterval) { | |
| 715 | + | clearInterval(this.publicPollingInterval); | |
| 716 | + | this.publicPollingInterval = null; | |
| 717 | + | } | |
| 718 | + | } | |
| 740 | 719 | } | |
| 741 | 720 | ||
| 742 | 721 | export const chatState = new ChatState(); | |
| @@ -1,3 +1,10 @@ | |||||
| 1 | + | /** | |||
| 2 | + | * Chat State Management | |||
| 3 | + | * | |||
| 4 | + | * Central state manager for chat functionality using Svelte 5 runes. | |||
| 5 | + | * Handles SSE connections, messages, and user state. | |||
| 6 | + | */ | |||
| 7 | + | ||||
| 1 | import type { User, Message, EnrichedMessage, SafeUser } from '../types/chat'; | 8 | import type { User, Message, EnrichedMessage, SafeUser } from '../types/chat'; | ||
| 2 | import { createSafeUser } from '../types/chat'; | 9 | import { createSafeUser } from '../types/chat'; | ||
| 3 | import type { | 10 | import type { | ||
| @@ -8,38 +15,53 @@ import type { | |||||
| 8 | import type { TextStyle } from '../types/text-formatting'; | 15 | import type { TextStyle } from '../types/text-formatting'; | ||
| 9 | import { invalidate } from '$app/navigation'; | 16 | import { invalidate } from '$app/navigation'; | ||
| 10 | import { env } from '$env/dynamic/public'; | 17 | import { env } from '$env/dynamic/public'; | ||
| 18 | + | import { SSEClient, type SSEClientConfig } from '../sse/SSEClient.svelte'; | |||
| 19 | + | import type { SSEEventMap } from '../sse/SSEEventEmitter'; | |||
| 20 | + | import type { ConnectionStatus } from '../sse/config'; | |||
| 11 | 21 | ||||
| 12 | // Use the public environment variable with a fallback | 22 | // Use the public environment variable with a fallback | ||
| 13 | const DEFAULT_CHAT_ROOM_ID = | 23 | const DEFAULT_CHAT_ROOM_ID = | ||
| 14 | env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001'; | 24 | env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001'; | ||
| 15 | 25 | ||||
| 16 | class ChatState { | 26 | class ChatState { | ||
| 27 | + | // User and message state | |||
| 17 | private users = $state<SafeUser[]>([]); | 28 | private users = $state<SafeUser[]>([]); | ||
| 18 | private messages = $state<Message[]>([]); | 29 | private messages = $state<Message[]>([]); | ||
| 19 | private currentUser = $state<SafeUser | null>(null); | 30 | private currentUser = $state<SafeUser | null>(null); | ||
| 20 | - | private eventSource: EventSource | null = null; | |||
| 21 | - | private reconnectAttempts = 0; | |||
| 22 | - | private maxReconnectAttempts = 5; | |||
| 23 | - | private reconnectDelay = 1000; // Start with 1 second | |||
| 24 | - | private isInitializing = $state(false); | |||
| 25 | private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID); | 31 | private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID); | ||
| 26 | private userCache = $state<Record<string, SafeUser>>({}); | 32 | private userCache = $state<Record<string, SafeUser>>({}); | ||
| 27 | - | private sseError = $state<string | null>(null); | 33 | + | private hasMoreMessages = $state(false); |
| 28 | - | private sseRetryAfter = $state<number | null>(null); | 34 | + | |
| 29 | - | private connectionTimeout: ReturnType<typeof setTimeout> | null = null; | 35 | + | // SSE Client |
| 30 | - | private isReconnecting = $state(false); | 36 | + | private sseClient: SSEClient | null = null; |
| 37 | + | ||||
| 38 | + | // Connection state | |||
| 39 | + | private _connectionStatus = $state<ConnectionStatus>('disconnected'); | |||
| 40 | + | private _sseError = $state<string | null>(null); | |||
| 41 | + | ||||
| 42 | + | // Internal state flags | |||
| 43 | + | private isInitializing = $state(false); | |||
| 31 | private isSettingUser = $state(false); | 44 | private isSettingUser = $state(false); | ||
| 32 | - | private isConnecting = $state(false); | 45 | + | |
| 33 | - | private connectionAttemptTimeout: ReturnType<typeof setTimeout> | null = null; | 46 | + | // Memoization for enriched messages |
| 34 | private _memoizedEnrichedMessages: EnrichedMessage[] | null = null; | 47 | private _memoizedEnrichedMessages: EnrichedMessage[] | null = null; | ||
| 35 | private _lastEnrichmentKey: string = ''; | 48 | private _lastEnrichmentKey: string = ''; | ||
| 36 | - | // Flag to avoid re-registering SSE event handlers | 49 | + | |
| 37 | - | private sseHandlersRegistered = false; | 50 | + | // Buddy list tracking |
| 38 | - | private lastBuddyListUpdate = 0; | |||
| 39 | private lastBuddyListHash = ''; | 51 | private lastBuddyListHash = ''; | ||
| 40 | - | private hasMoreMessages = $state(false); | 52 | + | |
| 53 | + | // Public polling for unauthenticated users | |||
| 41 | private publicPollingInterval: ReturnType<typeof setInterval> | null = null; | 54 | private publicPollingInterval: ReturnType<typeof setInterval> | null = null; | ||
| 42 | 55 | ||||
| 56 | + | // Derived state | |||
| 57 | + | public connectionStatus = $derived(this._connectionStatus); | |||
| 58 | + | public sseError = $derived(this._sseError); | |||
| 59 | + | public isConnected = $derived(this._connectionStatus === 'connected'); | |||
| 60 | + | public isReconnecting = $derived(this._connectionStatus === 'reconnecting'); | |||
| 61 | + | ||||
| 62 | + | /** | |||
| 63 | + | * Reinitialize the chat state (e.g., after login) | |||
| 64 | + | */ | |||
| 43 | public async reinitialize() { | 65 | public async reinitialize() { | ||
| 44 | if (this.isInitializing) { | 66 | if (this.isInitializing) { | ||
| 45 | console.debug('Reinitialize already in progress, skipping.'); | 67 | console.debug('Reinitialize already in progress, skipping.'); | ||
| @@ -47,43 +69,54 @@ class ChatState { | |||||
| 47 | } | 69 | } | ||
| 48 | this.isInitializing = true; | 70 | this.isInitializing = true; | ||
| 49 | console.debug('Reinitializing chat state...'); | 71 | console.debug('Reinitializing chat state...'); | ||
| 50 | - | if (this.eventSource) { | 72 | + | |
| 51 | - | this.eventSource.close(); | 73 | + | // Disconnect existing SSE client |
| 52 | - | this.eventSource = null; | 74 | + | if (this.sseClient) { |
| 75 | + | this.sseClient.disconnect(); | |||
| 76 | + | this.sseClient = null; | |||
| 53 | } | 77 | } | ||
| 54 | - | this.reconnectAttempts = 0; | 78 | + | |
| 55 | - | this.reconnectDelay = 1000; | |||
| 56 | this.messages = []; | 79 | this.messages = []; | ||
| 57 | - | // Ensure currentRoomId is set | |||
| 58 | this.currentRoomId = DEFAULT_CHAT_ROOM_ID; | 80 | this.currentRoomId = DEFAULT_CHAT_ROOM_ID; | ||
| 59 | 81 | ||||
| 60 | if (this.currentUser) { | 82 | if (this.currentUser) { | ||
| 61 | console.debug('Waiting for session cookie update before setting up SSE...'); | 83 | console.debug('Waiting for session cookie update before setting up SSE...'); | ||
| 62 | await new Promise((resolve) => setTimeout(resolve, 1000)); | 84 | await new Promise((resolve) => setTimeout(resolve, 1000)); | ||
| 63 | - | // First set up SSE to receive status updates | 85 | + | |
| 86 | + | // Set up SSE connection | |||
| 64 | this.setupSSE(); | 87 | this.setupSSE(); | ||
| 65 | - | // Then initialize messages and room users | 88 | + | |
| 89 | + | // Initialize messages and room users | |||
| 66 | await Promise.all([this.initializeMessages(), this.initializeRoomUsers()]); | 90 | await Promise.all([this.initializeMessages(), this.initializeRoomUsers()]); | ||
| 67 | } | 91 | } | ||
| 92 | + | ||||
| 68 | this.isInitializing = false; | 93 | this.isInitializing = false; | ||
| 69 | } | 94 | } | ||
| 70 | 95 | ||||
| 96 | + | /** | |||
| 97 | + | * Get SSE error information | |||
| 98 | + | */ | |||
| 71 | getSSEError() { | 99 | getSSEError() { | ||
| 72 | return { | 100 | return { | ||
| 73 | - | error: this.sseError, | 101 | + | error: this._sseError, |
| 74 | - | retryAfter: this.sseRetryAfter | 102 | + | status: this._connectionStatus, |
| 103 | + | retryAfter: this.sseClient?.getReconnectDelay() ?? null | |||
| 75 | }; | 104 | }; | ||
| 76 | } | 105 | } | ||
| 77 | 106 | ||||
| 78 | - | // User methods | 107 | + | /** |
| 108 | + | * Get the current user | |||
| 109 | + | */ | |||
| 79 | getCurrentUser() { | 110 | getCurrentUser() { | ||
| 80 | return this.currentUser; | 111 | return this.currentUser; | ||
| 81 | } | 112 | } | ||
| 82 | 113 | ||||
| 114 | + | /** | |||
| 115 | + | * Set the current user | |||
| 116 | + | */ | |||
| 83 | async setCurrentUser(user: User | SafeUser | null) { | 117 | async setCurrentUser(user: User | SafeUser | null) { | ||
| 84 | console.debug('setCurrentUser called with', user ? { ...user, password: '[REDACTED]' } : null); | 118 | console.debug('setCurrentUser called with', user ? { ...user, password: '[REDACTED]' } : null); | ||
| 85 | 119 | ||||
| 86 | - | // Prevent concurrent setCurrentUser calls | |||
| 87 | if (this.isSettingUser) { | 120 | if (this.isSettingUser) { | ||
| 88 | console.debug('Already setting user, skipping'); | 121 | console.debug('Already setting user, skipping'); | ||
| 89 | return; | 122 | return; | ||
| @@ -92,18 +125,16 @@ class ChatState { | |||||
| 92 | this.isSettingUser = true; | 125 | this.isSettingUser = true; | ||
| 93 | 126 | ||||
| 94 | try { | 127 | try { | ||
| 95 | - | // If user is null and we already have no user, skip | |||
| 96 | if (!user && !this.currentUser) { | 128 | if (!user && !this.currentUser) { | ||
| 97 | console.debug('No user to set and no current user, skipping'); | 129 | console.debug('No user to set and no current user, skipping'); | ||
| 98 | return; | 130 | return; | ||
| 99 | } | 131 | } | ||
| 100 | 132 | ||||
| 101 | - | // Convert to SafeUser if full User object is passed | |||
| 102 | const safeUser = user ? ('password' in user ? createSafeUser(user) : user) : null; | 133 | const safeUser = user ? ('password' in user ? createSafeUser(user) : user) : null; | ||
| 103 | 134 | ||||
| 104 | - | // If same user is being set, check SSE connection | 135 | + | // Check if same user with active connection |
| 105 | if (safeUser?.id === this.currentUser?.id) { | 136 | if (safeUser?.id === this.currentUser?.id) { | ||
| 106 | - | if (!this.eventSource) { | 137 | + | if (!this.sseClient || !this.sseClient.isConnected) { |
| 107 | console.debug('Same user being set but no active SSE connection, reinitializing.'); | 138 | console.debug('Same user being set but no active SSE connection, reinitializing.'); | ||
| 108 | } else { | 139 | } else { | ||
| 109 | console.debug('Same user being set with active connection, skipping.'); | 140 | console.debug('Same user being set with active connection, skipping.'); | ||
| @@ -111,31 +142,31 @@ class ChatState { | |||||
| 111 | } | 142 | } | ||
| 112 | } | 143 | } | ||
| 113 | 144 | ||||
| 114 | - | // Clear existing messages before setting new user | |||
| 115 | this.messages = []; | 145 | this.messages = []; | ||
| 116 | this.currentUser = safeUser; | 146 | this.currentUser = safeUser; | ||
| 117 | 147 | ||||
| 118 | if (safeUser) { | 148 | if (safeUser) { | ||
| 119 | - | // Update user cache with current user | |||
| 120 | this.userCache[safeUser.id] = safeUser; | 149 | this.userCache[safeUser.id] = safeUser; | ||
| 121 | - | ||||
| 122 | - | // Only reinitialize if we have a valid user | |||
| 123 | await this.reinitialize(); | 150 | await this.reinitialize(); | ||
| 124 | } else { | 151 | } else { | ||
| 125 | // Cleanup when user is removed | 152 | // Cleanup when user is removed | ||
| 126 | - | if (this.eventSource) { | 153 | + | if (this.sseClient) { |
| 127 | - | this.eventSource.close(); | 154 | + | this.sseClient.disconnect(); |
| 128 | - | this.eventSource = null; | 155 | + | this.sseClient = null; |
| 129 | } | 156 | } | ||
| 130 | this.messages = []; | 157 | this.messages = []; | ||
| 131 | this.users = []; | 158 | this.users = []; | ||
| 132 | this.userCache = {}; | 159 | this.userCache = {}; | ||
| 160 | + | this._connectionStatus = 'disconnected'; | |||
| 133 | } | 161 | } | ||
| 134 | } finally { | 162 | } finally { | ||
| 135 | this.isSettingUser = false; | 163 | this.isSettingUser = false; | ||
| 136 | } | 164 | } | ||
| 137 | } | 165 | } | ||
| 138 | 166 | ||||
| 167 | + | /** | |||
| 168 | + | * Initialize room users | |||
| 169 | + | */ | |||
| 139 | private async initializeRoomUsers() { | 170 | private async initializeRoomUsers() { | ||
| 140 | console.debug('Initializing room users for room:', this.currentRoomId); | 171 | console.debug('Initializing room users for room:', this.currentRoomId); | ||
| 141 | try { | 172 | try { | ||
| @@ -143,9 +174,7 @@ class ChatState { | |||||
| 143 | const url = `/api/rooms/${this.currentRoomId}${isPublic ? '?public=true' : ''}`; | 174 | const url = `/api/rooms/${this.currentRoomId}${isPublic ? '?public=true' : ''}`; | ||
| 144 | console.debug('Fetching room users from:', url); | 175 | console.debug('Fetching room users from:', url); | ||
| 145 | 176 | ||||
| 146 | - | const response = await fetch(url, { | 177 | + | const response = await fetch(url, { credentials: 'include' }); |
| 147 | - | credentials: 'include' | |||
| 148 | - | }); | |||
| 149 | 178 | ||||
| 150 | if (!response.ok) { | 179 | if (!response.ok) { | ||
| 151 | throw new Error('Failed to fetch room users'); | 180 | throw new Error('Failed to fetch room users'); | ||
| @@ -157,20 +186,14 @@ class ChatState { | |||||
| 157 | } | 186 | } | ||
| 158 | 187 | ||||
| 159 | console.debug('Fetched room buddy list:', data.buddyList); | 188 | console.debug('Fetched room buddy list:', data.buddyList); | ||
| 160 | - | // Merge fetched buddy list with existing cached users to preserve user details even if offline | |||
| 161 | - | // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary Map for merging, not reactive state | |||
| 162 | - | const mergedUsers = new Map<string, (typeof data.buddyList)[0]>(); | |||
| 163 | - | // Add existing cached users | |||
| 164 | - | Object.values(this.userCache).forEach((user) => mergedUsers.set(user.id, user)); | |||
| 165 | 189 | ||||
| 166 | - | // Overwrite/add with fetched buddy list | 190 | + | // Merge fetched buddy list with existing cached users |
| 167 | - | data.buddyList.forEach((user: (typeof data.buddyList)[0]) => { | 191 | + | // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary Map for merging |
| 168 | - | mergedUsers.set(user.id, user); | 192 | + | const mergedUsers = new Map<string, SafeUser>(); |
| 169 | - | }); | 193 | + | Object.values(this.userCache).forEach((user) => mergedUsers.set(user.id, user)); |
| 194 | + | data.buddyList.forEach((user: SafeUser) => mergedUsers.set(user.id, user)); | |||
| 170 | 195 | ||||
| 171 | this.users = Array.from(mergedUsers.values()); | 196 | this.users = Array.from(mergedUsers.values()); | ||
| 172 | - | ||||
| 173 | - | // Update cache with merged users | |||
| 174 | this.users.forEach((user) => { | 197 | this.users.forEach((user) => { | ||
| 175 | this.userCache[user.id] = user; | 198 | this.userCache[user.id] = user; | ||
| 176 | }); | 199 | }); | ||
| @@ -180,8 +203,10 @@ class ChatState { | |||||
| 180 | } | 203 | } | ||
| 181 | } | 204 | } | ||
| 182 | 205 | ||||
| 206 | + | /** | |||
| 207 | + | * Get online users sorted by status | |||
| 208 | + | */ | |||
| 183 | getOnlineUsers() { | 209 | getOnlineUsers() { | ||
| 184 | - | // Sort users by status: online first, then away, then busy, then offline | |||
| 185 | return [...this.users].sort((a: SafeUser, b: SafeUser) => { | 210 | return [...this.users].sort((a: SafeUser, b: SafeUser) => { | ||
| 186 | const statusOrder: Record<SafeUser['status'], number> = { | 211 | const statusOrder: Record<SafeUser['status'], number> = { | ||
| 187 | online: 0, | 212 | online: 0, | ||
| @@ -193,13 +218,14 @@ class ChatState { | |||||
| 193 | }); | 218 | }); | ||
| 194 | } | 219 | } | ||
| 195 | 220 | ||||
| 221 | + | /** | |||
| 222 | + | * Get user by ID | |||
| 223 | + | */ | |||
| 196 | getUserById(userId: string) { | 224 | getUserById(userId: string) { | ||
| 197 | - | // First check the cache | |||
| 198 | if (this.userCache[userId]) { | 225 | if (this.userCache[userId]) { | ||
| 199 | return this.userCache[userId]; | 226 | return this.userCache[userId]; | ||
| 200 | } | 227 | } | ||
| 201 | 228 | ||||
| 202 | - | // Return a fallback object and cache it to avoid repeated fetches | |||
| 203 | const fallback: SafeUser = { | 229 | const fallback: SafeUser = { | ||
| 204 | id: userId, | 230 | id: userId, | ||
| 205 | nickname: 'Unknown User', | 231 | nickname: 'Unknown User', | ||
| @@ -212,6 +238,9 @@ class ChatState { | |||||
| 212 | return fallback; | 238 | return fallback; | ||
| 213 | } | 239 | } | ||
| 214 | 240 | ||||
| 241 | + | /** | |||
| 242 | + | * Update user status | |||
| 243 | + | */ | |||
| 215 | updateUserStatus(userId: string, status: User['status'], lastSeen?: number) { | 244 | updateUserStatus(userId: string, status: User['status'], lastSeen?: number) { | ||
| 216 | console.debug('Updating user status with:', { | 245 | console.debug('Updating user status with:', { | ||
| 217 | userId, | 246 | userId, | ||
| @@ -226,16 +255,14 @@ class ChatState { | |||||
| 226 | existingUser && | 255 | existingUser && | ||
| 227 | existingUser.status === status && | 256 | existingUser.status === status && | ||
| 228 | lastSeen && | 257 | lastSeen && | ||
| 229 | - | Math.abs(existingUser.lastSeen ?? 0 - lastSeen) < 5000 | 258 | + | Math.abs((existingUser.lastSeen ?? 0) - lastSeen) < 5000 |
| 230 | ) { | 259 | ) { | ||
| 231 | console.debug('Skipping redundant status update for user:', userId); | 260 | console.debug('Skipping redundant status update for user:', userId); | ||
| 232 | return; | 261 | return; | ||
| 233 | } | 262 | } | ||
| 234 | 263 | ||||
| 235 | - | // Try to get the user from cache first | |||
| 236 | let user: SafeUser | undefined = this.userCache[userId]; | 264 | let user: SafeUser | undefined = this.userCache[userId]; | ||
| 237 | 265 | ||||
| 238 | - | // If not in cache, try to find in users array | |||
| 239 | if (!user) { | 266 | if (!user) { | ||
| 240 | const foundUser = this.users.find((u: SafeUser) => u.id === userId); | 267 | const foundUser = this.users.find((u: SafeUser) => u.id === userId); | ||
| 241 | if (foundUser) { | 268 | if (foundUser) { | ||
| @@ -251,13 +278,10 @@ class ChatState { | |||||
| 251 | lastSeen: lastSeen ?? (status === 'offline' ? now : user.lastSeen) | 278 | lastSeen: lastSeen ?? (status === 'offline' ? now : user.lastSeen) | ||
| 252 | }; | 279 | }; | ||
| 253 | 280 | ||||
| 254 | - | // Update cache first | |||
| 255 | this.userCache[userId] = updatedUser; | 281 | this.userCache[userId] = updatedUser; | ||
| 256 | 282 | ||||
| 257 | - | // Then update or add to users array | |||
| 258 | const index = this.users.findIndex((u: SafeUser) => u.id === userId); | 283 | const index = this.users.findIndex((u: SafeUser) => u.id === userId); | ||
| 259 | if (index !== -1) { | 284 | if (index !== -1) { | ||
| 260 | - | // Create new array with updated user | |||
| 261 | this.users = [...this.users.slice(0, index), updatedUser, ...this.users.slice(index + 1)]; | 285 | this.users = [...this.users.slice(0, index), updatedUser, ...this.users.slice(index + 1)]; | ||
| 262 | } else { | 286 | } else { | ||
| 263 | this.users = [...this.users, updatedUser]; | 287 | this.users = [...this.users, updatedUser]; | ||
| @@ -266,12 +290,9 @@ class ChatState { | |||||
| 266 | console.debug('User status updated:', { | 290 | console.debug('User status updated:', { | ||
| 267 | userId, | 291 | userId, | ||
| 268 | newStatus: status, | 292 | newStatus: status, | ||
| 269 | - | newLastSeen: new Date(updatedUser.lastSeen || now).toISOString(), | 293 | + | newLastSeen: new Date(updatedUser.lastSeen || now).toISOString() |
| 270 | - | updatedInCache: this.userCache[userId].status === status, | |||
| 271 | - | updatedInList: this.users.find((u) => u.id === userId)?.status === status | |||
| 272 | }); | 294 | }); | ||
| 273 | } else { | 295 | } else { | ||
| 274 | - | // If user not found anywhere, fetch from API | |||
| 275 | console.debug('User not found in cache or list, fetching from API:', userId); | 296 | console.debug('User not found in cache or list, fetching from API:', userId); | ||
| 276 | fetch(`/api/users/${userId}`, { credentials: 'include' }) | 297 | fetch(`/api/users/${userId}`, { credentials: 'include' }) | ||
| 277 | .then((response) => response.json()) | 298 | .then((response) => response.json()) | ||
| @@ -282,16 +303,12 @@ class ChatState { | |||||
| 282 | status, | 303 | status, | ||
| 283 | lastSeen: lastSeen ?? (status === 'offline' ? now : data.user.lastSeen) | 304 | lastSeen: lastSeen ?? (status === 'offline' ? now : data.user.lastSeen) | ||
| 284 | }; | 305 | }; | ||
| 285 | - | // Update both cache and users array | |||
| 286 | this.userCache[userId] = newUser; | 306 | this.userCache[userId] = newUser; | ||
| 287 | this.users = [...this.users, newUser]; | 307 | this.users = [...this.users, newUser]; | ||
| 288 | 308 | ||||
| 289 | console.debug('User fetched and status updated:', { | 309 | console.debug('User fetched and status updated:', { | ||
| 290 | userId, | 310 | userId, | ||
| 291 | - | status: newUser.status, | 311 | + | status: newUser.status |
| 292 | - | lastSeen: new Date(newUser.lastSeen || now).toISOString(), | |||
| 293 | - | addedToCache: !!this.userCache[userId], | |||
| 294 | - | addedToList: this.users.some((u) => u.id === userId) | |||
| 295 | }); | 312 | }); | ||
| 296 | } | 313 | } | ||
| 297 | }) | 314 | }) | ||
| @@ -299,7 +316,9 @@ class ChatState { | |||||
| 299 | } | 316 | } | ||
| 300 | } | 317 | } | ||
| 301 | 318 | ||||
| 302 | - | // Message methods | 319 | + | /** |
| 320 | + | * Get enriched messages with user data | |||
| 321 | + | */ | |||
| 303 | getMessages() { | 322 | getMessages() { | ||
| 304 | const key = | 323 | const key = | ||
| 305 | this.messages.map((m) => m.id).join(',') + '|' + Object.keys(this.userCache).sort().join(','); | 324 | this.messages.map((m) => m.id).join(',') + '|' + Object.keys(this.userCache).sort().join(','); | ||
| @@ -315,13 +334,15 @@ class ChatState { | |||||
| 315 | return this._memoizedEnrichedMessages; | 334 | return this._memoizedEnrichedMessages; | ||
| 316 | } | 335 | } | ||
| 317 | 336 | ||||
| 337 | + | /** | |||
| 338 | + | * Prepend messages (for loading older messages) | |||
| 339 | + | */ | |||
| 318 | public prependMessages(newMessages: Message[], hasMore?: boolean) { | 340 | public prependMessages(newMessages: Message[], hasMore?: boolean) { | ||
| 319 | console.debug('Prepending messages:', { count: newMessages.length }); | 341 | console.debug('Prepending messages:', { count: newMessages.length }); | ||
| 320 | - | // Get unique user IDs from new messages that aren't in the cache | 342 | + | |
| 321 | const uniqueUserIds = new Set(newMessages.map((msg) => msg.senderId)); | 343 | const uniqueUserIds = new Set(newMessages.map((msg) => msg.senderId)); | ||
| 322 | const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]); | 344 | const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]); | ||
| 323 | 345 | ||||
| 324 | - | // Fetch missing user data in parallel | |||
| 325 | if (missingUserIds.length > 0) { | 346 | if (missingUserIds.length > 0) { | ||
| 326 | console.debug('Fetching missing user data for IDs:', missingUserIds); | 347 | console.debug('Fetching missing user data for IDs:', missingUserIds); | ||
| 327 | Promise.all( | 348 | Promise.all( | ||
| @@ -334,7 +355,6 @@ class ChatState { | |||||
| 334 | const userData = await userResponse.json(); | 355 | const userData = await userResponse.json(); | ||
| 335 | if (userData.success && userData.user) { | 356 | if (userData.success && userData.user) { | ||
| 336 | this.userCache[userId] = userData.user; | 357 | this.userCache[userId] = userData.user; | ||
| 337 | - | // Only add to users array if not already present | |||
| 338 | if (!this.users.some((u) => u.id === userId)) { | 358 | if (!this.users.some((u) => u.id === userId)) { | ||
| 339 | this.users = [...this.users, userData.user]; | 359 | this.users = [...this.users, userData.user]; | ||
| 340 | } | 360 | } | ||
| @@ -347,31 +367,29 @@ class ChatState { | |||||
| 347 | ); | 367 | ); | ||
| 348 | } | 368 | } | ||
| 349 | 369 | ||||
| 350 | - | // Ensure incoming messages are sorted in ascending order before prepending | |||
| 351 | const sortedNewMessages = newMessages.slice().sort((a, b) => a.timestamp - b.timestamp); | 370 | const sortedNewMessages = newMessages.slice().sort((a, b) => a.timestamp - b.timestamp); | ||
| 352 | const uniqueNewMessages = sortedNewMessages.filter( | 371 | const uniqueNewMessages = sortedNewMessages.filter( | ||
| 353 | (msg) => !this.messages.some((m) => m.id === msg.id) | 372 | (msg) => !this.messages.some((m) => m.id === msg.id) | ||
| 354 | ); | 373 | ); | ||
| 355 | this.messages = [...uniqueNewMessages, ...this.messages]; | 374 | this.messages = [...uniqueNewMessages, ...this.messages]; | ||
| 356 | 375 | ||||
| 357 | - | // Update hasMore if provided | |||
| 358 | if (hasMore !== undefined) { | 376 | if (hasMore !== undefined) { | ||
| 359 | this.hasMoreMessages = hasMore; | 377 | this.hasMoreMessages = hasMore; | ||
| 360 | } | 378 | } | ||
| 361 | } | 379 | } | ||
| 362 | 380 | ||||
| 363 | - | // Add method to update user cache for public access | 381 | + | /** |
| 382 | + | * Update user cache | |||
| 383 | + | */ | |||
| 364 | updateUserCache(users: (User | SafeUser)[]) { | 384 | updateUserCache(users: (User | SafeUser)[]) { | ||
| 365 | console.debug( | 385 | console.debug( | ||
| 366 | 'Updating user cache with users:', | 386 | 'Updating user cache with users:', | ||
| 367 | users.map((u) => ({ ...u, password: '[REDACTED]' })) | 387 | users.map((u) => ({ ...u, password: '[REDACTED]' })) | ||
| 368 | ); | 388 | ); | ||
| 369 | - | // Merge incoming users with the existing cache, ensuring we only store SafeUser objects | |||
| 370 | users.forEach((user) => { | 389 | users.forEach((user) => { | ||
| 371 | const safeUser = 'password' in user ? createSafeUser(user) : user; | 390 | const safeUser = 'password' in user ? createSafeUser(user) : user; | ||
| 372 | if (!this.userCache[user.id] || safeUser.status !== this.userCache[user.id].status) { | 391 | if (!this.userCache[user.id] || safeUser.status !== this.userCache[user.id].status) { | ||
| 373 | this.userCache[user.id] = safeUser; | 392 | this.userCache[user.id] = safeUser; | ||
| 374 | - | // Update users array if not already present or if status changed | |||
| 375 | const existingIndex = this.users.findIndex((u) => u.id === user.id); | 393 | const existingIndex = this.users.findIndex((u) => u.id === user.id); | ||
| 376 | if (existingIndex === -1) { | 394 | if (existingIndex === -1) { | ||
| 377 | this.users = [...this.users, safeUser]; | 395 | this.users = [...this.users, safeUser]; | ||
| @@ -386,14 +404,14 @@ class ChatState { | |||||
| 386 | }); | 404 | }); | ||
| 387 | } | 405 | } | ||
| 388 | 406 | ||||
| 389 | - | // Add method to update online users for public access | 407 | + | /** |
| 408 | + | * Update online users (from buddy list updates) | |||
| 409 | + | */ | |||
| 390 | updateOnlineUsers(users: (User | SafeUser)[]) { | 410 | updateOnlineUsers(users: (User | SafeUser)[]) { | ||
| 391 | const now = Date.now(); | 411 | const now = Date.now(); | ||
| 392 | - | // Convert all users to SafeUser and create a hash | |||
| 393 | const safeUsers = users.map((user) => ('password' in user ? createSafeUser(user) : user)); | 412 | const safeUsers = users.map((user) => ('password' in user ? createSafeUser(user) : user)); | ||
| 394 | const newHash = JSON.stringify(safeUsers); | 413 | const newHash = JSON.stringify(safeUsers); | ||
| 395 | 414 | ||||
| 396 | - | // Only update if the data has changed | |||
| 397 | if (newHash !== this.lastBuddyListHash) { | 415 | if (newHash !== this.lastBuddyListHash) { | ||
| 398 | const statusCounts = safeUsers.reduce( | 416 | const statusCounts = safeUsers.reduce( | ||
| 399 | (acc, user) => { | 417 | (acc, user) => { | ||
| @@ -410,28 +428,27 @@ class ChatState { | |||||
| 410 | }); | 428 | }); | ||
| 411 | 429 | ||||
| 412 | this.users = safeUsers; | 430 | this.users = safeUsers; | ||
| 413 | - | // Also update cache | |||
| 414 | safeUsers.forEach((user) => { | 431 | safeUsers.forEach((user) => { | ||
| 415 | this.userCache[user.id] = user; | 432 | this.userCache[user.id] = user; | ||
| 416 | }); | 433 | }); | ||
| 417 | this.lastBuddyListHash = newHash; | 434 | this.lastBuddyListHash = newHash; | ||
| 418 | - | this.lastBuddyListUpdate = now; | |||
| 419 | } | 435 | } | ||
| 420 | } | 436 | } | ||
| 421 | 437 | ||||
| 422 | - | // Add method to update messages for public access | 438 | + | /** |
| 439 | + | * Update messages (for public/unauthenticated access) | |||
| 440 | + | */ | |||
| 423 | updateMessages(messages: Message[]) { | 441 | updateMessages(messages: Message[]) { | ||
| 424 | - | // Deduplicate the messages by their id in case duplicates exist | |||
| 425 | const uniqueMessages = Array.from(new Map(messages.map((msg) => [msg.id, msg])).values()); | 442 | const uniqueMessages = Array.from(new Map(messages.map((msg) => [msg.id, msg])).values()); | ||
| 426 | - | // Sort messages in ascending order (oldest first, newest at bottom) | |||
| 427 | this.messages = uniqueMessages.sort((a, b) => a.timestamp - b.timestamp); | 443 | this.messages = uniqueMessages.sort((a, b) => a.timestamp - b.timestamp); | ||
| 428 | } | 444 | } | ||
| 429 | 445 | ||||
| 430 | - | // Note: For authenticated users, we no longer use the message cache. Messages are fetched directly from the database, and real-time updates are handled via SSE. | 446 | + | /** |
| 447 | + | * Initialize messages from the server | |||
| 448 | + | */ | |||
| 431 | async initializeMessages() { | 449 | async initializeMessages() { | ||
| 432 | try { | 450 | try { | ||
| 433 | - | // Build the URL with appropriate parameters | 451 | + | // eslint-disable-next-line svelte/prefer-svelte-reactivity -- URLSearchParams for URL building |
| 434 | - | // eslint-disable-next-line svelte/prefer-svelte-reactivity -- URLSearchParams for URL building, not reactive state | |||
| 435 | const params = new URLSearchParams(); | 452 | const params = new URLSearchParams(); | ||
| 436 | if (!this.currentUser) { | 453 | if (!this.currentUser) { | ||
| 437 | params.append('public', 'true'); | 454 | params.append('public', 'true'); | ||
| @@ -448,11 +465,9 @@ class ChatState { | |||||
| 448 | throw new Error(data.error); | 465 | throw new Error(data.error); | ||
| 449 | } | 466 | } | ||
| 450 | 467 | ||||
| 451 | - | // Get unique user IDs from messages that aren't in the cache | |||
| 452 | const uniqueUserIds = new Set(data.messages.map((msg) => msg.senderId)); | 468 | const uniqueUserIds = new Set(data.messages.map((msg) => msg.senderId)); | ||
| 453 | const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]); | 469 | const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]); | ||
| 454 | 470 | ||||
| 455 | - | // Fetch missing user data in parallel | |||
| 456 | if (missingUserIds.length > 0) { | 471 | if (missingUserIds.length > 0) { | ||
| 457 | console.debug('Fetching missing user data for IDs:', missingUserIds); | 472 | console.debug('Fetching missing user data for IDs:', missingUserIds); | ||
| 458 | await Promise.all( | 473 | await Promise.all( | ||
| @@ -465,7 +480,6 @@ class ChatState { | |||||
| 465 | const userData = await userResponse.json(); | 480 | const userData = await userResponse.json(); | ||
| 466 | if (userData.success && userData.user) { | 481 | if (userData.success && userData.user) { | ||
| 467 | this.userCache[userId] = userData.user; | 482 | this.userCache[userId] = userData.user; | ||
| 468 | - | // Only add to users array if not already present | |||
| 469 | if (!this.users.some((u) => u.id === userId)) { | 483 | if (!this.users.some((u) => u.id === userId)) { | ||
| 470 | this.users = [...this.users, userData.user]; | 484 | this.users = [...this.users, userData.user]; | ||
| 471 | } | 485 | } | ||
| @@ -478,10 +492,8 @@ class ChatState { | |||||
| 478 | ); | 492 | ); | ||
| 479 | } | 493 | } | ||
| 480 | 494 | ||||
| 481 | - | // Sort messages in ascending order (oldest first, most recent at the bottom) | |||
| 482 | this.messages = data.messages.slice().sort((a, b) => a.timestamp - b.timestamp); | 495 | this.messages = data.messages.slice().sort((a, b) => a.timestamp - b.timestamp); | ||
| 483 | 496 | ||||
| 484 | - | // Update hasMore based on response | |||
| 485 | if ('hasMore' in data) { | 497 | if ('hasMore' in data) { | ||
| 486 | this.hasMoreMessages = data.hasMore; | 498 | this.hasMoreMessages = data.hasMore; | ||
| 487 | } | 499 | } | ||
| @@ -498,6 +510,9 @@ class ChatState { | |||||
| 498 | } | 510 | } | ||
| 499 | } | 511 | } | ||
| 500 | 512 | ||||
| 513 | + | /** | |||
| 514 | + | * Send a message | |||
| 515 | + | */ | |||
| 501 | async sendMessage( | 516 | async sendMessage( | ||
| 502 | content: string, | 517 | content: string, | ||
| 503 | type: Message['type'] = 'chat', | 518 | type: Message['type'] = 'chat', | ||
| @@ -554,124 +569,91 @@ class ChatState { | |||||
| 554 | } | 569 | } | ||
| 555 | } | 570 | } | ||
| 556 | 571 | ||||
| 572 | + | /** | |||
| 573 | + | * Set up SSE connection using the new SSEClient | |||
| 574 | + | */ | |||
| 557 | private setupSSE() { | 575 | private setupSSE() { | ||
| 558 | - | // Prevent creating multiple SSE connections if one is already active. | 576 | + | if (this.sseClient && this.sseClient.isConnected) { |
| 559 | - | if (this.eventSource && this.eventSource.readyState !== EventSource.CLOSED) { | |||
| 560 | console.debug('setupSSE: Active SSE connection exists, skipping setup.'); | 577 | console.debug('setupSSE: Active SSE connection exists, skipping setup.'); | ||
| 561 | return; | 578 | return; | ||
| 562 | } | 579 | } | ||
| 563 | - | // Clear any existing timeouts | |||
| 564 | - | if (this.connectionTimeout) { | |||
| 565 | - | clearTimeout(this.connectionTimeout); | |||
| 566 | - | this.connectionTimeout = null; | |||
| 567 | - | } | |||
| 568 | 580 | ||||
| 569 | - | if (this.connectionAttemptTimeout) { | 581 | + | if (!this.currentUser) { |
| 570 | - | clearTimeout(this.connectionAttemptTimeout); | 582 | + | console.debug('No current user, skipping SSE connection'); |
| 571 | - | this.connectionAttemptTimeout = null; | 583 | + | return; |
| 572 | } | 584 | } | ||
| 573 | 585 | ||||
| 574 | - | // Debounce connection attempts | 586 | + | console.debug('Setting up SSE connection...'); |
| 575 | - | this.connectionAttemptTimeout = setTimeout(() => { | |||
| 576 | - | if (!this.currentUser) { | |||
| 577 | - | console.debug('No current user, skipping SSE connection'); | |||
| 578 | - | return; | |||
| 579 | - | } | |||
| 580 | 587 | ||||
| 581 | - | if (this.isConnecting) { | 588 | + | const config: SSEClientConfig = { |
| 582 | - | console.debug('Already connecting to SSE, skipping'); | 589 | + | url: '/api/sse', |
| 583 | - | return; | 590 | + | onMessage: this.handleSSEMessage.bind(this), |
| 591 | + | onStatusChange: this.handleConnectionStatusChange.bind(this), | |||
| 592 | + | onError: (error) => { | |||
| 593 | + | this._sseError = error; | |||
| 594 | + | console.debug('SSE error:', error); | |||
| 584 | } | 595 | } | ||
| 596 | + | }; | |||
| 585 | 597 | ||||
| 586 | - | this.connectSSE(); | 598 | + | this.sseClient = new SSEClient(config); |
| 587 | - | }, 1000); | 599 | + | this.sseClient.connect(); |
| 588 | } | 600 | } | ||
| 589 | 601 | ||||
| 590 | - | private async connectSSE() { | 602 | + | /** |
| 591 | - | if (this.isConnecting) { | 603 | + | * Handle incoming SSE messages |
| 592 | - | console.debug('Already connecting to SSE, skipping'); | 604 | + | */ |
| 593 | - | return; | 605 | + | private handleSSEMessage<T extends keyof SSEEventMap>(type: T, data: SSEEventMap[T]) { |
| 594 | - | } | 606 | + | switch (type) { |
| 595 | - | 607 | + | case 'chatMessage': | |
| 596 | - | this.isConnecting = true; | 608 | + | this.handleChatMessage(data as SSEEventMap['chatMessage']); |
| 597 | - | 609 | + | break; | |
| 598 | - | try { | 610 | + | case 'buddyListUpdate': |
| 599 | - | // Ensure clean disconnect of any existing connection | 611 | + | this.updateOnlineUsers(data as SSEEventMap['buddyListUpdate']); |
| 600 | - | if (this.eventSource) { | 612 | + | break; |
| 601 | - | console.debug('Closing existing SSE connection'); | 613 | + | case 'userStatusUpdate': { |
| 602 | - | this.eventSource.close(); | 614 | + | const statusData = data as SSEEventMap['userStatusUpdate']; |
| 603 | - | this.eventSource = null; | 615 | + | this.updateUserStatus(statusData.userId, statusData.status, statusData.lastSeen); |
| 616 | + | break; | |||
| 604 | } | 617 | } | ||
| 605 | - | 618 | + | case 'heartbeat': | |
| 606 | - | console.debug('Connecting to SSE...'); | 619 | + | // Heartbeat is handled by SSEClient, no action needed here |
| 607 | - | this.eventSource = new EventSource('/api/sse', { withCredentials: true }); | 620 | + | break; |
| 608 | - | // Reset the handlers flag for the new connection | 621 | + | default: |
| 609 | - | this.sseHandlersRegistered = false; | 622 | + | console.debug('Unknown SSE event type:', type); |
| 610 | - | ||||
| 611 | - | this.eventSource.onopen = () => { | |||
| 612 | - | console.debug('SSE connection established'); | |||
| 613 | - | this.reconnectAttempts = 0; | |||
| 614 | - | this.reconnectDelay = 1000; | |||
| 615 | - | this.sseError = null; | |||
| 616 | - | this.sseRetryAfter = null; | |||
| 617 | - | this.isReconnecting = false; | |||
| 618 | - | }; | |||
| 619 | - | ||||
| 620 | - | // Set up error handling | |||
| 621 | - | this.eventSource.onerror = (error) => { | |||
| 622 | - | console.debug('SSE connection error:', error); | |||
| 623 | - | if (this.eventSource?.readyState === EventSource.CLOSED) { | |||
| 624 | - | this.handleDisconnection(); | |||
| 625 | - | } | |||
| 626 | - | }; | |||
| 627 | - | ||||
| 628 | - | // Register event handlers (only once) | |||
| 629 | - | this.setupMessageHandlers(); | |||
| 630 | - | } catch (error) { | |||
| 631 | - | console.debug('Error setting up SSE connection:', error); | |||
| 632 | - | this.handleDisconnection(); | |||
| 633 | - | } finally { | |||
| 634 | - | this.isConnecting = false; | |||
| 635 | } | 623 | } | ||
| 636 | } | 624 | } | ||
| 637 | 625 | ||||
| 638 | - | // Register SSE event handlers only once per connection. | 626 | + | /** |
| 639 | - | private setupMessageHandlers() { | 627 | + | * Handle chat message from SSE |
| 640 | - | if (this.sseHandlersRegistered || !this.eventSource) { | 628 | + | */ |
| 641 | - | return; | 629 | + | private async handleChatMessage(messageData: Message) { |
| 642 | - | } | 630 | + | console.debug('Received chat message via SSE:', messageData); |
| 643 | 631 | ||||
| 644 | - | // Listen for chat messages | 632 | + | // Deduplicate and update messages array |
| 645 | - | this.eventSource.addEventListener('chatMessage', async (event: MessageEvent) => { | 633 | + | this.messages = Array.from( |
| 646 | - | try { | 634 | + | new Map([...this.messages, messageData].map((m) => [m.id, m])).values() |
| 647 | - | const messageData = JSON.parse(event.data) as Message; | 635 | + | ); |
| 648 | - | console.debug('Received chat message via SSE:', messageData); | 636 | + | this.messages.sort((a, b) => a.timestamp - b.timestamp); |
| 649 | - | // Deduplicate and update messages array with the new message | |||
| 650 | - | this.messages = Array.from( | |||
| 651 | - | new Map([...this.messages, messageData].map((m) => [m.id, m])).values() | |||
| 652 | - | ); | |||
| 653 | - | // Sort messages so the newest are at the bottom | |||
| 654 | - | this.messages.sort((a, b) => a.timestamp - b.timestamp); | |||
| 655 | - | // Ensure the sender's data is available | |||
| 656 | - | await this.ensureUserData(messageData.senderId); | |||
| 657 | - | } catch (error) { | |||
| 658 | - | console.debug('Error handling SSE chat message:', error); | |||
| 659 | - | } | |||
| 660 | - | }); | |||
| 661 | 637 | ||||
| 662 | - | // Modify buddy list update handler | 638 | + | // Ensure the sender's data is available |
| 663 | - | this.eventSource.addEventListener('buddyListUpdate', async (event: MessageEvent) => { | 639 | + | await this.ensureUserData(messageData.senderId); |
| 664 | - | try { | 640 | + | } |
| 665 | - | const buddyList = JSON.parse(event.data) as SafeUser[]; | |||
| 666 | - | this.updateOnlineUsers(buddyList); | |||
| 667 | - | } catch (error) { | |||
| 668 | - | console.debug('Error handling buddy list update via SSE:', error); | |||
| 669 | - | } | |||
| 670 | - | }); | |||
| 671 | 641 | ||||
| 672 | - | this.sseHandlersRegistered = true; | 642 | + | /** |
| 643 | + | * Handle connection status changes | |||
| 644 | + | */ | |||
| 645 | + | private handleConnectionStatusChange(status: ConnectionStatus) { | |||
| 646 | + | this._connectionStatus = status; | |||
| 647 | + | console.debug('SSE connection status changed:', status); | |||
| 648 | + | ||||
| 649 | + | if (status === 'connected') { | |||
| 650 | + | this._sseError = null; | |||
| 651 | + | } | |||
| 673 | } | 652 | } | ||
| 674 | 653 | ||||
| 654 | + | /** | |||
| 655 | + | * Ensure user data is loaded | |||
| 656 | + | */ | |||
| 675 | private async ensureUserData(userId: string) { | 657 | private async ensureUserData(userId: string) { | ||
| 676 | if (this.userCache[userId]) return; | 658 | if (this.userCache[userId]) return; | ||
| 677 | 659 | ||||
| @@ -694,29 +676,9 @@ class ChatState { | |||||
| 694 | } | 676 | } | ||
| 695 | } | 677 | } | ||
| 696 | 678 | ||||
| 697 | - | private handleDisconnection() { | 679 | + | /** |
| 698 | - | console.debug('SSE connection closed, reconnecting...'); | 680 | + | * Enrich messages with user data |
| 699 | - | this.isReconnecting = true; | 681 | + | */ |
| 700 | - | this.reconnectAttempts++; | |||
| 701 | - | if (this.reconnectAttempts > this.maxReconnectAttempts) { | |||
| 702 | - | console.debug('Max reconnect attempts reached, giving up'); | |||
| 703 | - | if (this.eventSource) { | |||
| 704 | - | this.eventSource.close(); | |||
| 705 | - | } | |||
| 706 | - | this.eventSource = null; | |||
| 707 | - | return; | |||
| 708 | - | } | |||
| 709 | - | this.reconnectDelay *= 2; // Exponential backoff | |||
| 710 | - | // Ensure any existing connection is properly closed before reconnecting | |||
| 711 | - | if (this.eventSource) { | |||
| 712 | - | this.eventSource.close(); | |||
| 713 | - | this.eventSource = null; | |||
| 714 | - | } | |||
| 715 | - | // Reset the handlers flag so that the new connection gets its event listeners registered fresh. | |||
| 716 | - | this.sseHandlersRegistered = false; | |||
| 717 | - | this.setupSSE(); | |||
| 718 | - | } | |||
| 719 | - | ||||
| 720 | enrichMessages(messages: Message[]): EnrichedMessage[] { | 682 | enrichMessages(messages: Message[]): EnrichedMessage[] { | ||
| 721 | console.debug('Enriching messages with user data', { | 683 | console.debug('Enriching messages with user data', { | ||
| 722 | messagesCount: messages.length, | 684 | messagesCount: messages.length, | ||
| @@ -734,9 +696,26 @@ class ChatState { | |||||
| 734 | })); | 696 | })); | ||
| 735 | } | 697 | } | ||
| 736 | 698 | ||||
| 699 | + | /** | |||
| 700 | + | * Get the default chat room ID | |||
| 701 | + | */ | |||
| 737 | getDefaultChatRoomId() { | 702 | getDefaultChatRoomId() { | ||
| 738 | return DEFAULT_CHAT_ROOM_ID; | 703 | return DEFAULT_CHAT_ROOM_ID; | ||
| 739 | } | 704 | } | ||
| 705 | + | ||||
| 706 | + | /** | |||
| 707 | + | * Disconnect SSE (for cleanup) | |||
| 708 | + | */ | |||
| 709 | + | disconnect() { | |||
| 710 | + | if (this.sseClient) { | |||
| 711 | + | this.sseClient.disconnect(); | |||
| 712 | + | this.sseClient = null; | |||
| 713 | + | } | |||
| 714 | + | if (this.publicPollingInterval) { | |||
| 715 | + | clearInterval(this.publicPollingInterval); | |||
| 716 | + | this.publicPollingInterval = null; | |||
| 717 | + | } | |||
| 718 | + | } | |||
| 740 | } | 719 | } | ||
| 741 | 720 | ||||
| 742 | export const chatState = new ChatState(); | 721 | export const chatState = new ChatState(); | ||
M src/routes/+layout.svelte +5 -4
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | import { chatState } from '$lib/states/chat.svelte'; | |
| 4 | 4 | import { onMount } from 'svelte'; | |
| 5 | 5 | import { browser } from '$app/environment'; | |
| 6 | + | import { SSE_CONFIG } from '$lib/sse/config'; | |
| 6 | 7 | ||
| 7 | 8 | let { children, data } = $props(); | |
| 8 | 9 | let isVisible = $state(true); | |
| @@ -11,14 +12,14 @@ | |||
| 11 | 12 | let updateInProgress = $state(false); | |
| 12 | 13 | let idleTimeout = $state<number | undefined>(undefined); | |
| 13 | 14 | ||
| 14 | - | // New throttle state variables | |
| 15 | + | // Timing constants from SSE config for consistency | |
| 15 | 16 | let lastSentStatus = $state<string | null>(null); | |
| 16 | 17 | let lastStatusSentAt = $state<number>(0); | |
| 17 | 18 | let lastUserActivity = $state<number>(Date.now()); | |
| 18 | - | const STATUS_UPDATE_INTERVAL = 15000; // 15 seconds | |
| 19 | + | const STATUS_UPDATE_INTERVAL = SSE_CONFIG.STATUS_UPDATE_INTERVAL; // 30 seconds | |
| 19 | 20 | const THROTTLE_TIME_MS = 10000; // 10 seconds | |
| 20 | - | const FORCE_UPDATE_INTERVAL = 5 * 60000; // 5 minutes | |
| 21 | - | const IDLE_TIMEOUT = 5 * 60000; // 5 minutes of inactivity = idle | |
| 21 | + | const FORCE_UPDATE_INTERVAL = SSE_CONFIG.CONNECTION_TIMEOUT; // 5 minutes | |
| 22 | + | const IDLE_TIMEOUT = SSE_CONFIG.IDLE_TIMEOUT; // 2 minutes of inactivity = idle (aligned with server) | |
| 22 | 23 | ||
| 23 | 24 | type UserStatus = 'online' | 'offline' | 'busy' | 'idle'; | |
| 24 | 25 | ||
| @@ -3,6 +3,7 @@ | |||||
| 3 | import { chatState } from '$lib/states/chat.svelte'; | 3 | import { chatState } from '$lib/states/chat.svelte'; | ||
| 4 | import { onMount } from 'svelte'; | 4 | import { onMount } from 'svelte'; | ||
| 5 | import { browser } from '$app/environment'; | 5 | import { browser } from '$app/environment'; | ||
| 6 | + | import { SSE_CONFIG } from '$lib/sse/config'; | |||
| 6 | 7 | ||||
| 7 | let { children, data } = $props(); | 8 | let { children, data } = $props(); | ||
| 8 | let isVisible = $state(true); | 9 | let isVisible = $state(true); | ||
| @@ -11,14 +12,14 @@ | |||||
| 11 | let updateInProgress = $state(false); | 12 | let updateInProgress = $state(false); | ||
| 12 | let idleTimeout = $state<number | undefined>(undefined); | 13 | let idleTimeout = $state<number | undefined>(undefined); | ||
| 13 | 14 | ||||
| 14 | - | // New throttle state variables | 15 | + | // Timing constants from SSE config for consistency |
| 15 | let lastSentStatus = $state<string | null>(null); | 16 | let lastSentStatus = $state<string | null>(null); | ||
| 16 | let lastStatusSentAt = $state<number>(0); | 17 | let lastStatusSentAt = $state<number>(0); | ||
| 17 | let lastUserActivity = $state<number>(Date.now()); | 18 | let lastUserActivity = $state<number>(Date.now()); | ||
| 18 | - | const STATUS_UPDATE_INTERVAL = 15000; // 15 seconds | 19 | + | const STATUS_UPDATE_INTERVAL = SSE_CONFIG.STATUS_UPDATE_INTERVAL; // 30 seconds |
| 19 | const THROTTLE_TIME_MS = 10000; // 10 seconds | 20 | const THROTTLE_TIME_MS = 10000; // 10 seconds | ||
| 20 | - | const FORCE_UPDATE_INTERVAL = 5 * 60000; // 5 minutes | 21 | + | const FORCE_UPDATE_INTERVAL = SSE_CONFIG.CONNECTION_TIMEOUT; // 5 minutes |
| 21 | - | const IDLE_TIMEOUT = 5 * 60000; // 5 minutes of inactivity = idle | 22 | + | const IDLE_TIMEOUT = SSE_CONFIG.IDLE_TIMEOUT; // 2 minutes of inactivity = idle (aligned with server) |
| 22 | 23 | ||||
| 23 | type UserStatus = 'online' | 'offline' | 'busy' | 'idle'; | 24 | type UserStatus = 'online' | 'offline' | 'busy' | 'idle'; | ||
| 24 | 25 | ||||
M src/routes/api/chat/messages/+server.ts +2 -2
| @@ -10,7 +10,7 @@ import type { | |||
| 10 | 10 | import { and, desc, eq, lt } from 'drizzle-orm/sql'; | |
| 11 | 11 | import type { SQL } from 'drizzle-orm/sql'; | |
| 12 | 12 | import { error } from '@sveltejs/kit'; | |
| 13 | - | import { sseEmitter } from '$lib/sseEmitter'; | |
| 13 | + | import { sseEmitter } from '$lib/sse/SSEEventEmitter'; | |
| 14 | 14 | import { chatRooms } from '$lib/db/schema'; | |
| 15 | 15 | import { users } from '$lib/db/schema'; | |
| 16 | 16 | import { DEFAULT_CHAT_ROOM_ID } from '$lib/utils/chat.server'; | |
| @@ -220,7 +220,7 @@ export async function POST({ request, locals }: { request: Request; locals: App. | |||
| 220 | 220 | }); | |
| 221 | 221 | ||
| 222 | 222 | // Broadcast the new message via SSE | |
| 223 | - | sseEmitter.emit('sse', { type: 'chatMessage', data: newMessage }); | |
| 223 | + | sseEmitter.emit('chatMessage', newMessage); | |
| 224 | 224 | ||
| 225 | 225 | log.debug('Message processed successfully', { | |
| 226 | 226 | messageId: newMessage.id, | |
| @@ -10,7 +10,7 @@ import type { | |||||
| 10 | import { and, desc, eq, lt } from 'drizzle-orm/sql'; | 10 | import { and, desc, eq, lt } from 'drizzle-orm/sql'; | ||
| 11 | import type { SQL } from 'drizzle-orm/sql'; | 11 | import type { SQL } from 'drizzle-orm/sql'; | ||
| 12 | import { error } from '@sveltejs/kit'; | 12 | import { error } from '@sveltejs/kit'; | ||
| 13 | - | import { sseEmitter } from '$lib/sseEmitter'; | 13 | + | import { sseEmitter } from '$lib/sse/SSEEventEmitter'; |
| 14 | import { chatRooms } from '$lib/db/schema'; | 14 | import { chatRooms } from '$lib/db/schema'; | ||
| 15 | import { users } from '$lib/db/schema'; | 15 | import { users } from '$lib/db/schema'; | ||
| 16 | import { DEFAULT_CHAT_ROOM_ID } from '$lib/utils/chat.server'; | 16 | import { DEFAULT_CHAT_ROOM_ID } from '$lib/utils/chat.server'; | ||
| @@ -220,7 +220,7 @@ export async function POST({ request, locals }: { request: Request; locals: App. | |||||
| 220 | }); | 220 | }); | ||
| 221 | 221 | ||||
| 222 | // Broadcast the new message via SSE | 222 | // Broadcast the new message via SSE | ||
| 223 | - | sseEmitter.emit('sse', { type: 'chatMessage', data: newMessage }); | 223 | + | sseEmitter.emit('chatMessage', newMessage); |
| 224 | 224 | ||||
| 225 | log.debug('Message processed successfully', { | 225 | log.debug('Message processed successfully', { | ||
| 226 | messageId: newMessage.id, | 226 | messageId: newMessage.id, | ||