detached 6c2663e

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 UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The 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+
18 import type { User, Message, EnrichedMessage, SafeUser } from '../types/chat';
29 import { createSafeUser } from '../types/chat';
310 import type {
@@ -8,38 +15,53 @@ import type {
815 import type { TextStyle } from '../types/text-formatting';
916 import { invalidate } from '$app/navigation';
1017 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';
1121
1222 // Use the public environment variable with a fallback
1323 const DEFAULT_CHAT_ROOM_ID =
1424 env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001';
1525
1626 class ChatState {
27+ // User and message state
1728 private users = $state<SafeUser[]>([]);
1829 private messages = $state<Message[]>([]);
1930 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);
2531 private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID);
2632 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);
3144 private isSettingUser = $state(false);
32- private isConnecting = $state(false);
33- private connectionAttemptTimeout: ReturnType<typeof setTimeout> | null = null;
45+
46+ // Memoization for enriched messages
3447 private _memoizedEnrichedMessages: EnrichedMessage[] | null = null;
3548 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
3951 private lastBuddyListHash = '';
40- private hasMoreMessages = $state(false);
52+
53+ // Public polling for unauthenticated users
4154 private publicPollingInterval: ReturnType<typeof setInterval> | null = null;
4255
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+ */
4365 public async reinitialize() {
4466 if (this.isInitializing) {
4567 console.debug('Reinitialize already in progress, skipping.');
@@ -47,43 +69,54 @@ class ChatState {
4769 }
4870 this.isInitializing = true;
4971 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;
5377 }
54- this.reconnectAttempts = 0;
55- this.reconnectDelay = 1000;
78+
5679 this.messages = [];
57- // Ensure currentRoomId is set
5880 this.currentRoomId = DEFAULT_CHAT_ROOM_ID;
5981
6082 if (this.currentUser) {
6183 console.debug('Waiting for session cookie update before setting up SSE...');
6284 await new Promise((resolve) => setTimeout(resolve, 1000));
63- // First set up SSE to receive status updates
85+
86+ // Set up SSE connection
6487 this.setupSSE();
65- // Then initialize messages and room users
88+
89+ // Initialize messages and room users
6690 await Promise.all([this.initializeMessages(), this.initializeRoomUsers()]);
6791 }
92+
6893 this.isInitializing = false;
6994 }
7095
96+ /**
97+ * Get SSE error information
98+ */
7199 getSSEError() {
72100 return {
73- error: this.sseError,
74- retryAfter: this.sseRetryAfter
101+ error: this._sseError,
102+ status: this._connectionStatus,
103+ retryAfter: this.sseClient?.getReconnectDelay() ?? null
75104 };
76105 }
77106
78- // User methods
107+ /**
108+ * Get the current user
109+ */
79110 getCurrentUser() {
80111 return this.currentUser;
81112 }
82113
114+ /**
115+ * Set the current user
116+ */
83117 async setCurrentUser(user: User | SafeUser | null) {
84118 console.debug('setCurrentUser called with', user ? { ...user, password: '[REDACTED]' } : null);
85119
86- // Prevent concurrent setCurrentUser calls
87120 if (this.isSettingUser) {
88121 console.debug('Already setting user, skipping');
89122 return;
@@ -92,18 +125,16 @@ class ChatState {
92125 this.isSettingUser = true;
93126
94127 try {
95- // If user is null and we already have no user, skip
96128 if (!user && !this.currentUser) {
97129 console.debug('No user to set and no current user, skipping');
98130 return;
99131 }
100132
101- // Convert to SafeUser if full User object is passed
102133 const safeUser = user ? ('password' in user ? createSafeUser(user) : user) : null;
103134
104- // If same user is being set, check SSE connection
135+ // Check if same user with active connection
105136 if (safeUser?.id === this.currentUser?.id) {
106- if (!this.eventSource) {
137+ if (!this.sseClient || !this.sseClient.isConnected) {
107138 console.debug('Same user being set but no active SSE connection, reinitializing.');
108139 } else {
109140 console.debug('Same user being set with active connection, skipping.');
@@ -111,31 +142,31 @@ class ChatState {
111142 }
112143 }
113144
114- // Clear existing messages before setting new user
115145 this.messages = [];
116146 this.currentUser = safeUser;
117147
118148 if (safeUser) {
119- // Update user cache with current user
120149 this.userCache[safeUser.id] = safeUser;
121-
122- // Only reinitialize if we have a valid user
123150 await this.reinitialize();
124151 } else {
125152 // 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;
129156 }
130157 this.messages = [];
131158 this.users = [];
132159 this.userCache = {};
160+ this._connectionStatus = 'disconnected';
133161 }
134162 } finally {
135163 this.isSettingUser = false;
136164 }
137165 }
138166
167+ /**
168+ * Initialize room users
169+ */
139170 private async initializeRoomUsers() {
140171 console.debug('Initializing room users for room:', this.currentRoomId);
141172 try {
@@ -143,9 +174,7 @@ class ChatState {
143174 const url = `/api/rooms/${this.currentRoomId}${isPublic ? '?public=true' : ''}`;
144175 console.debug('Fetching room users from:', url);
145176
146- const response = await fetch(url, {
147- credentials: 'include'
148- });
177+ const response = await fetch(url, { credentials: 'include' });
149178
150179 if (!response.ok) {
151180 throw new Error('Failed to fetch room users');
@@ -157,20 +186,14 @@ class ChatState {
157186 }
158187
159188 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));
165189
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));
170195
171196 this.users = Array.from(mergedUsers.values());
172-
173- // Update cache with merged users
174197 this.users.forEach((user) => {
175198 this.userCache[user.id] = user;
176199 });
@@ -180,8 +203,10 @@ class ChatState {
180203 }
181204 }
182205
206+ /**
207+ * Get online users sorted by status
208+ */
183209 getOnlineUsers() {
184- // Sort users by status: online first, then away, then busy, then offline
185210 return [...this.users].sort((a: SafeUser, b: SafeUser) => {
186211 const statusOrder: Record<SafeUser['status'], number> = {
187212 online: 0,
@@ -193,13 +218,14 @@ class ChatState {
193218 });
194219 }
195220
221+ /**
222+ * Get user by ID
223+ */
196224 getUserById(userId: string) {
197- // First check the cache
198225 if (this.userCache[userId]) {
199226 return this.userCache[userId];
200227 }
201228
202- // Return a fallback object and cache it to avoid repeated fetches
203229 const fallback: SafeUser = {
204230 id: userId,
205231 nickname: 'Unknown User',
@@ -212,6 +238,9 @@ class ChatState {
212238 return fallback;
213239 }
214240
241+ /**
242+ * Update user status
243+ */
215244 updateUserStatus(userId: string, status: User['status'], lastSeen?: number) {
216245 console.debug('Updating user status with:', {
217246 userId,
@@ -226,16 +255,14 @@ class ChatState {
226255 existingUser &&
227256 existingUser.status === status &&
228257 lastSeen &&
229- Math.abs(existingUser.lastSeen ?? 0 - lastSeen) < 5000
258+ Math.abs((existingUser.lastSeen ?? 0) - lastSeen) < 5000
230259 ) {
231260 console.debug('Skipping redundant status update for user:', userId);
232261 return;
233262 }
234263
235- // Try to get the user from cache first
236264 let user: SafeUser | undefined = this.userCache[userId];
237265
238- // If not in cache, try to find in users array
239266 if (!user) {
240267 const foundUser = this.users.find((u: SafeUser) => u.id === userId);
241268 if (foundUser) {
@@ -251,13 +278,10 @@ class ChatState {
251278 lastSeen: lastSeen ?? (status === 'offline' ? now : user.lastSeen)
252279 };
253280
254- // Update cache first
255281 this.userCache[userId] = updatedUser;
256282
257- // Then update or add to users array
258283 const index = this.users.findIndex((u: SafeUser) => u.id === userId);
259284 if (index !== -1) {
260- // Create new array with updated user
261285 this.users = [...this.users.slice(0, index), updatedUser, ...this.users.slice(index + 1)];
262286 } else {
263287 this.users = [...this.users, updatedUser];
@@ -266,12 +290,9 @@ class ChatState {
266290 console.debug('User status updated:', {
267291 userId,
268292 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()
272294 });
273295 } else {
274- // If user not found anywhere, fetch from API
275296 console.debug('User not found in cache or list, fetching from API:', userId);
276297 fetch(`/api/users/${userId}`, { credentials: 'include' })
277298 .then((response) => response.json())
@@ -282,16 +303,12 @@ class ChatState {
282303 status,
283304 lastSeen: lastSeen ?? (status === 'offline' ? now : data.user.lastSeen)
284305 };
285- // Update both cache and users array
286306 this.userCache[userId] = newUser;
287307 this.users = [...this.users, newUser];
288308
289309 console.debug('User fetched and status updated:', {
290310 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
295312 });
296313 }
297314 })
@@ -299,7 +316,9 @@ class ChatState {
299316 }
300317 }
301318
302- // Message methods
319+ /**
320+ * Get enriched messages with user data
321+ */
303322 getMessages() {
304323 const key =
305324 this.messages.map((m) => m.id).join(',') + '|' + Object.keys(this.userCache).sort().join(',');
@@ -315,13 +334,15 @@ class ChatState {
315334 return this._memoizedEnrichedMessages;
316335 }
317336
337+ /**
338+ * Prepend messages (for loading older messages)
339+ */
318340 public prependMessages(newMessages: Message[], hasMore?: boolean) {
319341 console.debug('Prepending messages:', { count: newMessages.length });
320- // Get unique user IDs from new messages that aren't in the cache
342+
321343 const uniqueUserIds = new Set(newMessages.map((msg) => msg.senderId));
322344 const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]);
323345
324- // Fetch missing user data in parallel
325346 if (missingUserIds.length > 0) {
326347 console.debug('Fetching missing user data for IDs:', missingUserIds);
327348 Promise.all(
@@ -334,7 +355,6 @@ class ChatState {
334355 const userData = await userResponse.json();
335356 if (userData.success && userData.user) {
336357 this.userCache[userId] = userData.user;
337- // Only add to users array if not already present
338358 if (!this.users.some((u) => u.id === userId)) {
339359 this.users = [...this.users, userData.user];
340360 }
@@ -347,31 +367,29 @@ class ChatState {
347367 );
348368 }
349369
350- // Ensure incoming messages are sorted in ascending order before prepending
351370 const sortedNewMessages = newMessages.slice().sort((a, b) => a.timestamp - b.timestamp);
352371 const uniqueNewMessages = sortedNewMessages.filter(
353372 (msg) => !this.messages.some((m) => m.id === msg.id)
354373 );
355374 this.messages = [...uniqueNewMessages, ...this.messages];
356375
357- // Update hasMore if provided
358376 if (hasMore !== undefined) {
359377 this.hasMoreMessages = hasMore;
360378 }
361379 }
362380
363- // Add method to update user cache for public access
381+ /**
382+ * Update user cache
383+ */
364384 updateUserCache(users: (User | SafeUser)[]) {
365385 console.debug(
366386 'Updating user cache with users:',
367387 users.map((u) => ({ ...u, password: '[REDACTED]' }))
368388 );
369- // Merge incoming users with the existing cache, ensuring we only store SafeUser objects
370389 users.forEach((user) => {
371390 const safeUser = 'password' in user ? createSafeUser(user) : user;
372391 if (!this.userCache[user.id] || safeUser.status !== this.userCache[user.id].status) {
373392 this.userCache[user.id] = safeUser;
374- // Update users array if not already present or if status changed
375393 const existingIndex = this.users.findIndex((u) => u.id === user.id);
376394 if (existingIndex === -1) {
377395 this.users = [...this.users, safeUser];
@@ -386,14 +404,14 @@ class ChatState {
386404 });
387405 }
388406
389- // Add method to update online users for public access
407+ /**
408+ * Update online users (from buddy list updates)
409+ */
390410 updateOnlineUsers(users: (User | SafeUser)[]) {
391411 const now = Date.now();
392- // Convert all users to SafeUser and create a hash
393412 const safeUsers = users.map((user) => ('password' in user ? createSafeUser(user) : user));
394413 const newHash = JSON.stringify(safeUsers);
395414
396- // Only update if the data has changed
397415 if (newHash !== this.lastBuddyListHash) {
398416 const statusCounts = safeUsers.reduce(
399417 (acc, user) => {
@@ -410,28 +428,27 @@ class ChatState {
410428 });
411429
412430 this.users = safeUsers;
413- // Also update cache
414431 safeUsers.forEach((user) => {
415432 this.userCache[user.id] = user;
416433 });
417434 this.lastBuddyListHash = newHash;
418- this.lastBuddyListUpdate = now;
419435 }
420436 }
421437
422- // Add method to update messages for public access
438+ /**
439+ * Update messages (for public/unauthenticated access)
440+ */
423441 updateMessages(messages: Message[]) {
424- // Deduplicate the messages by their id in case duplicates exist
425442 const uniqueMessages = Array.from(new Map(messages.map((msg) => [msg.id, msg])).values());
426- // Sort messages in ascending order (oldest first, newest at bottom)
427443 this.messages = uniqueMessages.sort((a, b) => a.timestamp - b.timestamp);
428444 }
429445
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+ */
431449 async initializeMessages() {
432450 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
435452 const params = new URLSearchParams();
436453 if (!this.currentUser) {
437454 params.append('public', 'true');
@@ -448,11 +465,9 @@ class ChatState {
448465 throw new Error(data.error);
449466 }
450467
451- // Get unique user IDs from messages that aren't in the cache
452468 const uniqueUserIds = new Set(data.messages.map((msg) => msg.senderId));
453469 const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]);
454470
455- // Fetch missing user data in parallel
456471 if (missingUserIds.length > 0) {
457472 console.debug('Fetching missing user data for IDs:', missingUserIds);
458473 await Promise.all(
@@ -465,7 +480,6 @@ class ChatState {
465480 const userData = await userResponse.json();
466481 if (userData.success && userData.user) {
467482 this.userCache[userId] = userData.user;
468- // Only add to users array if not already present
469483 if (!this.users.some((u) => u.id === userId)) {
470484 this.users = [...this.users, userData.user];
471485 }
@@ -478,10 +492,8 @@ class ChatState {
478492 );
479493 }
480494
481- // Sort messages in ascending order (oldest first, most recent at the bottom)
482495 this.messages = data.messages.slice().sort((a, b) => a.timestamp - b.timestamp);
483496
484- // Update hasMore based on response
485497 if ('hasMore' in data) {
486498 this.hasMoreMessages = data.hasMore;
487499 }
@@ -498,6 +510,9 @@ class ChatState {
498510 }
499511 }
500512
513+ /**
514+ * Send a message
515+ */
501516 async sendMessage(
502517 content: string,
503518 type: Message['type'] = 'chat',
@@ -554,124 +569,91 @@ class ChatState {
554569 }
555570 }
556571
572+ /**
573+ * Set up SSE connection using the new SSEClient
574+ */
557575 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) {
560577 console.debug('setupSSE: Active SSE connection exists, skipping setup.');
561578 return;
562579 }
563- // Clear any existing timeouts
564- if (this.connectionTimeout) {
565- clearTimeout(this.connectionTimeout);
566- this.connectionTimeout = null;
567- }
568580
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;
572584 }
573585
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...');
580587
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);
584595 }
596+ };
585597
586- this.connectSSE();
587- }, 1000);
598+ this.sseClient = new SSEClient(config);
599+ this.sseClient.connect();
588600 }
589601
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;
604617 }
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);
635623 }
636624 }
637625
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);
643631
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);
661637
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+ }
671641
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+ }
673652 }
674653
654+ /**
655+ * Ensure user data is loaded
656+ */
675657 private async ensureUserData(userId: string) {
676658 if (this.userCache[userId]) return;
677659
@@ -694,29 +676,9 @@ class ChatState {
694676 }
695677 }
696678
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+ */
720682 enrichMessages(messages: Message[]): EnrichedMessage[] {
721683 console.debug('Enriching messages with user data', {
722684 messagesCount: messages.length,
@@ -734,9 +696,26 @@ class ChatState {
734696 }));
735697 }
736698
699+ /**
700+ * Get the default chat room ID
701+ */
737702 getDefaultChatRoomId() {
738703 return DEFAULT_CHAT_ROOM_ID;
739704 }
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+ }
740719 }
741720
742721 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 fallback22 // 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 handlers49+
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 updates85+
86+ // Set up SSE connection
64 this.setupSSE();87 this.setupSSE();
65- // Then initialize messages and room users88+
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.sseRetryAfter102+ status: this._connectionStatus,
103+ retryAfter: this.sseClient?.getReconnectDelay() ?? null
75 };104 };
76 }105 }
77 106
78- // User methods107+ /**
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 connection135+ // 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 removed152 // 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 list190+ // 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) < 5000258+ 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 methods319+ /**
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 cache342+
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 access381+ /**
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 access407+ /**
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 access438+ /**
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 parameters451+ // 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 attempts586+ 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 connection611+ 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 connection621+ 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 messages632+ // 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 handler638+ // 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 @@
33 import { chatState } from '$lib/states/chat.svelte';
44 import { onMount } from 'svelte';
55 import { browser } from '$app/environment';
6+ import { SSE_CONFIG } from '$lib/sse/config';
67
78 let { children, data } = $props();
89 let isVisible = $state(true);
@@ -11,14 +12,14 @@
1112 let updateInProgress = $state(false);
1213 let idleTimeout = $state<number | undefined>(undefined);
1314
14- // New throttle state variables
15+ // Timing constants from SSE config for consistency
1516 let lastSentStatus = $state<string | null>(null);
1617 let lastStatusSentAt = $state<number>(0);
1718 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
1920 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)
2223
2324 type UserStatus = 'online' | 'offline' | 'busy' | 'idle';
2425
@@ -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 variables15+ // 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 seconds19+ const STATUS_UPDATE_INTERVAL = SSE_CONFIG.STATUS_UPDATE_INTERVAL; // 30 seconds
19 const THROTTLE_TIME_MS = 10000; // 10 seconds20 const THROTTLE_TIME_MS = 10000; // 10 seconds
20- const FORCE_UPDATE_INTERVAL = 5 * 60000; // 5 minutes21+ const FORCE_UPDATE_INTERVAL = SSE_CONFIG.CONNECTION_TIMEOUT; // 5 minutes
21- const IDLE_TIMEOUT = 5 * 60000; // 5 minutes of inactivity = idle22+ 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 {
1010 import { and, desc, eq, lt } from 'drizzle-orm/sql';
1111 import type { SQL } from 'drizzle-orm/sql';
1212 import { error } from '@sveltejs/kit';
13-import { sseEmitter } from '$lib/sseEmitter';
13+import { sseEmitter } from '$lib/sse/SSEEventEmitter';
1414 import { chatRooms } from '$lib/db/schema';
1515 import { users } from '$lib/db/schema';
1616 import { DEFAULT_CHAT_ROOM_ID } from '$lib/utils/chat.server';
@@ -220,7 +220,7 @@ export async function POST({ request, locals }: { request: Request; locals: App.
220220 });
221221
222222 // Broadcast the new message via SSE
223- sseEmitter.emit('sse', { type: 'chatMessage', data: newMessage });
223+ sseEmitter.emit('chatMessage', newMessage);
224224
225225 log.debug('Message processed successfully', {
226226 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 SSE222 // 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,

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close