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