v0.1.3 ff61cc9

chat.svelte.ts RawHistoryFolder

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();

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