detached 16eb1cf

feat: Enhance chat state management and SSE connection handling

- Improve chat state initialization with more robust user and message handling
- Add support for infinite scroll and message loading in chat room
- Optimize SSE connection with better error handling and reconnection logic
- Implement memoization for message enrichment to improve performance
- Add development-friendly rate limiting configuration
- Enhance user data fetching and caching mechanisms
commit
16eb1cf19ad234f460b3a9557aa87c455de651b9
author
Kit · · 2025-02-14 16:16 UTC
parent
dabbe8a
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
8 files · +414 -167
M src/lib/api/rate-limiter.ts +27 -5
@@ -1,20 +1,37 @@
11 import type { RequestEvent } from '@sveltejs/kit';
22
3+const isDev = import.meta.env.DEV;
4+
35 // Rate limit configuration by endpoint type
46 const RATE_LIMITS = {
57 // Authentication endpoints (login, register)
6- auth: { points: 20, durationMs: 5 * 60 * 1000 }, // 20 requests per 5 minutes
8+ auth: {
9+ points: isDev ? 1000 : 20,
10+ durationMs: 5 * 60 * 1000
11+ },
712
813 // Public endpoints (public chat, rooms)
9- public: { points: 30, durationMs: 60 * 1000 }, // 30 requests per minute
14+ public: {
15+ points: isDev ? 1000 : 30,
16+ durationMs: 60 * 1000
17+ },
1018
1119 // Protected endpoints (authenticated API calls)
12- protected: { points: 100, durationMs: 60 * 1000 }, // 100 requests per minute
20+ protected: {
21+ points: isDev ? 1000 : 100,
22+ durationMs: 60 * 1000
23+ },
1324
1425 // SSE endpoints - separate limits for authenticated and unauthenticated users
1526 sse: {
16- authenticated: { points: 100, durationMs: 60 * 1000 }, // 10 connections per minute for logged in users
17- unauthenticated: { points: 10, durationMs: 60 * 1000 } // 2 connections per minute for public users
27+ authenticated: {
28+ points: isDev ? 1000 : 100,
29+ durationMs: 60 * 1000
30+ },
31+ unauthenticated: {
32+ points: isDev ? 1000 : 10,
33+ durationMs: 60 * 1000
34+ }
1835 }
1936 } as const;
2037
@@ -49,6 +66,11 @@ function getEndpointType(pathname: string, isPublic: boolean): keyof typeof RATE
4966 }
5067
5168 function isRateLimited(ip: string, endpointType: keyof typeof RATE_LIMITS, isAuthenticated: boolean = false): { limited: boolean; retryAfter?: number } {
69+ // Skip rate limiting completely in development for localhost
70+ if (isDev && (ip === '127.0.0.1' || ip === '::1' || ip === 'localhost')) {
71+ return { limited: false };
72+ }
73+
5274 const now = Date.now();
5375
5476 // Handle SSE endpoints differently based on authentication status
@@ -1,20 +1,37 @@
1 import type { RequestEvent } from '@sveltejs/kit';1 import type { RequestEvent } from '@sveltejs/kit';
2 2
3+const isDev = import.meta.env.DEV;
4+
3 // Rate limit configuration by endpoint type5 // Rate limit configuration by endpoint type
4 const RATE_LIMITS = {6 const RATE_LIMITS = {
5 // Authentication endpoints (login, register)7 // Authentication endpoints (login, register)
6- auth: { points: 20, durationMs: 5 * 60 * 1000 }, // 20 requests per 5 minutes8+ auth: {
9+ points: isDev ? 1000 : 20,
10+ durationMs: 5 * 60 * 1000
11+ },
7 12
8 // Public endpoints (public chat, rooms)13 // Public endpoints (public chat, rooms)
9- public: { points: 30, durationMs: 60 * 1000 }, // 30 requests per minute14+ public: {
15+ points: isDev ? 1000 : 30,
16+ durationMs: 60 * 1000
17+ },
10 18
11 // Protected endpoints (authenticated API calls)19 // Protected endpoints (authenticated API calls)
12- protected: { points: 100, durationMs: 60 * 1000 }, // 100 requests per minute20+ protected: {
21+ points: isDev ? 1000 : 100,
22+ durationMs: 60 * 1000
23+ },
13 24
14 // SSE endpoints - separate limits for authenticated and unauthenticated users25 // SSE endpoints - separate limits for authenticated and unauthenticated users
15 sse: {26 sse: {
16- authenticated: { points: 100, durationMs: 60 * 1000 }, // 10 connections per minute for logged in users27+ authenticated: {
17- unauthenticated: { points: 10, durationMs: 60 * 1000 } // 2 connections per minute for public users28+ points: isDev ? 1000 : 100,
29+ durationMs: 60 * 1000
30+ },
31+ unauthenticated: {
32+ points: isDev ? 1000 : 10,
33+ durationMs: 60 * 1000
34+ }
18 }35 }
19 } as const;36 } as const;
20 37
@@ -49,6 +66,11 @@ function getEndpointType(pathname: string, isPublic: boolean): keyof typeof RATE
49 }66 }
50 67
51 function isRateLimited(ip: string, endpointType: keyof typeof RATE_LIMITS, isAuthenticated: boolean = false): { limited: boolean; retryAfter?: number } {68 function isRateLimited(ip: string, endpointType: keyof typeof RATE_LIMITS, isAuthenticated: boolean = false): { limited: boolean; retryAfter?: number } {
69+ // Skip rate limiting completely in development for localhost
70+ if (isDev && (ip === '127.0.0.1' || ip === '::1' || ip === 'localhost')) {
71+ return { limited: false };
72+ }
73+
52 const now = Date.now();74 const now = Date.now();
53 75
54 // Handle SSE endpoints differently based on authentication status76 // Handle SSE endpoints differently based on authentication status
M src/lib/components/aim-login.svelte +3 -1
@@ -4,6 +4,7 @@
44 import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads';
55 import type { User } from '$lib/types/chat';
66 import { invalidateAll } from '$app/navigation';
7+ import { chatState } from '$lib/states/chat.svelte';
78
89 let { onLoginSuccess, showAuth = $bindable() }: { onLoginSuccess?: (user: User) => void, showAuth:Boolean } = $props();
910
@@ -57,6 +58,7 @@
5758 }
5859
5960 console.debug("Login succeeded:", data);
61+ await chatState.setCurrentUser(data.user);
6062 loginStatus = 'success';
6163
6264 // Call the callback prop if provided
@@ -132,7 +134,7 @@
132134 return;
133135 }
134136 console.debug("Auto login after registration succeeded:", data);
135-
137+ await chatState.setCurrentUser(data.user);
136138 onLoginSuccess?.(data.user);
137139 setTimeout(() => {
138140 console.debug("Auto closing AIM Login component after auto login on registration");
@@ -4,6 +4,7 @@
4 import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads';4 import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads';
5 import type { User } from '$lib/types/chat';5 import type { User } from '$lib/types/chat';
6 import { invalidateAll } from '$app/navigation';6 import { invalidateAll } from '$app/navigation';
7+ import { chatState } from '$lib/states/chat.svelte';
7 8
8 let { onLoginSuccess, showAuth = $bindable() }: { onLoginSuccess?: (user: User) => void, showAuth:Boolean } = $props();9 let { onLoginSuccess, showAuth = $bindable() }: { onLoginSuccess?: (user: User) => void, showAuth:Boolean } = $props();
9 10
@@ -57,6 +58,7 @@
57 }58 }
58 59
59 console.debug("Login succeeded:", data);60 console.debug("Login succeeded:", data);
61+ await chatState.setCurrentUser(data.user);
60 loginStatus = 'success';62 loginStatus = 'success';
61 63
62 // Call the callback prop if provided64 // Call the callback prop if provided
@@ -132,7 +134,7 @@
132 return;134 return;
133 }135 }
134 console.debug("Auto login after registration succeeded:", data);136 console.debug("Auto login after registration succeeded:", data);
135-137+ await chatState.setCurrentUser(data.user);
136 onLoginSuccess?.(data.user);138 onLoginSuccess?.(data.user);
137 setTimeout(() => {139 setTimeout(() => {
138 console.debug("Auto closing AIM Login component after auto login on registration");140 console.debug("Auto closing AIM Login component after auto login on registration");
M src/lib/components/chat-room.svelte +80 -4
@@ -62,6 +62,10 @@ function handleClose() {
6262 let messages = $state<EnrichedMessage[]>([]);
6363 let onlineUsers = $state<User[]>([]);
6464 let currentUser = $state<User | null>(null);
65+let isLoadingMore = $state(false);
66+let hasMoreMessages = $state(true);
67+let oldestMessageTimestamp = $state<number | null>(null);
68+let currentRoomId = $state(DEFAULT_CHAT_ROOM_ID);
6569
6670 // Compute visible messages based on login status
6771 let visibleMessages = $derived((() => {
@@ -82,6 +86,7 @@ $effect(() => {
8286 const user = chatState.getCurrentUser();
8387 if (user?.id !== currentUser?.id) {
8488 currentUser = user;
89+ hasMoreMessages = true; // Reset when user changes
8590 }
8691
8792 // Check for SSE errors
@@ -89,11 +94,16 @@ $effect(() => {
8994 sseError = error;
9095 sseRetryAfter = retryAfter;
9196
92- // Only update messages if we have new ones
97+ // Update messages if there's any difference
9398 const stateMessages = chatState.getMessages();
94- if (stateMessages.length !== messages.length) {
99+ if (JSON.stringify(stateMessages) !== JSON.stringify(messages)) {
95100 messages = stateMessages;
96101
102+ // Update oldest message timestamp
103+ if (messages.length > 0) {
104+ oldestMessageTimestamp = Math.min(...messages.map(m => m.timestamp));
105+ }
106+
97107 // Scroll to bottom when new messages arrive
98108 const chatArea = document.querySelector('.chat-area');
99109 if (chatArea) {
@@ -128,15 +138,17 @@ $effect(() => {
128138 ])
129139 .then(([roomData, messagesData]) => {
130140 if (roomData.success) {
131- // First update the chat state with the buddy list
132- chatState.updateUserCache(roomData.buddyList);
141+ // Update both the chat state and local state with the buddy list
142+ chatState.updateOnlineUsers(roomData.buddyList);
133143 onlineUsers = roomData.buddyList;
144+ console.debug('Updated public buddy list:', roomData.buddyList);
134145 }
135146
136147 // Then update messages after buddy list is cached
137148 if (messagesData.success && messagesData.messages) {
138149 chatState.updateMessages(messagesData.messages);
139150 messages = chatState.enrichMessages(messagesData.messages);
151+ console.debug('Updated public messages:', messages);
140152 }
141153 })
142154 .catch(error => {
@@ -277,6 +289,54 @@ function handleMaximizeEvent(event: CustomEvent<{
277289 windowX = event.detail.x;
278290 windowY = event.detail.y;
279291 }
292+
293+async function handleScroll(event: Event) {
294+ const chatArea = event.target as HTMLElement;
295+ const { scrollTop } = chatArea;
296+
297+ // Check if we've scrolled near the top (within 100px) and not already loading
298+ if (scrollTop < 100 && !isLoadingMore && hasMoreMessages) {
299+ isLoadingMore = true;
300+ console.debug('Loading more messages...', { oldestMessageTimestamp });
301+
302+ try {
303+ const response = await fetch(`/api/chat/messages?${new URLSearchParams({
304+ before: oldestMessageTimestamp?.toString() || '',
305+ roomId: currentRoomId,
306+ ...(currentUser ? {} : { public: 'true' })
307+ })}`);
308+
309+ if (response.ok) {
310+ const data = await response.json();
311+ if (data.success) {
312+ if (data.messages.length === 0) {
313+ hasMoreMessages = false;
314+ } else {
315+ // Preserve scroll position
316+ const oldHeight = chatArea.scrollHeight;
317+
318+ // Update messages
319+ chatState.prependMessages(data.messages);
320+
321+ // After the DOM updates, adjust scroll position
322+ setTimeout(() => {
323+ const newHeight = chatArea.scrollHeight;
324+ chatArea.scrollTop = newHeight - oldHeight;
325+ }, 0);
326+
327+ // Update oldest timestamp
328+ const newOldest = Math.min(...data.messages.map((m: Message) => m.timestamp));
329+ oldestMessageTimestamp = newOldest;
330+ }
331+ }
332+ }
333+ } catch (error) {
334+ console.error('Error loading more messages:', error);
335+ } finally {
336+ isLoadingMore = false;
337+ }
338+ }
339+}
280340 </script>
281341
282342 {#if showChatRoom}
@@ -351,7 +411,13 @@ function handleMaximizeEvent(event: CustomEvent<{
351411 <div
352412 class="sunken-panel chat-area"
353413 style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"
414+ onscroll={(e) => handleScroll(e)}
354415 >
416+ {#if isLoadingMore}
417+ <div class="loading-messages">
418+ Loading more messages...
419+ </div>
420+ {/if}
355421 {#each visibleMessages as message (message.id)}
356422 <div class="message {message.type} text">
357423 {#if message.type === 'emote'}
@@ -691,4 +757,14 @@ function handleMaximizeEvent(event: CustomEvent<{
691757 background: #e65100;
692758 transition: width 0.1s linear;
693759 }
760+
761+ .loading-messages {
762+ text-align: center;
763+ padding: 1rem;
764+ color: #666;
765+ font-style: italic;
766+ background: rgba(0, 0, 0, 0.05);
767+ margin-bottom: 1rem;
768+ border-radius: 4px;
769+ }
694770 </style>⏎̸
@@ -62,6 +62,10 @@ function handleClose() {
62 let messages = $state<EnrichedMessage[]>([]);62 let messages = $state<EnrichedMessage[]>([]);
63 let onlineUsers = $state<User[]>([]);63 let onlineUsers = $state<User[]>([]);
64 let currentUser = $state<User | null>(null);64 let currentUser = $state<User | null>(null);
65+let isLoadingMore = $state(false);
66+let hasMoreMessages = $state(true);
67+let oldestMessageTimestamp = $state<number | null>(null);
68+let currentRoomId = $state(DEFAULT_CHAT_ROOM_ID);
65 69
66 // Compute visible messages based on login status70 // Compute visible messages based on login status
67 let visibleMessages = $derived((() => {71 let visibleMessages = $derived((() => {
@@ -82,6 +86,7 @@ $effect(() => {
82 const user = chatState.getCurrentUser();86 const user = chatState.getCurrentUser();
83 if (user?.id !== currentUser?.id) {87 if (user?.id !== currentUser?.id) {
84 currentUser = user;88 currentUser = user;
89+ hasMoreMessages = true; // Reset when user changes
85 }90 }
86 91
87 // Check for SSE errors92 // Check for SSE errors
@@ -89,11 +94,16 @@ $effect(() => {
89 sseError = error;94 sseError = error;
90 sseRetryAfter = retryAfter;95 sseRetryAfter = retryAfter;
91 96
92- // Only update messages if we have new ones97+ // Update messages if there's any difference
93 const stateMessages = chatState.getMessages();98 const stateMessages = chatState.getMessages();
94- if (stateMessages.length !== messages.length) {99+ if (JSON.stringify(stateMessages) !== JSON.stringify(messages)) {
95 messages = stateMessages;100 messages = stateMessages;
96 101
102+ // Update oldest message timestamp
103+ if (messages.length > 0) {
104+ oldestMessageTimestamp = Math.min(...messages.map(m => m.timestamp));
105+ }
106+
97 // Scroll to bottom when new messages arrive107 // Scroll to bottom when new messages arrive
98 const chatArea = document.querySelector('.chat-area');108 const chatArea = document.querySelector('.chat-area');
99 if (chatArea) {109 if (chatArea) {
@@ -128,15 +138,17 @@ $effect(() => {
128 ])138 ])
129 .then(([roomData, messagesData]) => {139 .then(([roomData, messagesData]) => {
130 if (roomData.success) {140 if (roomData.success) {
131- // First update the chat state with the buddy list141+ // Update both the chat state and local state with the buddy list
132- chatState.updateUserCache(roomData.buddyList);142+ chatState.updateOnlineUsers(roomData.buddyList);
133 onlineUsers = roomData.buddyList;143 onlineUsers = roomData.buddyList;
144+ console.debug('Updated public buddy list:', roomData.buddyList);
134 }145 }
135 146
136 // Then update messages after buddy list is cached147 // Then update messages after buddy list is cached
137 if (messagesData.success && messagesData.messages) {148 if (messagesData.success && messagesData.messages) {
138 chatState.updateMessages(messagesData.messages);149 chatState.updateMessages(messagesData.messages);
139 messages = chatState.enrichMessages(messagesData.messages);150 messages = chatState.enrichMessages(messagesData.messages);
151+ console.debug('Updated public messages:', messages);
140 }152 }
141 })153 })
142 .catch(error => {154 .catch(error => {
@@ -277,6 +289,54 @@ function handleMaximizeEvent(event: CustomEvent<{
277 windowX = event.detail.x;289 windowX = event.detail.x;
278 windowY = event.detail.y;290 windowY = event.detail.y;
279 }291 }
292+
293+async function handleScroll(event: Event) {
294+ const chatArea = event.target as HTMLElement;
295+ const { scrollTop } = chatArea;
296+
297+ // Check if we've scrolled near the top (within 100px) and not already loading
298+ if (scrollTop < 100 && !isLoadingMore && hasMoreMessages) {
299+ isLoadingMore = true;
300+ console.debug('Loading more messages...', { oldestMessageTimestamp });
301+
302+ try {
303+ const response = await fetch(`/api/chat/messages?${new URLSearchParams({
304+ before: oldestMessageTimestamp?.toString() || '',
305+ roomId: currentRoomId,
306+ ...(currentUser ? {} : { public: 'true' })
307+ })}`);
308+
309+ if (response.ok) {
310+ const data = await response.json();
311+ if (data.success) {
312+ if (data.messages.length === 0) {
313+ hasMoreMessages = false;
314+ } else {
315+ // Preserve scroll position
316+ const oldHeight = chatArea.scrollHeight;
317+
318+ // Update messages
319+ chatState.prependMessages(data.messages);
320+
321+ // After the DOM updates, adjust scroll position
322+ setTimeout(() => {
323+ const newHeight = chatArea.scrollHeight;
324+ chatArea.scrollTop = newHeight - oldHeight;
325+ }, 0);
326+
327+ // Update oldest timestamp
328+ const newOldest = Math.min(...data.messages.map((m: Message) => m.timestamp));
329+ oldestMessageTimestamp = newOldest;
330+ }
331+ }
332+ }
333+ } catch (error) {
334+ console.error('Error loading more messages:', error);
335+ } finally {
336+ isLoadingMore = false;
337+ }
338+ }
339+}
280 </script>340 </script>
281 341
282 {#if showChatRoom}342 {#if showChatRoom}
@@ -351,7 +411,13 @@ function handleMaximizeEvent(event: CustomEvent<{
351 <div 411 <div
352 class="sunken-panel chat-area"412 class="sunken-panel chat-area"
353 style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"413 style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"
414+ onscroll={(e) => handleScroll(e)}
354 >415 >
416+ {#if isLoadingMore}
417+ <div class="loading-messages">
418+ Loading more messages...
419+ </div>
420+ {/if}
355 {#each visibleMessages as message (message.id)}421 {#each visibleMessages as message (message.id)}
356 <div class="message {message.type} text">422 <div class="message {message.type} text">
357 {#if message.type === 'emote'}423 {#if message.type === 'emote'}
@@ -691,4 +757,14 @@ function handleMaximizeEvent(event: CustomEvent<{
691 background: #e65100;757 background: #e65100;
692 transition: width 0.1s linear;758 transition: width 0.1s linear;
693 }759 }
760+
761+ .loading-messages {
762+ text-align: center;
763+ padding: 1rem;
764+ color: #666;
765+ font-style: italic;
766+ background: rgba(0, 0, 0, 0.05);
767+ margin-bottom: 1rem;
768+ border-radius: 4px;
769+ }
694 </style>⏎̸770 </style>⏎̸
M src/lib/components/desktop-icons.svelte +0 -2
@@ -9,12 +9,10 @@
99 }
1010
1111 function handleChatRoomClick() {
12- console.log('handleChatRoomClick');
1312 showChatRoom = true
1413 }
1514
1615 function handleAuthClick() {
17- console.log('handleAuthClick');
1816 showAuth = true;
1917 }
2018 </script>
@@ -9,12 +9,10 @@
9 }9 }
10 10
11 function handleChatRoomClick() {11 function handleChatRoomClick() {
12- console.log('handleChatRoomClick');
13 showChatRoom = true12 showChatRoom = true
14 }13 }
15 14
16 function handleAuthClick() {15 function handleAuthClick() {
17- console.log('handleAuthClick');
18 showAuth = true;16 showAuth = true;
19 }17 }
20 </script>18 </script>
M src/lib/states/chat.svelte.ts +231 -137
@@ -23,8 +23,18 @@ class ChatState {
2323 private sseRetryAfter = $state<number | null>(null);
2424 private connectionTimeout: ReturnType<typeof setTimeout> | null = null;
2525 private isReconnecting = $state(false);
26+ private isSettingUser = $state(false);
27+ private isConnecting = $state(false);
28+ private connectionAttemptTimeout: ReturnType<typeof setTimeout> | null = null;
29+ private _memoizedEnrichedMessages: EnrichedMessage[] | null = null;
30+ private _lastEnrichmentKey: string = '';
2631
2732 public async reinitialize() {
33+ if (this.isInitializing) {
34+ console.debug('Reinitialize already in progress, skipping.');
35+ return;
36+ }
37+ this.isInitializing = true;
2838 console.debug('Reinitializing chat state...');
2939 if (this.eventSource) {
3040 this.eventSource.close();
@@ -34,6 +44,8 @@ class ChatState {
3444 this.reconnectDelay = 1000;
3545 this.messages = [];
3646 if (this.currentUser) {
47+ console.debug('Waiting for session cookie update before setting up SSE...');
48+ await new Promise(resolve => setTimeout(resolve, 1000));
3749 // First set up SSE to receive status updates
3850 this.setupSSE();
3951 // Then initialize messages and room users
@@ -42,6 +54,14 @@ class ChatState {
4254 this.initializeRoomUsers()
4355 ]);
4456 }
57+ this.isInitializing = false;
58+ }
59+
60+ getSSEError() {
61+ return {
62+ error: this.sseError,
63+ retryAfter: this.sseRetryAfter
64+ };
4565 }
4666
4767 // User methods
@@ -51,34 +71,49 @@ class ChatState {
5171
5272 async setCurrentUser(user: User | null) {
5373 console.debug('setCurrentUser called with', user);
54- this.currentUser = user;
55-
56- if (user) {
57- // Add the current user to the cache immediately
58- this.userCache[user.id] = user;
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- } else {
67- // If no global session, clear SSE and state
68- if (this.eventSource) {
69- this.eventSource.close();
70- this.eventSource = null;
74+
75+ // Prevent concurrent setCurrentUser calls
76+ if (this.isSettingUser) {
77+ console.debug('Already setting user, skipping');
78+ return;
79+ }
80+
81+ this.isSettingUser = true;
82+
83+ try {
84+ // If user is null and we already have no user, skip
85+ if (!user && !this.currentUser) {
86+ console.debug('No user to set and no current user, skipping');
87+ return;
7188 }
72- this.messages = [];
73- this.users = [];
74- this.userCache = {};
89+
90+ // If same user is being set, skip
91+ if (user?.id === this.currentUser?.id) {
92+ console.debug('Same user being set, skipping');
93+ return;
94+ }
95+
96+ this.currentUser = user;
97+
98+ if (user) {
99+ // Update user cache with current user
100+ this.userCache[user.id] = user;
101+
102+ // Only reinitialize if we have a valid user
103+ await this.reinitialize();
104+ } else {
105+ // Cleanup when user is removed
106+ if (this.eventSource) {
107+ this.eventSource.close();
108+ this.eventSource = null;
109+ }
110+ this.messages = [];
111+ this.users = [];
112+ this.userCache = {};
113+ }
114+ } finally {
115+ this.isSettingUser = false;
75116 }
76-
77- // Invalidate both session and messages to trigger reloads
78- await Promise.all([
79- invalidate('chat:session'),
80- invalidate('chat:messages')
81- ]);
82117 }
83118
84119 private async initializeRoomUsers() {
@@ -237,31 +272,91 @@ class ChatState {
237272
238273 // Message methods
239274 getMessages() {
240- // Return messages with enriched user data
241- return this.enrichMessages(this.messages);
275+ const key = this.messages.map(m => m.id).join(',') + '|' + Object.keys(this.userCache).sort().join(',');
276+ if (key === this._lastEnrichmentKey && this._memoizedEnrichedMessages !== null) {
277+ return this._memoizedEnrichedMessages;
278+ }
279+ this._lastEnrichmentKey = key;
280+ console.debug('Enriching messages with user data', {
281+ messagesCount: this.messages.length,
282+ userCacheSize: Object.keys(this.userCache).length
283+ });
284+ this._memoizedEnrichedMessages = this.enrichMessages(this.messages);
285+ return this._memoizedEnrichedMessages;
242286 }
243287
244- // Add method to update user cache for public access
245- updateUserCache(users: User[]) {
246- // Merge incoming users with the existing cache
247- const mergedUsers = new Map<string, User>();
288+ public prependMessages(newMessages: Message[]) {
289+ console.debug('Prepending messages:', { count: newMessages.length });
290+ // Get unique user IDs from new messages that aren't in the cache
291+ const uniqueUserIds = new Set(newMessages.map(msg => msg.senderId));
292+ const missingUserIds = Array.from(uniqueUserIds).filter(id => !this.userCache[id]);
248293
249- // Add all users from the existing cache
250- Object.values(this.userCache).forEach(user => mergedUsers.set(user.id, user));
294+ // Fetch missing user data in parallel
295+ if (missingUserIds.length > 0) {
296+ console.debug('Fetching missing user data for IDs:', missingUserIds);
297+ Promise.all(missingUserIds.map(async (userId) => {
298+ try {
299+ const userResponse = await fetch(`/api/users/${userId}`, {
300+ credentials: 'include'
301+ });
302+ if (userResponse.ok) {
303+ const userData = await userResponse.json();
304+ if (userData.success && userData.user) {
305+ this.userCache[userId] = userData.user;
306+ // Only add to users array if not already present
307+ if (!this.users.some(u => u.id === userId)) {
308+ this.users = [...this.users, userData.user];
309+ }
310+ }
311+ }
312+ } catch (error) {
313+ console.debug('Error fetching user data:', { userId, error });
314+ }
315+ }));
316+ }
251317
252- // Merge with the new list of users
253- users.forEach(user => mergedUsers.set(user.id, user));
318+ // Prepend new messages while maintaining uniqueness
319+ const uniqueNewMessages = newMessages.filter(msg => !this.messages.some(m => m.id === msg.id));
320+ this.messages = [...uniqueNewMessages, ...this.messages];
321+ }
254322
255- // Update this.users with all merged users
256- this.users = Array.from(mergedUsers.values());
323+ // Add method to update user cache for public access
324+ updateUserCache(users: User[]) {
325+ console.debug('Updating user cache with users:', users);
326+ // Merge incoming users with the existing cache
327+ users.forEach(user => {
328+ if (!this.userCache[user.id] || user.status !== this.userCache[user.id].status) {
329+ this.userCache[user.id] = user;
330+ // Update users array if not already present or if status changed
331+ const existingIndex = this.users.findIndex(u => u.id === user.id);
332+ if (existingIndex === -1) {
333+ this.users = [...this.users, user];
334+ } else if (user.status !== this.users[existingIndex].status) {
335+ this.users = [
336+ ...this.users.slice(0, existingIndex),
337+ user,
338+ ...this.users.slice(existingIndex + 1)
339+ ];
340+ }
341+ }
342+ });
343+ }
257344
258- // Update the cache as well
259- mergedUsers.forEach((user, id) => { this.userCache[id] = user; });
345+ // Add method to update online users for public access
346+ updateOnlineUsers(users: User[]) {
347+ console.debug('Updating online users:', users);
348+ this.users = users;
349+ // Also update cache
350+ users.forEach(user => {
351+ this.userCache[user.id] = user;
352+ });
260353 }
261354
262355 // Add method to update messages for public access
263356 updateMessages(messages: Message[]) {
264- this.messages = messages;
357+ // Deduplicate the messages by their id in case duplicates exist
358+ const uniqueMessages = Array.from(new Map(messages.map(msg => [msg.id, msg])).values());
359+ this.messages = uniqueMessages;
265360 }
266361
267362 async initializeMessages() {
@@ -364,147 +459,146 @@ class ChatState {
364459
365460 private setupSSE() {
366461 console.debug('Setting up SSE connection for chat messages');
367- // Clear any existing connection timeout
462+
463+ // Clear any existing timeouts
368464 if (this.connectionTimeout) {
369465 clearTimeout(this.connectionTimeout);
370466 this.connectionTimeout = null;
371467 }
372468
373- // Add a small delay before connecting to prevent rapid reconnection attempts
374- this.connectionTimeout = setTimeout(() => {
469+ if (this.connectionAttemptTimeout) {
470+ clearTimeout(this.connectionAttemptTimeout);
471+ this.connectionAttemptTimeout = null;
472+ }
473+
474+ // Debounce connection attempts
475+ this.connectionAttemptTimeout = setTimeout(() => {
476+ if (!this.currentUser) {
477+ console.debug('No current user, skipping SSE connection');
478+ return;
479+ }
480+
481+ if (this.isConnecting) {
482+ console.debug('Already connecting to SSE, skipping');
483+ return;
484+ }
485+
375486 this.connectSSE();
376- }, 500);
487+ }, 1000);
377488 }
378489
379- private connectSSE() {
380- if (this.eventSource) {
381- this.eventSource.close();
382- this.eventSource = null;
383- }
384-
385- if (this.connectionTimeout) {
386- clearTimeout(this.connectionTimeout);
387- this.connectionTimeout = null;
490+ private async connectSSE() {
491+ if (this.isConnecting) {
492+ console.debug('Already connecting to SSE, skipping');
493+ return;
388494 }
389-
495+
496+ this.isConnecting = true;
497+
390498 try {
499+ // Ensure clean disconnect of any existing connection
500+ if (this.eventSource) {
501+ console.debug('Closing existing SSE connection');
502+ this.eventSource.close();
503+ this.eventSource = null;
504+ }
505+
391506 console.debug('Connecting to SSE...');
392507 this.eventSource = new EventSource('/api/sse', { withCredentials: true });
393- this.isReconnecting = false;
394-
508+
395509 this.eventSource.onopen = () => {
396510 console.debug('SSE connection established');
397511 this.reconnectAttempts = 0;
398512 this.reconnectDelay = 1000;
399513 this.sseError = null;
400514 this.sseRetryAfter = null;
515+ this.isReconnecting = false;
401516 };
402-
517+
518+ // Set up error handling
403519 this.eventSource.onerror = (error) => {
404520 console.debug('SSE connection error:', error);
405521 if (this.eventSource?.readyState === EventSource.CLOSED) {
406522 this.handleDisconnection();
407523 }
408524 };
525+
526+ // Set up message handling
527+ this.setupMessageHandlers(this.eventSource);
528+
529+ } catch (error) {
530+ console.debug('Error setting up SSE connection:', error);
531+ this.handleDisconnection();
532+ } finally {
533+ this.isConnecting = false;
534+ }
535+ }
409536
410- // Set a connection timeout
411- this.connectionTimeout = setTimeout(() => {
412- if (this.eventSource?.readyState !== EventSource.OPEN) {
413- console.debug('SSE connection timeout');
414- this.handleDisconnection();
415- }
416- }, 10000); // 10 second timeout
537+ private setupMessageHandlers(eventSource: EventSource) {
538+ // Listen for chat messages
539+ eventSource.addEventListener('chatMessage', async (event: MessageEvent) => {
540+ try {
541+ const messageData = JSON.parse(event.data) as Message;
542+ console.debug('Received chat message via SSE:', messageData);
543+ // Deduplicate and update messages array with the new message
544+ this.messages = Array.from(new Map([...this.messages, messageData].map(m => [m.id, m])).values());
545+
546+ // Ensure the sender's data is available
547+ await this.ensureUserData(messageData.senderId);
548+ } catch (error) {
549+ console.debug('Error handling SSE chat message:', error);
550+ }
551+ });
552+ }
417553
418- // Listen for chat messages
419- this.eventSource.addEventListener('chatMessage', async (event: MessageEvent) => {
420- try {
421- const messageData = JSON.parse(event.data) as Message;
422- // Ensure the sender's data is available
423- if (!this.userCache[messageData.senderId]) {
424- console.debug('Fetching user data for new message:', messageData.senderId);
425- const response = await fetch(`/api/users/${messageData.senderId}`, { credentials: 'include' });
426- if (response.ok) {
427- const userData = await response.json();
428- if (userData.success && userData.user) {
429- this.userCache[userData.user.id] = userData.user;
430- if (!this.users.some(u => u.id === userData.user.id)) {
431- this.users = [...this.users, userData.user];
432- }
433- }
434- }
435- }
436- this.messages = [...this.messages, messageData];
437- await invalidate('chat:messages');
438- } catch (error) {
439- console.debug('Error parsing chatMessage SSE data:', error);
440- }
554+ private async ensureUserData(userId: string) {
555+ if (this.userCache[userId]) return;
556+
557+ console.debug('Fetching user data for:', userId);
558+ try {
559+ const response = await fetch(`/api/users/${userId}`, {
560+ credentials: 'include'
441561 });
442-
443- // Listen for user status updates
444- this.eventSource.addEventListener('userStatusUpdate', (event: MessageEvent) => {
445- try {
446- const userUpdate = JSON.parse(event.data);
447- console.debug('Received SSE user status update:', userUpdate);
448- this.updateUserStatus(userUpdate.userId, userUpdate.status, userUpdate.lastSeen);
449- } catch (error) {
450- console.debug('Error processing userStatusUpdate event:', error);
562+ if (response.ok) {
563+ const userData = await response.json();
564+ if (userData.success && userData.user) {
565+ this.userCache[userId] = userData.user;
566+ if (!this.users.some(u => u.id === userId)) {
567+ this.users = [...this.users, userData.user];
568+ }
451569 }
452- });
570+ }
453571 } catch (error) {
454- console.error('Error setting up SSE connection:', error);
455- this.handleDisconnection();
572+ console.debug('Error fetching user data:', error);
456573 }
457574 }
458575
459576 private handleDisconnection() {
460- if (this.isReconnecting) return;
461-
577+ console.debug('SSE connection closed, reconnecting...');
462578 this.isReconnecting = true;
463- if (this.eventSource) {
464- this.eventSource.close();
579+ this.reconnectAttempts++;
580+ if (this.reconnectAttempts > this.maxReconnectAttempts) {
581+ console.debug('Max reconnect attempts reached, giving up');
465582 this.eventSource = null;
583+ return;
466584 }
467-
468- if (this.reconnectAttempts < this.maxReconnectAttempts) {
469- console.debug(`Attempting to reconnect (${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})...`);
470- setTimeout(() => {
471- this.reconnectAttempts++;
472- this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000); // Max 30 second delay
473- this.connectSSE();
474- }, this.reconnectDelay);
475- } else {
476- console.debug('Max reconnection attempts reached');
477- this.sseError = 'Connection lost. Please refresh the page.';
478- }
585+ this.reconnectDelay *= 2; // Exponential backoff
586+ this.setupSSE();
479587 }
480588
481- // Add new method to enrich multiple messages at once
482589 enrichMessages(messages: Message[]): EnrichedMessage[] {
483- return messages.map(msg => this.enrichMessageWithUser(msg));
484- }
485-
486- // Add new method to efficiently get user data for messages
487- enrichMessageWithUser(message: Message): EnrichedMessage {
488- const user = this.getUserById(message.senderId);
489- return {
590+ console.debug('Enriching messages with user data', { messagesCount: messages.length, userCacheSize: Object.keys(this.userCache).length });
591+ return messages.map(message => ({
490592 ...message,
491- user: user || {
593+ user: this.userCache[message.senderId] || {
492594 id: message.senderId,
493595 nickname: 'Unknown User',
494596 status: 'offline',
495- password: '', // This is a system user, no password needed
496- createdAt: Date.now(),
497- lastSeen: null,
498- avatarUrl: null
597+ password: '',
598+ createdAt: 0
499599 }
500- };
501- }
502-
503- // Add getter for SSE error state
504- public getSSEError() {
505- return { error: this.sseError, retryAfter: this.sseRetryAfter };
600+ }));
506601 }
507602 }
508603
509-// Export a singleton instance
510-export const chatState = new ChatState(); ⏎̸
604+export const chatState = new ChatState();⏎̸
@@ -23,8 +23,18 @@ class ChatState {
23 private sseRetryAfter = $state<number | null>(null);23 private sseRetryAfter = $state<number | null>(null);
24 private connectionTimeout: ReturnType<typeof setTimeout> | null = null;24 private connectionTimeout: ReturnType<typeof setTimeout> | null = null;
25 private isReconnecting = $state(false);25 private isReconnecting = $state(false);
26+ private isSettingUser = $state(false);
27+ private isConnecting = $state(false);
28+ private connectionAttemptTimeout: ReturnType<typeof setTimeout> | null = null;
29+ private _memoizedEnrichedMessages: EnrichedMessage[] | null = null;
30+ private _lastEnrichmentKey: string = '';
26 31
27 public async reinitialize() {32 public async reinitialize() {
33+ if (this.isInitializing) {
34+ console.debug('Reinitialize already in progress, skipping.');
35+ return;
36+ }
37+ this.isInitializing = true;
28 console.debug('Reinitializing chat state...');38 console.debug('Reinitializing chat state...');
29 if (this.eventSource) {39 if (this.eventSource) {
30 this.eventSource.close();40 this.eventSource.close();
@@ -34,6 +44,8 @@ class ChatState {
34 this.reconnectDelay = 1000;44 this.reconnectDelay = 1000;
35 this.messages = [];45 this.messages = [];
36 if (this.currentUser) {46 if (this.currentUser) {
47+ console.debug('Waiting for session cookie update before setting up SSE...');
48+ await new Promise(resolve => setTimeout(resolve, 1000));
37 // First set up SSE to receive status updates49 // First set up SSE to receive status updates
38 this.setupSSE();50 this.setupSSE();
39 // Then initialize messages and room users51 // Then initialize messages and room users
@@ -42,6 +54,14 @@ class ChatState {
42 this.initializeRoomUsers()54 this.initializeRoomUsers()
43 ]);55 ]);
44 }56 }
57+ this.isInitializing = false;
58+ }
59+
60+ getSSEError() {
61+ return {
62+ error: this.sseError,
63+ retryAfter: this.sseRetryAfter
64+ };
45 }65 }
46 66
47 // User methods67 // User methods
@@ -51,34 +71,49 @@ class ChatState {
51 71
52 async setCurrentUser(user: User | null) {72 async setCurrentUser(user: User | null) {
53 console.debug('setCurrentUser called with', user);73 console.debug('setCurrentUser called with', user);
54- this.currentUser = user;74+
55-75+ // Prevent concurrent setCurrentUser calls
56- if (user) {76+ if (this.isSettingUser) {
57- // Add the current user to the cache immediately77+ console.debug('Already setting user, skipping');
58- this.userCache[user.id] = user;78+ return;
59- // First set up SSE to receive status updates79+ }
60- this.setupSSE();80+
61- // Then initialize messages and room users81+ this.isSettingUser = true;
62- await Promise.all([82+
63- this.initializeMessages(),83+ try {
64- this.initializeRoomUsers()84+ // If user is null and we already have no user, skip
65- ]);85+ if (!user && !this.currentUser) {
66- } else {86+ console.debug('No user to set and no current user, skipping');
67- // If no global session, clear SSE and state87+ return;
68- if (this.eventSource) {
69- this.eventSource.close();
70- this.eventSource = null;
71 }88 }
72- this.messages = [];89+
73- this.users = [];90+ // If same user is being set, skip
74- this.userCache = {};91+ if (user?.id === this.currentUser?.id) {
92+ console.debug('Same user being set, skipping');
93+ return;
94+ }
95+
96+ this.currentUser = user;
97+
98+ if (user) {
99+ // Update user cache with current user
100+ this.userCache[user.id] = user;
101+
102+ // Only reinitialize if we have a valid user
103+ await this.reinitialize();
104+ } else {
105+ // Cleanup when user is removed
106+ if (this.eventSource) {
107+ this.eventSource.close();
108+ this.eventSource = null;
109+ }
110+ this.messages = [];
111+ this.users = [];
112+ this.userCache = {};
113+ }
114+ } finally {
115+ this.isSettingUser = false;
75 }116 }
76-
77- // Invalidate both session and messages to trigger reloads
78- await Promise.all([
79- invalidate('chat:session'),
80- invalidate('chat:messages')
81- ]);
82 }117 }
83 118
84 private async initializeRoomUsers() {119 private async initializeRoomUsers() {
@@ -237,31 +272,91 @@ class ChatState {
237 272
238 // Message methods273 // Message methods
239 getMessages() {274 getMessages() {
240- // Return messages with enriched user data275+ const key = this.messages.map(m => m.id).join(',') + '|' + Object.keys(this.userCache).sort().join(',');
241- return this.enrichMessages(this.messages);276+ if (key === this._lastEnrichmentKey && this._memoizedEnrichedMessages !== null) {
277+ return this._memoizedEnrichedMessages;
278+ }
279+ this._lastEnrichmentKey = key;
280+ console.debug('Enriching messages with user data', {
281+ messagesCount: this.messages.length,
282+ userCacheSize: Object.keys(this.userCache).length
283+ });
284+ this._memoizedEnrichedMessages = this.enrichMessages(this.messages);
285+ return this._memoizedEnrichedMessages;
242 }286 }
243 287
244- // Add method to update user cache for public access288+ public prependMessages(newMessages: Message[]) {
245- updateUserCache(users: User[]) {289+ console.debug('Prepending messages:', { count: newMessages.length });
246- // Merge incoming users with the existing cache290+ // Get unique user IDs from new messages that aren't in the cache
247- const mergedUsers = new Map<string, User>();291+ const uniqueUserIds = new Set(newMessages.map(msg => msg.senderId));
292+ const missingUserIds = Array.from(uniqueUserIds).filter(id => !this.userCache[id]);
248 293
249- // Add all users from the existing cache294+ // Fetch missing user data in parallel
250- Object.values(this.userCache).forEach(user => mergedUsers.set(user.id, user));295+ if (missingUserIds.length > 0) {
296+ console.debug('Fetching missing user data for IDs:', missingUserIds);
297+ Promise.all(missingUserIds.map(async (userId) => {
298+ try {
299+ const userResponse = await fetch(`/api/users/${userId}`, {
300+ credentials: 'include'
301+ });
302+ if (userResponse.ok) {
303+ const userData = await userResponse.json();
304+ if (userData.success && userData.user) {
305+ this.userCache[userId] = userData.user;
306+ // Only add to users array if not already present
307+ if (!this.users.some(u => u.id === userId)) {
308+ this.users = [...this.users, userData.user];
309+ }
310+ }
311+ }
312+ } catch (error) {
313+ console.debug('Error fetching user data:', { userId, error });
314+ }
315+ }));
316+ }
251 317
252- // Merge with the new list of users318+ // Prepend new messages while maintaining uniqueness
253- users.forEach(user => mergedUsers.set(user.id, user));319+ const uniqueNewMessages = newMessages.filter(msg => !this.messages.some(m => m.id === msg.id));
320+ this.messages = [...uniqueNewMessages, ...this.messages];
321+ }
254 322
255- // Update this.users with all merged users323+ // Add method to update user cache for public access
256- this.users = Array.from(mergedUsers.values());324+ updateUserCache(users: User[]) {
325+ console.debug('Updating user cache with users:', users);
326+ // Merge incoming users with the existing cache
327+ users.forEach(user => {
328+ if (!this.userCache[user.id] || user.status !== this.userCache[user.id].status) {
329+ this.userCache[user.id] = user;
330+ // Update users array if not already present or if status changed
331+ const existingIndex = this.users.findIndex(u => u.id === user.id);
332+ if (existingIndex === -1) {
333+ this.users = [...this.users, user];
334+ } else if (user.status !== this.users[existingIndex].status) {
335+ this.users = [
336+ ...this.users.slice(0, existingIndex),
337+ user,
338+ ...this.users.slice(existingIndex + 1)
339+ ];
340+ }
341+ }
342+ });
343+ }
257 344
258- // Update the cache as well345+ // Add method to update online users for public access
259- mergedUsers.forEach((user, id) => { this.userCache[id] = user; });346+ updateOnlineUsers(users: User[]) {
347+ console.debug('Updating online users:', users);
348+ this.users = users;
349+ // Also update cache
350+ users.forEach(user => {
351+ this.userCache[user.id] = user;
352+ });
260 }353 }
261 354
262 // Add method to update messages for public access355 // Add method to update messages for public access
263 updateMessages(messages: Message[]) {356 updateMessages(messages: Message[]) {
264- this.messages = messages;357+ // Deduplicate the messages by their id in case duplicates exist
358+ const uniqueMessages = Array.from(new Map(messages.map(msg => [msg.id, msg])).values());
359+ this.messages = uniqueMessages;
265 }360 }
266 361
267 async initializeMessages() {362 async initializeMessages() {
@@ -364,147 +459,146 @@ class ChatState {
364 459
365 private setupSSE() {460 private setupSSE() {
366 console.debug('Setting up SSE connection for chat messages');461 console.debug('Setting up SSE connection for chat messages');
367- // Clear any existing connection timeout462+
463+ // Clear any existing timeouts
368 if (this.connectionTimeout) {464 if (this.connectionTimeout) {
369 clearTimeout(this.connectionTimeout);465 clearTimeout(this.connectionTimeout);
370 this.connectionTimeout = null;466 this.connectionTimeout = null;
371 }467 }
372 468
373- // Add a small delay before connecting to prevent rapid reconnection attempts469+ if (this.connectionAttemptTimeout) {
374- this.connectionTimeout = setTimeout(() => {470+ clearTimeout(this.connectionAttemptTimeout);
471+ this.connectionAttemptTimeout = null;
472+ }
473+
474+ // Debounce connection attempts
475+ this.connectionAttemptTimeout = setTimeout(() => {
476+ if (!this.currentUser) {
477+ console.debug('No current user, skipping SSE connection');
478+ return;
479+ }
480+
481+ if (this.isConnecting) {
482+ console.debug('Already connecting to SSE, skipping');
483+ return;
484+ }
485+
375 this.connectSSE();486 this.connectSSE();
376- }, 500);487+ }, 1000);
377 }488 }
378 489
379- private connectSSE() {490+ private async connectSSE() {
380- if (this.eventSource) {491+ if (this.isConnecting) {
381- this.eventSource.close();492+ console.debug('Already connecting to SSE, skipping');
382- this.eventSource = null;493+ return;
383- }
384-
385- if (this.connectionTimeout) {
386- clearTimeout(this.connectionTimeout);
387- this.connectionTimeout = null;
388 }494 }
389-495+
496+ this.isConnecting = true;
497+
390 try {498 try {
499+ // Ensure clean disconnect of any existing connection
500+ if (this.eventSource) {
501+ console.debug('Closing existing SSE connection');
502+ this.eventSource.close();
503+ this.eventSource = null;
504+ }
505+
391 console.debug('Connecting to SSE...');506 console.debug('Connecting to SSE...');
392 this.eventSource = new EventSource('/api/sse', { withCredentials: true });507 this.eventSource = new EventSource('/api/sse', { withCredentials: true });
393- this.isReconnecting = false;508+
394-
395 this.eventSource.onopen = () => {509 this.eventSource.onopen = () => {
396 console.debug('SSE connection established');510 console.debug('SSE connection established');
397 this.reconnectAttempts = 0;511 this.reconnectAttempts = 0;
398 this.reconnectDelay = 1000;512 this.reconnectDelay = 1000;
399 this.sseError = null;513 this.sseError = null;
400 this.sseRetryAfter = null;514 this.sseRetryAfter = null;
515+ this.isReconnecting = false;
401 };516 };
402-517+
518+ // Set up error handling
403 this.eventSource.onerror = (error) => {519 this.eventSource.onerror = (error) => {
404 console.debug('SSE connection error:', error);520 console.debug('SSE connection error:', error);
405 if (this.eventSource?.readyState === EventSource.CLOSED) {521 if (this.eventSource?.readyState === EventSource.CLOSED) {
406 this.handleDisconnection();522 this.handleDisconnection();
407 }523 }
408 };524 };
525+
526+ // Set up message handling
527+ this.setupMessageHandlers(this.eventSource);
528+
529+ } catch (error) {
530+ console.debug('Error setting up SSE connection:', error);
531+ this.handleDisconnection();
532+ } finally {
533+ this.isConnecting = false;
534+ }
535+ }
409 536
410- // Set a connection timeout537+ private setupMessageHandlers(eventSource: EventSource) {
411- this.connectionTimeout = setTimeout(() => {538+ // Listen for chat messages
412- if (this.eventSource?.readyState !== EventSource.OPEN) {539+ eventSource.addEventListener('chatMessage', async (event: MessageEvent) => {
413- console.debug('SSE connection timeout');540+ try {
414- this.handleDisconnection();541+ const messageData = JSON.parse(event.data) as Message;
415- }542+ console.debug('Received chat message via SSE:', messageData);
416- }, 10000); // 10 second timeout543+ // Deduplicate and update messages array with the new message
544+ this.messages = Array.from(new Map([...this.messages, messageData].map(m => [m.id, m])).values());
545+
546+ // Ensure the sender's data is available
547+ await this.ensureUserData(messageData.senderId);
548+ } catch (error) {
549+ console.debug('Error handling SSE chat message:', error);
550+ }
551+ });
552+ }
417 553
418- // Listen for chat messages554+ private async ensureUserData(userId: string) {
419- this.eventSource.addEventListener('chatMessage', async (event: MessageEvent) => {555+ if (this.userCache[userId]) return;
420- try {556+
421- const messageData = JSON.parse(event.data) as Message;557+ console.debug('Fetching user data for:', userId);
422- // Ensure the sender's data is available558+ try {
423- if (!this.userCache[messageData.senderId]) {559+ const response = await fetch(`/api/users/${userId}`, {
424- console.debug('Fetching user data for new message:', messageData.senderId);560+ credentials: 'include'
425- const response = await fetch(`/api/users/${messageData.senderId}`, { credentials: 'include' });
426- if (response.ok) {
427- const userData = await response.json();
428- if (userData.success && userData.user) {
429- this.userCache[userData.user.id] = userData.user;
430- if (!this.users.some(u => u.id === userData.user.id)) {
431- this.users = [...this.users, userData.user];
432- }
433- }
434- }
435- }
436- this.messages = [...this.messages, messageData];
437- await invalidate('chat:messages');
438- } catch (error) {
439- console.debug('Error parsing chatMessage SSE data:', error);
440- }
441 });561 });
442-562+ if (response.ok) {
443- // Listen for user status updates563+ const userData = await response.json();
444- this.eventSource.addEventListener('userStatusUpdate', (event: MessageEvent) => {564+ if (userData.success && userData.user) {
445- try {565+ this.userCache[userId] = userData.user;
446- const userUpdate = JSON.parse(event.data);566+ if (!this.users.some(u => u.id === userId)) {
447- console.debug('Received SSE user status update:', userUpdate);567+ this.users = [...this.users, userData.user];
448- this.updateUserStatus(userUpdate.userId, userUpdate.status, userUpdate.lastSeen);568+ }
449- } catch (error) {
450- console.debug('Error processing userStatusUpdate event:', error);
451 }569 }
452- });570+ }
453 } catch (error) {571 } catch (error) {
454- console.error('Error setting up SSE connection:', error);572+ console.debug('Error fetching user data:', error);
455- this.handleDisconnection();
456 }573 }
457 }574 }
458 575
459 private handleDisconnection() {576 private handleDisconnection() {
460- if (this.isReconnecting) return;577+ console.debug('SSE connection closed, reconnecting...');
461-
462 this.isReconnecting = true;578 this.isReconnecting = true;
463- if (this.eventSource) {579+ this.reconnectAttempts++;
464- this.eventSource.close();580+ if (this.reconnectAttempts > this.maxReconnectAttempts) {
581+ console.debug('Max reconnect attempts reached, giving up');
465 this.eventSource = null;582 this.eventSource = null;
583+ return;
466 }584 }
467-585+ this.reconnectDelay *= 2; // Exponential backoff
468- if (this.reconnectAttempts < this.maxReconnectAttempts) {586+ this.setupSSE();
469- console.debug(`Attempting to reconnect (${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})...`);
470- setTimeout(() => {
471- this.reconnectAttempts++;
472- this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000); // Max 30 second delay
473- this.connectSSE();
474- }, this.reconnectDelay);
475- } else {
476- console.debug('Max reconnection attempts reached');
477- this.sseError = 'Connection lost. Please refresh the page.';
478- }
479 }587 }
480 588
481- // Add new method to enrich multiple messages at once
482 enrichMessages(messages: Message[]): EnrichedMessage[] {589 enrichMessages(messages: Message[]): EnrichedMessage[] {
483- return messages.map(msg => this.enrichMessageWithUser(msg));590+ console.debug('Enriching messages with user data', { messagesCount: messages.length, userCacheSize: Object.keys(this.userCache).length });
484- }591+ return messages.map(message => ({
485-
486- // Add new method to efficiently get user data for messages
487- enrichMessageWithUser(message: Message): EnrichedMessage {
488- const user = this.getUserById(message.senderId);
489- return {
490 ...message,592 ...message,
491- user: user || {593+ user: this.userCache[message.senderId] || {
492 id: message.senderId,594 id: message.senderId,
493 nickname: 'Unknown User',595 nickname: 'Unknown User',
494 status: 'offline',596 status: 'offline',
495- password: '', // This is a system user, no password needed597+ password: '',
496- createdAt: Date.now(),598+ createdAt: 0
497- lastSeen: null,
498- avatarUrl: null
499 }599 }
500- };600+ }));
501- }
502-
503- // Add getter for SSE error state
504- public getSSEError() {
505- return { error: this.sseError, retryAfter: this.sseRetryAfter };
506 }601 }
507 }602 }
508 603
509-// Export a singleton instance604+export const chatState = new ChatState();⏎̸
510-export const chatState = new ChatState(); ⏎̸
M src/routes/+layout.svelte +16 -5
@@ -7,6 +7,7 @@
77 let { children, data } = $props();
88 let isVisible = $state(true);
99 let statusInterval = $state<number | undefined>(undefined);
10+ let lastUserUpdate = $state<string | null>(null);
1011
1112 async function updateUserStatus(status: 'online' | 'offline' | 'busy') {
1213 if (!data.user?.id) return;
@@ -112,6 +113,15 @@
112113
113114 $effect(() => {
114115 if (!browser) return;
116+
117+ const currentUserJson = data.user ? JSON.stringify(data.user) : null;
118+ if (currentUserJson === lastUserUpdate) {
119+ console.debug('Skipping duplicate user update');
120+ return;
121+ }
122+
123+ lastUserUpdate = currentUserJson;
124+
115125 console.debug('Layout reactive update - Session state:', {
116126 timestamp: new Date().toISOString(),
117127 hasUser: !!data.user,
@@ -128,11 +138,12 @@
128138 cookiePresent: document.cookie.includes('session='),
129139 isVisible
130140 });
131- });
132-
133- $effect(() => {
134- console.debug('Updating global chatState with data.user:', data.user);
135- chatState.setCurrentUser(data.user);
141+
142+ // Only update chat state if we have valid session data
143+ if (data.session?.id) {
144+ console.debug('Updating global chatState with data.user:', data.user);
145+ chatState.setCurrentUser(data.user);
146+ }
136147 });
137148 </script>
138149
@@ -7,6 +7,7 @@
7 let { children, data } = $props();7 let { children, data } = $props();
8 let isVisible = $state(true);8 let isVisible = $state(true);
9 let statusInterval = $state<number | undefined>(undefined);9 let statusInterval = $state<number | undefined>(undefined);
10+ let lastUserUpdate = $state<string | null>(null);
10 11
11 async function updateUserStatus(status: 'online' | 'offline' | 'busy') {12 async function updateUserStatus(status: 'online' | 'offline' | 'busy') {
12 if (!data.user?.id) return;13 if (!data.user?.id) return;
@@ -112,6 +113,15 @@
112 113
113 $effect(() => {114 $effect(() => {
114 if (!browser) return;115 if (!browser) return;
116+
117+ const currentUserJson = data.user ? JSON.stringify(data.user) : null;
118+ if (currentUserJson === lastUserUpdate) {
119+ console.debug('Skipping duplicate user update');
120+ return;
121+ }
122+
123+ lastUserUpdate = currentUserJson;
124+
115 console.debug('Layout reactive update - Session state:', {125 console.debug('Layout reactive update - Session state:', {
116 timestamp: new Date().toISOString(),126 timestamp: new Date().toISOString(),
117 hasUser: !!data.user,127 hasUser: !!data.user,
@@ -128,11 +138,12 @@
128 cookiePresent: document.cookie.includes('session='),138 cookiePresent: document.cookie.includes('session='),
129 isVisible139 isVisible
130 });140 });
131- });141+
132-142+ // Only update chat state if we have valid session data
133- $effect(() => {143+ if (data.session?.id) {
134- console.debug('Updating global chatState with data.user:', data.user);144+ console.debug('Updating global chatState with data.user:', data.user);
135- chatState.setCurrentUser(data.user);145+ chatState.setCurrentUser(data.user);
146+ }
136 });147 });
137 </script>148 </script>
138 149
M src/routes/api/chat/messages/+server.ts +50 -12
@@ -10,7 +10,7 @@ import type {
1010 import { desc } from 'drizzle-orm';
1111 import { error } from '@sveltejs/kit';
1212 import { sseEmitter } from '$lib/sseEmitter';
13-import { eq } from 'drizzle-orm';
13+import { eq, lt, and } from 'drizzle-orm';
1414 import { chatRooms, DEFAULT_CHAT_ROOM_ID } from '$lib/db/schema';
1515 import { users } from '$lib/db/schema';
1616 import { ensureDefaultChatRoom } from '$lib/db/schema';
@@ -78,8 +78,10 @@ async function initializeCache() {
7878
7979 // GET endpoint: fetch messages from cache or DB
8080 export async function GET({ request, locals }) {
81- const isPublic = new URL(request.url).searchParams.get('public') === 'true';
82- const roomId = new URL(request.url).searchParams.get('roomId') || DEFAULT_CHAT_ROOM_ID;
81+ const url = new URL(request.url);
82+ const isPublic = url.searchParams.get('public') === 'true';
83+ const roomId = url.searchParams.get('roomId') || DEFAULT_CHAT_ROOM_ID;
84+ const beforeTimestamp = url.searchParams.get('before');
8385
8486 if (!isPublic && !locals.user) {
8587 throw error(401, 'Authentication required');
@@ -94,19 +96,48 @@ export async function GET({ request, locals }) {
9496 if (!isCacheInitialized) {
9597 await initializeCache();
9698 }
97- console.log('[Chat] Fetching public messages from cache', { roomId });
98- fetchedMessages = messageCache.getMessages(roomId, 10);
99+
100+ if (beforeTimestamp) {
101+ // For public users requesting older messages, fetch from DB with limit
102+ console.log('[Chat] Fetching public messages from DB before timestamp:', beforeTimestamp);
103+ const query = db.select()
104+ .from(messages)
105+ .where(and(
106+ eq(messages.chatRoomId, roomId),
107+ lt(messages.timestamp, parseInt(beforeTimestamp))
108+ ))
109+ .orderBy(desc(messages.timestamp))
110+ .limit(10);
111+ fetchedMessages = (await query).reverse();
112+ } else {
113+ console.log('[Chat] Fetching public messages from cache', { roomId });
114+ fetchedMessages = messageCache.getMessages(roomId, 10);
115+ }
99116 } else {
100117 if (!locals.user) {
101118 throw error(401, 'Authentication required');
102119 }
103- console.log('[Chat] Fetching messages from DB for instant chat', { roomId });
104- const query = db.select()
105- .from(messages)
106- .where(eq(messages.chatRoomId, roomId))
107- .orderBy(desc(messages.timestamp))
108- .limit(50);
109- fetchedMessages = (await query).reverse();
120+
121+ if (beforeTimestamp) {
122+ console.log('[Chat] Fetching messages from DB before timestamp:', beforeTimestamp);
123+ const query = db.select()
124+ .from(messages)
125+ .where(and(
126+ eq(messages.chatRoomId, roomId),
127+ lt(messages.timestamp, parseInt(beforeTimestamp))
128+ ))
129+ .orderBy(desc(messages.timestamp))
130+ .limit(100);
131+ fetchedMessages = (await query).reverse();
132+ } else {
133+ console.log('[Chat] Fetching messages from DB for instant chat', { roomId });
134+ const query = db.select()
135+ .from(messages)
136+ .where(eq(messages.chatRoomId, roomId))
137+ .orderBy(desc(messages.timestamp))
138+ .limit(50);
139+ fetchedMessages = (await query).reverse();
140+ }
110141 }
111142
112143 const response: GetMessagesResponse = {
@@ -234,6 +265,13 @@ export async function POST({ request, locals }: { request: Request, locals: App.
234265 timestamp: newMessage.timestamp
235266 });
236267
268+ // Update message cache
269+ messageCache.addMessage(newMessage);
270+ console.debug('[Chat] Message added to cache', {
271+ messageId: newMessage.id,
272+ chatRoomId: newMessage.chatRoomId
273+ });
274+
237275 // Broadcast the new message via the unified SSE emitter
238276 sseEmitter.emit('sse', { type: 'chatMessage', data: newMessage });
239277
@@ -10,7 +10,7 @@ import type {
10 import { desc } from 'drizzle-orm';10 import { desc } from 'drizzle-orm';
11 import { error } from '@sveltejs/kit';11 import { error } from '@sveltejs/kit';
12 import { sseEmitter } from '$lib/sseEmitter';12 import { sseEmitter } from '$lib/sseEmitter';
13-import { eq } from 'drizzle-orm';13+import { eq, lt, and } from 'drizzle-orm';
14 import { chatRooms, DEFAULT_CHAT_ROOM_ID } from '$lib/db/schema';14 import { chatRooms, DEFAULT_CHAT_ROOM_ID } from '$lib/db/schema';
15 import { users } from '$lib/db/schema';15 import { users } from '$lib/db/schema';
16 import { ensureDefaultChatRoom } from '$lib/db/schema';16 import { ensureDefaultChatRoom } from '$lib/db/schema';
@@ -78,8 +78,10 @@ async function initializeCache() {
78 78
79 // GET endpoint: fetch messages from cache or DB79 // GET endpoint: fetch messages from cache or DB
80 export async function GET({ request, locals }) {80 export async function GET({ request, locals }) {
81- const isPublic = new URL(request.url).searchParams.get('public') === 'true';81+ const url = new URL(request.url);
82- const roomId = new URL(request.url).searchParams.get('roomId') || DEFAULT_CHAT_ROOM_ID;82+ const isPublic = url.searchParams.get('public') === 'true';
83+ const roomId = url.searchParams.get('roomId') || DEFAULT_CHAT_ROOM_ID;
84+ const beforeTimestamp = url.searchParams.get('before');
83 85
84 if (!isPublic && !locals.user) {86 if (!isPublic && !locals.user) {
85 throw error(401, 'Authentication required');87 throw error(401, 'Authentication required');
@@ -94,19 +96,48 @@ export async function GET({ request, locals }) {
94 if (!isCacheInitialized) {96 if (!isCacheInitialized) {
95 await initializeCache();97 await initializeCache();
96 }98 }
97- console.log('[Chat] Fetching public messages from cache', { roomId });99+
98- fetchedMessages = messageCache.getMessages(roomId, 10);100+ if (beforeTimestamp) {
101+ // For public users requesting older messages, fetch from DB with limit
102+ console.log('[Chat] Fetching public messages from DB before timestamp:', beforeTimestamp);
103+ const query = db.select()
104+ .from(messages)
105+ .where(and(
106+ eq(messages.chatRoomId, roomId),
107+ lt(messages.timestamp, parseInt(beforeTimestamp))
108+ ))
109+ .orderBy(desc(messages.timestamp))
110+ .limit(10);
111+ fetchedMessages = (await query).reverse();
112+ } else {
113+ console.log('[Chat] Fetching public messages from cache', { roomId });
114+ fetchedMessages = messageCache.getMessages(roomId, 10);
115+ }
99 } else {116 } else {
100 if (!locals.user) {117 if (!locals.user) {
101 throw error(401, 'Authentication required');118 throw error(401, 'Authentication required');
102 }119 }
103- console.log('[Chat] Fetching messages from DB for instant chat', { roomId });120+
104- const query = db.select()121+ if (beforeTimestamp) {
105- .from(messages)122+ console.log('[Chat] Fetching messages from DB before timestamp:', beforeTimestamp);
106- .where(eq(messages.chatRoomId, roomId))123+ const query = db.select()
107- .orderBy(desc(messages.timestamp))124+ .from(messages)
108- .limit(50);125+ .where(and(
109- fetchedMessages = (await query).reverse();126+ eq(messages.chatRoomId, roomId),
127+ lt(messages.timestamp, parseInt(beforeTimestamp))
128+ ))
129+ .orderBy(desc(messages.timestamp))
130+ .limit(100);
131+ fetchedMessages = (await query).reverse();
132+ } else {
133+ console.log('[Chat] Fetching messages from DB for instant chat', { roomId });
134+ const query = db.select()
135+ .from(messages)
136+ .where(eq(messages.chatRoomId, roomId))
137+ .orderBy(desc(messages.timestamp))
138+ .limit(50);
139+ fetchedMessages = (await query).reverse();
140+ }
110 }141 }
111 142
112 const response: GetMessagesResponse = {143 const response: GetMessagesResponse = {
@@ -234,6 +265,13 @@ export async function POST({ request, locals }: { request: Request, locals: App.
234 timestamp: newMessage.timestamp265 timestamp: newMessage.timestamp
235 });266 });
236 267
268+ // Update message cache
269+ messageCache.addMessage(newMessage);
270+ console.debug('[Chat] Message added to cache', {
271+ messageId: newMessage.id,
272+ chatRoomId: newMessage.chatRoomId
273+ });
274+
237 // Broadcast the new message via the unified SSE emitter275 // Broadcast the new message via the unified SSE emitter
238 sseEmitter.emit('sse', { type: 'chatMessage', data: newMessage });276 sseEmitter.emit('sse', { type: 'chatMessage', data: newMessage });
239 277
M src/routes/api/sse/+server.ts +7 -1
@@ -95,7 +95,7 @@ if (typeof process !== 'undefined') {
9595 });
9696 }
9797
98-export const GET: RequestHandler = async ({ locals }) => {
98+export const GET: RequestHandler = async ({ request, locals }) => {
9999 if (!locals.user) {
100100 return new Response('Unauthorized', { status: 401 });
101101 }
@@ -145,6 +145,12 @@ export const GET: RequestHandler = async ({ locals }) => {
145145 };
146146
147147 sseEmitter.addListener('sse', onSSE);
148+ request.signal.addEventListener('abort', () => {
149+ if (onSSE) {
150+ sseEmitter.removeListener('sse', onSSE);
151+ console.log('[SSE] Removed listener due to abort signal.');
152+ }
153+ });
148154 console.log('[SSE] Added new listener');
149155
150156 // Send initial connection message
@@ -95,7 +95,7 @@ if (typeof process !== 'undefined') {
95 });95 });
96 }96 }
97 97
98-export const GET: RequestHandler = async ({ locals }) => {98+export const GET: RequestHandler = async ({ request, locals }) => {
99 if (!locals.user) {99 if (!locals.user) {
100 return new Response('Unauthorized', { status: 401 });100 return new Response('Unauthorized', { status: 401 });
101 }101 }
@@ -145,6 +145,12 @@ export const GET: RequestHandler = async ({ locals }) => {
145 };145 };
146 146
147 sseEmitter.addListener('sse', onSSE);147 sseEmitter.addListener('sse', onSSE);
148+ request.signal.addEventListener('abort', () => {
149+ if (onSSE) {
150+ sseEmitter.removeListener('sse', onSSE);
151+ console.log('[SSE] Removed listener due to abort signal.');
152+ }
153+ });
148 console.log('[SSE] Added new listener');154 console.log('[SSE] Added new listener');
149 155
150 // Send initial connection message156 // Send initial connection message

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