1import type { User, Message, EnrichedMessage } from '../types/chat';
2import type {
3 SendMessageRequest,
4 SendMessageResponse,
5 GetMessagesResponse
6} from '../types/payloads';
7import { invalidate } from '$app/navigation';
8import { env } from '$env/dynamic/public';
9
10// Use the public environment variable with a fallback
11const DEFAULT_CHAT_ROOM_ID = env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001';
12
13class ChatState {
14 private users = $state<User[]>([]);
15 private messages = $state<Message[]>([]);
16 private currentUser = $state<User | null>(null);
17 private eventSource: EventSource | null = null;
18 private reconnectAttempts = 0;
19 private maxReconnectAttempts = 5;
20 private reconnectDelay = 1000; // Start with 1 second
21 private isInitializing = $state(false);
22 private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID);
23 private userCache = $state<Record<string, User>>({});
24 private sseError = $state<string | null>(null);
25 private sseRetryAfter = $state<number | null>(null);
26 private connectionTimeout: ReturnType<typeof setTimeout> | null = null;
27 private isReconnecting = $state(false);
28 private isSettingUser = $state(false);
29 private isConnecting = $state(false);
30 private connectionAttemptTimeout: ReturnType<typeof setTimeout> | null = null;
31 private _memoizedEnrichedMessages: EnrichedMessage[] | null = null;
32 private _lastEnrichmentKey: string = '';
33 // Flag to avoid re-registering SSE event handlers
34 private sseHandlersRegistered = false;
35 private lastBuddyListUpdate = 0;
36 private lastBuddyListHash = '';
37 private hasMoreMessages = $state(false);
38
39 public async reinitialize() {
40 if (this.isInitializing) {
41 console.debug('Reinitialize already in progress, skipping.');
42 return;
43 }
44 this.isInitializing = true;
45 console.debug('Reinitializing chat state...');
46 if (this.eventSource) {
47 this.eventSource.close();
48 this.eventSource = null;
49 }
50 this.reconnectAttempts = 0;
51 this.reconnectDelay = 1000;
52 this.messages = [];
53 // Ensure currentRoomId is set
54 this.currentRoomId = DEFAULT_CHAT_ROOM_ID;
55
56 if (this.currentUser) {
57 console.debug('Waiting for session cookie update before setting up SSE...');
58 await new Promise(resolve => setTimeout(resolve, 1000));
59 // First set up SSE to receive status updates
60 this.setupSSE();
61 // Then initialize messages and room users
62 await Promise.all([
63 this.initializeMessages(),
64 this.initializeRoomUsers()
65 ]);
66 }
67 this.isInitializing = false;
68 }
69
70 getSSEError() {
71 return {
72 error: this.sseError,
73 retryAfter: this.sseRetryAfter
74 };
75 }
76
77 // User methods
78 getCurrentUser() {
79 return this.currentUser;
80 }
81
82 async setCurrentUser(user: User | null) {
83 console.debug('setCurrentUser called with', user);
84
85 // Prevent concurrent setCurrentUser calls
86 if (this.isSettingUser) {
87 console.debug('Already setting user, skipping');
88 return;
89 }
90
91 this.isSettingUser = true;
92
93 try {
94 // If user is null and we already have no user, skip
95 if (!user && !this.currentUser) {
96 console.debug('No user to set and no current user, skipping');
97 return;
98 }
99
100 // If same user is being set, check SSE connection
101 if (user?.id === this.currentUser?.id) {
102 if (!this.eventSource) {
103 console.debug('Same user being set but no active SSE connection, reinitializing.');
104 } else {
105 console.debug('Same user being set with active connection, skipping.');
106 return;
107 }
108 }
109
110 // Clear existing messages before setting new user
111 this.messages = [];
112 this.currentUser = user;
113
114 if (user) {
115 // Update user cache with current user
116 this.userCache[user.id] = user;
117
118 // Only reinitialize if we have a valid user
119 await this.reinitialize();
120 } else {
121 // Cleanup when user is removed
122 if (this.eventSource) {
123 this.eventSource.close();
124 this.eventSource = null;
125 }
126 this.messages = [];
127 this.users = [];
128 this.userCache = {};
129 }
130 } finally {
131 this.isSettingUser = false;
132 }
133 }
134
135 private async initializeRoomUsers() {
136 console.debug('Initializing room users for room:', this.currentRoomId);
137 try {
138 const isPublic = !this.currentUser;
139 const url = `/api/rooms/${this.currentRoomId}${isPublic ? '?public=true' : ''}`;
140 console.debug('Fetching room users from:', url);
141
142 const response = await fetch(url, {
143 credentials: 'include'
144 });
145
146 if (!response.ok) {
147 throw new Error('Failed to fetch room users');
148 }
149
150 const data = await response.json();
151 if (!data.success) {
152 throw new Error(data.error);
153 }
154
155 console.debug('Fetched room buddy list:', data.buddyList);
156 // Merge fetched buddy list with existing cached users to preserve user details even if offline
157 const mergedUsers = new Map<string, typeof data.buddyList[0]>();
158 // Add existing cached users
159 Object.values(this.userCache).forEach((user) => mergedUsers.set(user.id, user));
160
161 // Overwrite/add with fetched buddy list
162 data.buddyList.forEach((user: typeof data.buddyList[0]) => {
163 mergedUsers.set(user.id, user);
164 });
165
166 this.users = Array.from(mergedUsers.values());
167
168 // Update cache with merged users
169 this.users.forEach(user => {
170 this.userCache[user.id] = user;
171 });
172 } catch (error) {
173 console.debug('Error fetching room users:', error);
174 this.users = [];
175 }
176 }
177
178 getOnlineUsers() {
179 // Sort users by status: online first, then away, then busy, then offline
180 return [...this.users].sort((a: User, b: User) => {
181 const statusOrder: Record<User['status'], number> = {
182 'online': 0,
183 'away': 1,
184 'busy': 2,
185 'offline': 3
186 };
187 return statusOrder[a.status] - statusOrder[b.status];
188 });
189 }
190
191 getUserById(userId: string) {
192 // First check the cache
193 if (this.userCache[userId]) {
194 return this.userCache[userId];
195 }
196
197 // Return a fallback object and cache it to avoid repeated fetches
198 const fallback = {
199 id: userId,
200 nickname: 'Unknown User',
201 status: 'offline',
202 password: '', // system user, no password needed
203 createdAt: Date.now(),
204 lastSeen: null,
205 avatarUrl: null
206 };
207 this.userCache[userId] = fallback;
208 console.debug('getUserById: returning fallback for missing user', { userId, fallback });
209 return fallback;
210 }
211
212 updateUserStatus(userId: string, status: User['status'], lastSeen?: number) {
213 console.debug('Updating user status with:', {
214 userId,
215 status,
216 lastSeen,
217 currentCache: this.userCache[userId],
218 currentUsers: this.users.find(u => u.id === userId)
219 });
220
221 const existingUser = this.userCache[userId];
222 if (
223 existingUser &&
224 existingUser.status === status &&
225 lastSeen &&
226 Math.abs(existingUser.lastSeen ?? 0 - lastSeen) < 5000
227 ) {
228 console.debug('Skipping redundant status update for user:', userId);
229 return;
230 }
231
232 // Try to get the user from cache first
233 let user: User | undefined = this.userCache[userId];
234
235 // If not in cache, try to find in users array
236 if (!user) {
237 const foundUser = this.users.find((u: User) => u.id === userId);
238 if (foundUser) {
239 user = foundUser;
240 }
241 }
242
243 const now = Date.now();
244 if (user) {
245 const updatedUser: User = {
246 ...user,
247 status,
248 // Update lastSeen based on status
249 lastSeen: lastSeen ?? (status === 'offline' ? now : user.lastSeen)
250 };
251
252 // Update cache first
253 this.userCache[userId] = updatedUser;
254
255 // Then update or add to users array
256 const index = this.users.findIndex((u: User) => u.id === userId);
257 if (index !== -1) {
258 // Create new array with updated user
259 this.users = [
260 ...this.users.slice(0, index),
261 updatedUser,
262 ...this.users.slice(index + 1)
263 ];
264 } else {
265 this.users = [...this.users, updatedUser];
266 }
267
268 console.debug('User status updated:', {
269 userId,
270 newStatus: status,
271 newLastSeen: new Date(updatedUser.lastSeen || now).toISOString(),
272 updatedInCache: this.userCache[userId].status === status,
273 updatedInList: this.users.find(u => u.id === userId)?.status === status
274 });
275 } else {
276 // If user not found anywhere, fetch from API
277 console.debug('User not found in cache or list, fetching from API:', userId);
278 fetch(`/api/users/${userId}`, { credentials: 'include' })
279 .then(response => response.json())
280 .then(data => {
281 if (data.success && data.user) {
282 const newUser: User = {
283 ...data.user,
284 status,
285 lastSeen: lastSeen ?? (status === 'offline' ? now : data.user.lastSeen)
286 };
287 // Update both cache and users array
288 this.userCache[userId] = newUser;
289 this.users = [...this.users, newUser];
290
291 console.debug('User fetched and status updated:', {
292 userId,
293 status: newUser.status,
294 lastSeen: new Date(newUser.lastSeen || now).toISOString(),
295 addedToCache: !!this.userCache[userId],
296 addedToList: this.users.some(u => u.id === userId)
297 });
298 }
299 })
300 .catch(error => console.error('Error fetching user data:', error));
301 }
302 }
303
304 // Message methods
305 getMessages() {
306 const key = this.messages.map(m => m.id).join(',') + '|' + Object.keys(this.userCache).sort().join(',');
307 if (key === this._lastEnrichmentKey && this._memoizedEnrichedMessages !== null) {
308 return this._memoizedEnrichedMessages;
309 }
310 this._lastEnrichmentKey = key;
311 console.debug('Enriching messages with user data', {
312 messagesCount: this.messages.length,
313 userCacheSize: Object.keys(this.userCache).length
314 });
315 this._memoizedEnrichedMessages = this.enrichMessages(this.messages);
316 return this._memoizedEnrichedMessages;
317 }
318
319 public prependMessages(newMessages: Message[], hasMore?: boolean) {
320 console.debug('Prepending messages:', { count: newMessages.length });
321 // Get unique user IDs from new messages that aren't in the cache
322 const uniqueUserIds = new Set(newMessages.map(msg => msg.senderId));
323 const missingUserIds = Array.from(uniqueUserIds).filter(id => !this.userCache[id]);
324
325 // Fetch missing user data in parallel
326 if (missingUserIds.length > 0) {
327 console.debug('Fetching missing user data for IDs:', missingUserIds);
328 Promise.all(missingUserIds.map(async (userId) => {
329 try {
330 const userResponse = await fetch(`/api/users/${userId}`, {
331 credentials: 'include'
332 });
333 if (userResponse.ok) {
334 const userData = await userResponse.json();
335 if (userData.success && userData.user) {
336 this.userCache[userId] = userData.user;
337 // Only add to users array if not already present
338 if (!this.users.some(u => u.id === userId)) {
339 this.users = [...this.users, userData.user];
340 }
341 }
342 }
343 } catch (error) {
344 console.debug('Error fetching user data:', { userId, error });
345 }
346 }));
347 }
348
349 // Ensure incoming messages are sorted in ascending order before prepending
350 const sortedNewMessages = newMessages.slice().sort((a, b) => a.timestamp - b.timestamp);
351 const uniqueNewMessages = sortedNewMessages.filter(msg => !this.messages.some(m => m.id === msg.id));
352 this.messages = [...uniqueNewMessages, ...this.messages];
353
354 // Update hasMore if provided
355 if (hasMore !== undefined) {
356 this.hasMoreMessages = hasMore;
357 }
358 }
359
360 // Add method to update user cache for public access
361 updateUserCache(users: User[]) {
362 console.debug('Updating user cache with users:', users);
363 // Merge incoming users with the existing cache
364 users.forEach(user => {
365 if (!this.userCache[user.id] || user.status !== this.userCache[user.id].status) {
366 this.userCache[user.id] = user;
367 // Update users array if not already present or if status changed
368 const existingIndex = this.users.findIndex(u => u.id === user.id);
369 if (existingIndex === -1) {
370 this.users = [...this.users, user];
371 } else if (user.status !== this.users[existingIndex].status) {
372 this.users = [
373 ...this.users.slice(0, existingIndex),
374 user,
375 ...this.users.slice(existingIndex + 1)
376 ];
377 }
378 }
379 });
380 }
381
382 // Add method to update online users for public access
383 updateOnlineUsers(users: User[]) {
384 const now = Date.now();
385 // Create a hash of the new users data
386 const newHash = JSON.stringify(users);
387
388 // Only update if the data has changed
389 if (newHash !== this.lastBuddyListHash) {
390 const statusCounts = users.reduce((acc, user) => {
391 acc[user.status] = (acc[user.status] || 0) + 1;
392 return acc;
393 }, {} as Record<string, number>);
394
395 console.debug('Updating online users (data changed):', {
396 total: users.length,
397 byStatus: statusCounts,
398 timestamp: new Date(now).toISOString()
399 });
400
401 this.users = users;
402 // Also update cache
403 users.forEach(user => {
404 this.userCache[user.id] = user;
405 });
406 this.lastBuddyListHash = newHash;
407 this.lastBuddyListUpdate = now;
408 }
409 }
410
411 // Add method to update messages for public access
412 updateMessages(messages: Message[]) {
413 // Deduplicate the messages by their id in case duplicates exist
414 const uniqueMessages = Array.from(new Map(messages.map(msg => [msg.id, msg])).values());
415 // Sort messages in ascending order (oldest first, newest at bottom)
416 this.messages = uniqueMessages.sort((a, b) => a.timestamp - b.timestamp);
417 }
418
419 // 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.
420 async initializeMessages() {
421 try {
422 // Build the URL with appropriate parameters
423 const params = new URLSearchParams();
424 if (!this.currentUser) {
425 params.append('public', 'true');
426 }
427 params.append('roomId', this.currentRoomId);
428
429 const response = await fetch(`/api/chat/messages?${params}`, {
430 credentials: 'include'
431 });
432 if (!response.ok) throw new Error('Failed to fetch messages');
433
434 const data = await response.json() as GetMessagesResponse;
435 if (!data.success) {
436 throw new Error(data.error);
437 }
438
439 // Get unique user IDs from messages that aren't in the cache
440 const uniqueUserIds = new Set(data.messages.map(msg => msg.senderId));
441 const missingUserIds = Array.from(uniqueUserIds).filter(id => !this.userCache[id]);
442
443 // Fetch missing user data in parallel
444 if (missingUserIds.length > 0) {
445 console.debug('Fetching missing user data for IDs:', missingUserIds);
446 await Promise.all(missingUserIds.map(async (userId) => {
447 try {
448 const userResponse = await fetch(`/api/users/${userId}`, {
449 credentials: 'include'
450 });
451 if (userResponse.ok) {
452 const userData = await userResponse.json();
453 if (userData.success && userData.user) {
454 this.userCache[userId] = userData.user;
455 // Only add to users array if not already present
456 if (!this.users.some(u => u.id === userId)) {
457 this.users = [...this.users, userData.user];
458 }
459 }
460 }
461 } catch (error) {
462 console.debug('Error fetching user data:', { userId, error });
463 }
464 }));
465 }
466
467 // Sort messages in ascending order (oldest first, most recent at the bottom)
468 this.messages = data.messages.slice().sort((a, b) => a.timestamp - b.timestamp);
469
470 // Update hasMore based on response
471 if ('hasMore' in data) {
472 this.hasMoreMessages = data.hasMore;
473 }
474
475 console.debug('Messages initialized:', {
476 count: data.messages.length,
477 isAuthenticated: !!this.currentUser,
478 hasMore: this.hasMoreMessages
479 });
480 } catch (error) {
481 console.debug('Error fetching initial messages:', error);
482 this.messages = [];
483 this.hasMoreMessages = false;
484 }
485 }
486
487 async sendMessage(content: string, type: Message['type'] = 'chat'): Promise<SendMessageResponse> {
488 const user = this.getCurrentUser();
489 if (!user) {
490 console.debug('Cannot send message: No current user');
491 return {
492 success: false,
493 error: 'Not logged in'
494 };
495 }
496
497 try {
498 const payload: SendMessageRequest = {
499 content,
500 type,
501 userId: user.id,
502 chatRoomId: DEFAULT_CHAT_ROOM_ID
503 };
504
505 console.debug('Sending message with payload:', payload);
506
507 const response = await fetch('/api/chat/messages', {
508 method: 'POST',
509 headers: {
510 'Content-Type': 'application/json',
511 },
512 credentials: 'include',
513 body: JSON.stringify(payload)
514 });
515
516 if (!response.ok) {
517 const errorData = await response.json();
518 throw new Error(errorData.error || 'Failed to save message');
519 }
520
521 const data = await response.json() as SendMessageResponse;
522 if (!data.success) {
523 throw new Error(data.error);
524 }
525
526 console.debug('Message sent successfully:', data.message);
527 await invalidate('chat:messages');
528 return data;
529 } catch (error) {
530 console.debug('Error sending message:', error);
531 return {
532 success: false,
533 error: 'Failed to send message'
534 };
535 }
536 }
537
538 private setupSSE() {
539 // Prevent creating multiple SSE connections if one is already active.
540 if (this.eventSource && this.eventSource.readyState !== EventSource.CLOSED) {
541 console.debug('setupSSE: Active SSE connection exists, skipping setup.');
542 return;
543 }
544 // Clear any existing timeouts
545 if (this.connectionTimeout) {
546 clearTimeout(this.connectionTimeout);
547 this.connectionTimeout = null;
548 }
549
550 if (this.connectionAttemptTimeout) {
551 clearTimeout(this.connectionAttemptTimeout);
552 this.connectionAttemptTimeout = null;
553 }
554
555 // Debounce connection attempts
556 this.connectionAttemptTimeout = setTimeout(() => {
557 if (!this.currentUser) {
558 console.debug('No current user, skipping SSE connection');
559 return;
560 }
561
562 if (this.isConnecting) {
563 console.debug('Already connecting to SSE, skipping');
564 return;
565 }
566
567 this.connectSSE();
568 }, 1000);
569 }
570
571 private async connectSSE() {
572 if (this.isConnecting) {
573 console.debug('Already connecting to SSE, skipping');
574 return;
575 }
576
577 this.isConnecting = true;
578
579 try {
580 // Ensure clean disconnect of any existing connection
581 if (this.eventSource) {
582 console.debug('Closing existing SSE connection');
583 this.eventSource.close();
584 this.eventSource = null;
585 }
586
587 console.debug('Connecting to SSE...');
588 this.eventSource = new EventSource('/api/sse', { withCredentials: true });
589 // Reset the handlers flag for the new connection
590 this.sseHandlersRegistered = false;
591
592 this.eventSource.onopen = () => {
593 console.debug('SSE connection established');
594 this.reconnectAttempts = 0;
595 this.reconnectDelay = 1000;
596 this.sseError = null;
597 this.sseRetryAfter = null;
598 this.isReconnecting = false;
599 };
600
601 // Set up error handling
602 this.eventSource.onerror = (error) => {
603 console.debug('SSE connection error:', error);
604 if (this.eventSource?.readyState === EventSource.CLOSED) {
605 this.handleDisconnection();
606 }
607 };
608
609 // Register event handlers (only once)
610 this.setupMessageHandlers();
611
612 } catch (error) {
613 console.debug('Error setting up SSE connection:', error);
614 this.handleDisconnection();
615 } finally {
616 this.isConnecting = false;
617 }
618 }
619
620 // Register SSE event handlers only once per connection.
621 private setupMessageHandlers() {
622 if (this.sseHandlersRegistered || !this.eventSource) {
623 return;
624 }
625
626 // Listen for chat messages
627 this.eventSource.addEventListener('chatMessage', async (event: MessageEvent) => {
628 try {
629 const messageData = JSON.parse(event.data) as Message;
630 console.debug('Received chat message via SSE:', messageData);
631 // Deduplicate and update messages array with the new message
632 this.messages = Array.from(new Map([...this.messages, messageData].map(m => [m.id, m])).values());
633 // Sort messages so the newest are at the bottom
634 this.messages.sort((a, b) => a.timestamp - b.timestamp);
635 // Ensure the sender's data is available
636 await this.ensureUserData(messageData.senderId);
637 } catch (error) {
638 console.debug('Error handling SSE chat message:', error);
639 }
640 });
641
642 // Modify buddy list update handler
643 this.eventSource.addEventListener('buddyListUpdate', async (event: MessageEvent) => {
644 try {
645 const buddyList = JSON.parse(event.data) as User[];
646 this.updateOnlineUsers(buddyList);
647 } catch (error) {
648 console.debug('Error handling buddy list update via SSE:', error);
649 }
650 });
651
652 this.sseHandlersRegistered = true;
653 }
654
655 private async ensureUserData(userId: string) {
656 if (this.userCache[userId]) return;
657
658 console.debug('Fetching user data for:', userId);
659 try {
660 const response = await fetch(`/api/users/${userId}`, {
661 credentials: 'include'
662 });
663 if (response.ok) {
664 const userData = await response.json();
665 if (userData.success && userData.user) {
666 this.userCache[userId] = userData.user;
667 if (!this.users.some(u => u.id === userId)) {
668 this.users = [...this.users, userData.user];
669 }
670 }
671 }
672 } catch (error) {
673 console.debug('Error fetching user data:', error);
674 }
675 }
676
677 private handleDisconnection() {
678 console.debug('SSE connection closed, reconnecting...');
679 this.isReconnecting = true;
680 this.reconnectAttempts++;
681 if (this.reconnectAttempts > this.maxReconnectAttempts) {
682 console.debug('Max reconnect attempts reached, giving up');
683 if (this.eventSource) {
684 this.eventSource.close();
685 }
686 this.eventSource = null;
687 return;
688 }
689 this.reconnectDelay *= 2; // Exponential backoff
690 // Ensure any existing connection is properly closed before reconnecting
691 if (this.eventSource) {
692 this.eventSource.close();
693 this.eventSource = null;
694 }
695 // Reset the handlers flag so that the new connection gets its event listeners registered fresh.
696 this.sseHandlersRegistered = false;
697 this.setupSSE();
698 }
699
700 enrichMessages(messages: Message[]): EnrichedMessage[] {
701 console.debug('Enriching messages with user data', { messagesCount: messages.length, userCacheSize: Object.keys(this.userCache).length });
702 return messages.map(message => ({
703 ...message,
704 user: this.userCache[message.senderId] || {
705 id: message.senderId,
706 nickname: 'Unknown User',
707 status: 'offline',
708 password: '',
709 createdAt: 0
710 }
711 }));
712 }
713
714 getDefaultChatRoomId() {
715 return DEFAULT_CHAT_ROOM_ID;
716 }
717}
718
719export const chatState = new ChatState();