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';
8
9
10class ChatState {
11 private users = $state<User[]>([]);
12 private messages = $state<Message[]>([]);
13 private currentUser = $state<User | null>(null);
14 private eventSource: EventSource | null = null;
15 private reconnectAttempts = 0;
16 private maxReconnectAttempts = 5;
17 private reconnectDelay = 1000; // Start with 1 second
18 private isInitializing = $state(false);
19 private currentRoomId = $state<string>('default');
20 private userCache = $state<Record<string, User>>({});
21
22 public async reinitialize() {
23 console.debug('Reinitializing chat state...');
24 if (this.eventSource) {
25 this.eventSource.close();
26 this.eventSource = null;
27 }
28 this.reconnectAttempts = 0;
29 this.reconnectDelay = 1000;
30 this.messages = [];
31 if (this.currentUser) {
32 await Promise.all([
33 this.initializeMessages(),
34 this.initializeRoomUsers()
35 ]);
36 this.setupSSE();
37 }
38 }
39
40 // User methods
41 getCurrentUser() {
42 return this.currentUser;
43 }
44
45 async setCurrentUser(user: User | null) {
46 console.debug('setCurrentUser called with', user);
47 this.currentUser = user;
48
49 if (user) {
50 await Promise.all([
51 this.initializeMessages(),
52 this.initializeRoomUsers()
53 ]);
54 this.setupSSE();
55 } else {
56 // If no global session, clear SSE and state
57 if (this.eventSource) {
58 this.eventSource.close();
59 this.eventSource = null;
60 }
61 this.messages = [];
62 this.users = [];
63 }
64
65 // Invalidate both session and messages to trigger reloads
66 await Promise.all([
67 invalidate('chat:session'),
68 invalidate('chat:messages')
69 ]);
70 }
71
72 private async initializeRoomUsers() {
73 console.debug('Initializing room users for room:', this.currentRoomId);
74 try {
75 const response = await fetch(`/api/rooms/${this.currentRoomId}`, {
76 credentials: 'include'
77 });
78
79 if (!response.ok) {
80 throw new Error('Failed to fetch room users');
81 }
82
83 const data = await response.json();
84 if (!data.success) {
85 throw new Error(data.error);
86 }
87
88 console.debug('Fetched room buddy list:', data.buddyList);
89 this.users = data.buddyList;
90 // Update cache with new buddy list users
91 this.users.forEach(user => {
92 this.userCache[user.id] = user;
93 });
94 } catch (error) {
95 console.debug('Error fetching room users:', error);
96 this.users = [];
97 }
98 }
99
100 getOnlineUsers() {
101 return this.users.filter((user: User) => user.status !== 'offline');
102 }
103
104 getUserById(userId: string) {
105 // First check the cache
106 if (this.userCache[userId]) {
107 return this.userCache[userId];
108 }
109
110 // If not in cache, look up in users array
111 const user = this.users.find((user: User) => user.id === userId);
112 if (user) {
113 // Update cache
114 this.userCache[userId] = user;
115 console.debug('getUserById: cached user data', { userId, user });
116 } else {
117 console.debug('getUserById: user not found', { userId });
118 }
119 return user;
120 }
121
122 updateUserStatus(userId: string, status: User['status']) {
123 this.users = this.users.map((user: User) => {
124 if (user.id === userId) {
125 const updatedUser = { ...user, status };
126 // Update cache
127 this.userCache[userId] = updatedUser;
128 return updatedUser;
129 }
130 return user;
131 });
132 }
133
134 // Message methods
135 getMessages() {
136 // Return messages with enriched user data
137 return this.enrichMessages(this.messages);
138 }
139
140 // Add method to update user cache for public access
141 updateUserCache(users: User[]) {
142 users.forEach(user => {
143 this.userCache[user.id] = user;
144 });
145 // Also update the users array
146 this.users = users;
147 }
148
149 // Add method to update messages for public access
150 updateMessages(messages: Message[]) {
151 this.messages = messages;
152 }
153
154 async initializeMessages() {
155 try {
156 const response = await fetch('/api/chat/messages', {
157 credentials: 'include'
158 });
159 if (!response.ok) throw new Error('Failed to fetch messages');
160
161 const data = await response.json() as GetMessagesResponse;
162 if (!data.success) {
163 throw new Error(data.error);
164 }
165
166 // Get unique user IDs from messages that aren't in the cache
167 const uniqueUserIds = new Set(data.messages.map(msg => msg.senderId));
168 const missingUserIds = Array.from(uniqueUserIds).filter(id => !this.userCache[id]);
169
170 // Fetch missing user data in parallel
171 if (missingUserIds.length > 0) {
172 console.debug('Fetching missing user data for IDs:', missingUserIds);
173 await Promise.all(missingUserIds.map(async (userId) => {
174 try {
175 const userResponse = await fetch(`/api/users/${userId}`, {
176 credentials: 'include'
177 });
178 if (userResponse.ok) {
179 const userData = await userResponse.json();
180 if (userData.success && userData.user) {
181 this.userCache[userId] = userData.user;
182 // Only add to users array if not already present
183 if (!this.users.some(u => u.id === userId)) {
184 this.users = [...this.users, userData.user];
185 }
186 }
187 }
188 } catch (error) {
189 console.debug('Error fetching user data:', { userId, error });
190 }
191 }));
192 }
193
194 this.messages = data.messages;
195 } catch (error) {
196 console.debug('Error fetching initial messages:', error);
197 this.messages = [];
198 }
199 }
200
201 async sendMessage(content: string, type: Message['type'] = 'chat') {
202 const user = this.getCurrentUser();
203 if (!user) {
204 console.debug('Cannot send message: No current user');
205 return;
206 }
207
208 try {
209 const payload: SendMessageRequest = {
210 content,
211 type,
212 userId: user.id,
213 chatRoomId: 'default'
214 };
215
216 console.debug('Sending message with payload:', payload);
217
218 const response = await fetch('/api/chat/messages', {
219 method: 'POST',
220 headers: {
221 'Content-Type': 'application/json',
222 },
223 credentials: 'include',
224 body: JSON.stringify(payload)
225 });
226
227 if (!response.ok) {
228 const errorData = await response.json();
229 throw new Error(errorData.error || 'Failed to save message');
230 }
231
232 const data = await response.json() as SendMessageResponse;
233 if (!data.success) {
234 throw new Error(data.error);
235 }
236
237 console.debug('Message sent successfully:', data.message);
238 await invalidate('chat:messages');
239 } catch (error) {
240 console.debug('Error sending message:', error);
241 throw error;
242 }
243 }
244
245 private setupSSE() {
246 console.debug('Setting up SSE connection for chat messages');
247 this.connectSSE();
248 }
249
250 private connectSSE() {
251 if (this.eventSource) {
252 this.eventSource.close();
253 }
254
255 this.eventSource = new EventSource('/api/chat/messages', {
256 withCredentials: true
257 });
258
259 this.eventSource.onmessage = async (event) => {
260 try {
261 if (event.data === 'Connected') {
262 console.debug('SSE connection established');
263 this.reconnectAttempts = 0;
264 this.reconnectDelay = 1000;
265 return;
266 }
267
268 const messageData = JSON.parse(event.data) as Message;
269
270 // Ensure we have the sender's data before adding the message
271 if (!this.userCache[messageData.senderId]) {
272 try {
273 console.debug('Fetching user data for new message:', messageData.senderId);
274 const response = await fetch(`/api/users/${messageData.senderId}`, {
275 credentials: 'include'
276 });
277 if (response.ok) {
278 const userData = await response.json();
279 if (userData.success && userData.user) {
280 this.userCache[userData.user.id] = userData.user;
281 // Only add to users array if not already present
282 if (!this.users.some(u => u.id === userData.user.id)) {
283 this.users = [...this.users, userData.user];
284 }
285 }
286 }
287 } catch (error) {
288 console.debug('Error fetching user data for new message:', error);
289 }
290 }
291
292 // Update messages state
293 this.messages = [...this.messages, messageData];
294 await invalidate('chat:messages');
295 } catch (error) {
296 console.debug('Error parsing SSE message data:', error);
297 }
298 };
299
300 this.eventSource.onerror = (event) => {
301 console.debug('SSE encountered an error:', event);
302 this.handleSSEError();
303 };
304 }
305
306 private handleSSEError() {
307 if (this.eventSource) {
308 this.eventSource.close();
309 this.eventSource = null;
310 }
311
312 if (this.reconnectAttempts < this.maxReconnectAttempts) {
313 console.debug(`Attempting to reconnect (${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})...`);
314 setTimeout(() => {
315 this.reconnectAttempts++;
316 this.reconnectDelay *= 2; // Exponential backoff
317 this.connectSSE();
318 }, this.reconnectDelay);
319 } else {
320 console.debug('Max reconnection attempts reached');
321 }
322 }
323
324 private reconnectSSE() {
325 this.reconnectAttempts = 0;
326 this.reconnectDelay = 1000;
327 this.connectSSE();
328 }
329
330 // Add new method to enrich multiple messages at once
331 enrichMessages(messages: Message[]): EnrichedMessage[] {
332 return messages.map(msg => this.enrichMessageWithUser(msg));
333 }
334
335 // Add new method to efficiently get user data for messages
336 enrichMessageWithUser(message: Message): EnrichedMessage {
337 const user = this.getUserById(message.senderId);
338 return {
339 ...message,
340 user: user || {
341 id: message.senderId,
342 nickname: 'Unknown User',
343 status: 'offline',
344 password: '', // This is a system user, no password needed
345 createdAt: Date.now(),
346 lastSeen: null,
347 avatarUrl: null
348 }
349 };
350 }
351}
352
353// Export a singleton instance
354export const chatState = new ChatState();