detached c624865

refactor(frontend): use Convex real-time subscriptions instead of polling

commit
c624865f4d63d29336cf1eb181c0e3995cfe9532
author
bnema · · 2026-01-18 18:34 UTC
parent
53f93b1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +343 -1061
M src/lib/components/chat-room.svelte +309 -367
@@ -1,5 +1,8 @@
11 <script lang="ts">
22 import { chatState } from '../states/chat.svelte';
3+ import { useQuery } from 'convex-svelte';
4+ import { api } from '../../convex/_generated/api';
5+ import type { Id } from '../../convex/_generated/dataModel';
36 import type { Message, EnrichedMessage, SafeUser } from '../types/chat';
47 import { onMount } from 'svelte';
58 import { browser } from '$app/environment';
@@ -22,117 +25,156 @@
2225 import { loadWindowState, saveWindowState, debounce } from '$lib/utils/chat-window-state';
2326 import { MAX_MESSAGE_LENGTH } from '$lib/validation/message';
2427
25- // Props destructuring must come first
28+ // Props
2629 let { showChatRoom = $bindable(), initialTextStyle = DEFAULT_TEXT_STYLE } = $props();
2730
28- // Initialize with default values for SSR (800x600 - 16:10 aspect ratio)
31+ // ============ CONVEX REAL-TIME SUBSCRIPTIONS ============
32+
33+ // Subscribe to default room (to get room ID)
34+ const defaultRoomQuery = useQuery(api.queries.getDefaultRoomPublic, {});
35+
36+ // Subscribe to users (buddy list) - real-time updates
37+ const usersQuery = useQuery(api.queries.getUsersPublic, {});
38+
39+ // Room ID from Convex query
40+ const roomId = $derived(defaultRoomQuery.data?.id as Id<'chatRooms'> | undefined);
41+
42+ // Subscribe to messages - only when room ID is available
43+ const messagesQuery = $derived(
44+ roomId ? useQuery(api.queries.getMessagesPublic, () => ({ roomId: roomId!, limit: 100 })) : null
45+ );
46+
47+ // Transform Convex users to SafeUser format
48+ const onlineUsers = $derived.by<SafeUser[]>(() => {
49+ if (!usersQuery.data) return [];
50+ return usersQuery.data
51+ .map((u) => ({
52+ id: u.id,
53+ nickname: u.nickname,
54+ status: u.status as SafeUser['status'],
55+ avatarUrl: u.avatarUrl ?? null,
56+ lastSeen: u.lastSeen ?? null
57+ }))
58+ .sort((a, b) => {
59+ const statusOrder: Record<string, number> = { online: 0, away: 1, busy: 2, offline: 3 };
60+ return (statusOrder[a.status] ?? 3) - (statusOrder[b.status] ?? 3);
61+ });
62+ });
63+
64+ // Transform Convex messages to EnrichedMessage format
65+ const messages = $derived.by<EnrichedMessage[]>(() => {
66+ if (!messagesQuery?.data) return [];
67+ return messagesQuery.data.map((msg) => ({
68+ id: msg.id,
69+ chatRoomId: msg.chatRoomId,
70+ senderId: msg.senderId,
71+ content: msg.content,
72+ type: msg.type as Message['type'],
73+ timestamp: msg.timestamp,
74+ styleData: msg.styleData,
75+ hasFormatting: msg.hasFormatting ?? false,
76+ user: msg.sender
77+ ? {
78+ id: msg.sender.id,
79+ nickname: msg.sender.nickname,
80+ status: msg.sender.status as SafeUser['status'],
81+ avatarUrl: msg.sender.avatarUrl ?? null,
82+ lastSeen: null
83+ }
84+ : {
85+ id: msg.senderId,
86+ nickname: 'Unknown User',
87+ status: 'offline' as const,
88+ avatarUrl: null,
89+ lastSeen: null
90+ }
91+ }));
92+ });
93+
94+ // Loading and error states from Convex
95+ const isInitialLoading = $derived(
96+ defaultRoomQuery.isLoading || usersQuery.isLoading || messagesQuery?.isLoading
97+ );
98+ const connectionError = $derived.by<string | null>(() => {
99+ if (defaultRoomQuery.error) return 'Erreur de connexion au salon';
100+ if (usersQuery.error) return 'Erreur de chargement des utilisateurs';
101+ if (messagesQuery?.error) return 'Erreur de chargement des messages';
102+ return null;
103+ });
104+
105+ // Update chatState with room ID when available
106+ $effect(() => {
107+ if (roomId) {
108+ chatState.setCurrentRoomId(roomId);
109+ chatState.setConnectionStatus('connected');
110+ }
111+ });
112+
113+ // ============ USER STATE ============
114+
115+ let currentUser = $state<SafeUser | null>(null);
116+
117+ // Sync current user from chatState
118+ $effect(() => {
119+ currentUser = chatState.getCurrentUser();
120+ });
121+
122+ // ============ WINDOW STATE ============
123+
29124 let windowWidth = $state(800);
30125 let windowHeight = $state(600);
31126 let windowX = $state(0);
32127 let windowY = $state(0);
33128 let isMobile = $state(false);
34- let currentMessage = $state('');
35129 let showUserList = $state(false);
36130 let isMaximized = $state(false);
37- let isInitialLoading = $state(true);
38- let showAuth = $state(false);
39131 let isMinimized = $state(false);
40- let isCentered = $state(true); // true = window stays centered on resize, false = user has manually positioned
41- let inputScrollLeft = $state(0); // Track input scroll position for gradient overlay sync
132+ let isCentered = $state(true);
42133
43- // Handle input changes - truncate and sync scroll
44- function handleInputChange(e: Event) {
45- const input = e.target as HTMLInputElement;
46- // Truncate if over limit (handles paste)
47- if (input.value.length > MAX_MESSAGE_LENGTH) {
48- input.value = input.value.slice(0, MAX_MESSAGE_LENGTH);
49- currentMessage = input.value;
50- }
51- // Sync scroll position for gradient overlay
52- inputScrollLeft = input.scrollLeft;
53- }
54-
55- function handleInputScroll(e: Event) {
56- const input = e.target as HTMLInputElement;
57- inputScrollLeft = input.scrollLeft;
58- }
134+ // ============ INPUT STATE ============
59135
60- // Text formatting state - initialize directly with merged initial style
136+ let currentMessage = $state('');
137+ let inputScrollLeft = $state(0);
61138 let currentTextStyle = $state<TextStyle>({
62139 ...DEFAULT_TEXT_STYLE,
63140 ...initialTextStyle,
64141 color: initialTextStyle.color || '#000000'
65142 });
66143
67- // Rate limiting state
144+ // ============ RATE LIMITING STATE ============
145+
68146 let cooldownEndTime = $state<number | null>(null);
69147 let cooldownProgress = $state(0);
70148 let cooldownInterval: ReturnType<typeof setInterval> | null = null;
71149 let rateLimitWarning = $state<string | null>(null);
150+ let isSendingMessage = $state(false);
72151
73- // At the top along with other variable declarations, add:
74- let roomPollInterval: ReturnType<typeof setInterval> | null = null;
75-
76- // Debounced save function for window state persistence
77- const debouncedSaveWindowState = debounce(saveWindowState, 300);
78-
79- function updateCooldownProgress() {
80- if (!cooldownEndTime) return;
81-
82- const now = Date.now();
83- if (now >= cooldownEndTime) {
84- cooldownEndTime = null;
85- cooldownProgress = 0;
86- rateLimitWarning = null;
87- if (cooldownInterval) {
88- clearInterval(cooldownInterval);
89- cooldownInterval = null;
90- }
91- return;
92- }
93-
94- cooldownProgress = (cooldownEndTime - now) / 1000;
95- }
152+ // ============ AUTH STATE ============
96153
97- function startCooldownTimer(retryAfter: number) {
98- cooldownEndTime = Date.now() + retryAfter;
99- if (cooldownInterval) clearInterval(cooldownInterval);
100- cooldownInterval = setInterval(updateCooldownProgress, 100);
101- updateCooldownProgress();
102- }
154+ let showAuth = $state(false);
103155
104- function handleClose() {
105- showChatRoom = false;
106- }
156+ // ============ SCROLL STATE ============
107157
108- // Reactive state using derived values
109- let messages = $state<EnrichedMessage[]>([]);
110- let onlineUsers = $state<SafeUser[]>([]);
111- let currentUser = $state<SafeUser | null>(null);
112158 let isLoadingMore = $state(false);
113159 let hasMoreMessages = $state(true);
114160 let oldestMessageTimestamp = $state<number | null>(null);
115- let currentRoomId = $state('');
116-
117- // Compute visible messages based on login status
118- let visibleMessages = $derived(
119- (() => {
120- const isLoggedIn = Boolean(currentUser);
121- const messageCount = messages.length;
122- const result: EnrichedMessage[] = isLoggedIn
123- ? messages
124- : messages.slice(Math.max(0, messageCount - 50));
125- return result;
126- })()
127- );
161+ let lastMessageCount = $state(0);
162+
163+ // ============ DERIVED STATE ============
164+
165+ // Visible messages (limit for non-logged-in users)
166+ const visibleMessages = $derived.by<EnrichedMessage[]>(() => {
167+ const isLoggedIn = Boolean(currentUser);
168+ return isLoggedIn ? messages : messages.slice(Math.max(0, messages.length - 50));
169+ });
128170
129- // Add a derived state to check if we're showing the registration prompt
130- let showRegistrationPrompt = $derived(!currentUser && messages.length > 50);
171+ // Show registration prompt for non-logged-in users
172+ const showRegistrationPrompt = $derived(!currentUser && messages.length > 50);
131173
132- // Character counter - show when approaching limit (80%+)
133- const CHAR_WARNING_THRESHOLD = Math.floor(MAX_MESSAGE_LENGTH * 0.8); // 400 chars
134- let showCharCounter = $derived(currentMessage.length >= CHAR_WARNING_THRESHOLD);
135- let charCounterClass = $derived(
174+ // Character counter
175+ const CHAR_WARNING_THRESHOLD = Math.floor(MAX_MESSAGE_LENGTH * 0.8);
176+ const showCharCounter = $derived(currentMessage.length >= CHAR_WARNING_THRESHOLD);
177+ const charCounterClass = $derived(
136178 currentMessage.length >= MAX_MESSAGE_LENGTH
137179 ? 'at-limit'
138180 : currentMessage.length >= MAX_MESSAGE_LENGTH * 0.95
@@ -140,244 +182,106 @@
140182 : 'warning'
141183 );
142184
143- // Add SSE error state
144- let sseError = $state<string | null>(null);
145- let sseRetryAfter = $state<number | null>(null);
146-
147- // Update the effect to check for SSE errors
148- $effect(() => {
149- const user = chatState.getCurrentUser();
150- if (user?.id !== currentUser?.id) {
151- currentUser = user;
152- hasMoreMessages = true; // Reset when user changes
153- }
154-
155- // Check for SSE errors
156- const { error, retryAfter } = chatState.getSSEError();
157- sseError = error;
158- sseRetryAfter = retryAfter;
185+ // User counts
186+ const totalUsers = $derived(onlineUsers.length);
187+ const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline'));
188+ const usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline'));
159189
160- // Update messages if there's any difference
161- const stateMessages = chatState.getMessages();
162- if (JSON.stringify(stateMessages) !== JSON.stringify(messages)) {
163- messages = stateMessages;
190+ // ============ EFFECTS ============
164191
165- // Update oldest message timestamp
166- if (messages.length > 0) {
167- oldestMessageTimestamp = Math.min(...messages.map((m) => m.timestamp));
168- }
169-
170- // Scroll to bottom when new messages arrive
171- const chatArea = document.querySelector('.chat-area');
172- if (chatArea) {
173- setTimeout(() => {
174- chatArea.scrollTop = chatArea.scrollHeight;
175- }, 0);
192+ // Auto-scroll to bottom when new messages arrive
193+ $effect(() => {
194+ if (messages.length > lastMessageCount && messages.length > 0) {
195+ lastMessageCount = messages.length;
196+ if (browser) {
197+ const chatArea = document.querySelector('.chat-area');
198+ if (chatArea) {
199+ setTimeout(() => {
200+ chatArea.scrollTop = chatArea.scrollHeight;
201+ }, 0);
202+ }
176203 }
177204 }
178-
179- // Update online users
180- const stateOnlineUsers = chatState.getOnlineUsers();
181- if (JSON.stringify(stateOnlineUsers) !== JSON.stringify(onlineUsers)) {
182- onlineUsers = stateOnlineUsers;
183- }
184205 });
185206
186- // Fetch public messages only once when needed
187- let fetchedPublicMessages = false;
188- let fetchedPublicRoom = false;
189-
207+ // Update oldest timestamp for pagination
190208 $effect(() => {
191- if (!currentUser) {
192- // Fetch both public room data and messages in parallel but handle them in sequence
193- if (!fetchedPublicRoom && !fetchedPublicMessages) {
194- fetchedPublicRoom = true;
195- fetchedPublicMessages = true;
196-
197- // Get the default chat room id
198- const defaultRoomId = chatState.getDefaultChatRoomId();
199-
200- // Fetch both in parallel
201- Promise.all([
202- fetch(`/api/rooms/${defaultRoomId}?public=true`).then((r) => r.json()),
203- fetch('/api/chat/messages?public=true').then((r) => r.json())
204- ])
205- .then(([roomData, messagesData]) => {
206- if (roomData.success) {
207- // Update both the chat state and local state with the buddy list
208- chatState.updateOnlineUsers(roomData.buddyList);
209- onlineUsers = roomData.buddyList;
210- console.debug('Updated public buddy list:', roomData.buddyList);
211- }
212-
213- // Then update messages after buddy list is cached
214- if (messagesData.success && messagesData.messages) {
215- chatState.updateMessages(messagesData.messages);
216- messages = chatState.enrichMessages(messagesData.messages);
217- console.debug('Updated public messages:', messages);
218- }
219- })
220- .catch((error) => {
221- console.error('Error fetching public data:', error);
222- onlineUsers = [];
223- messages = [];
224- });
225- }
209+ if (messages.length > 0) {
210+ oldestMessageTimestamp = Math.min(...messages.map((m) => m.timestamp));
226211 }
227212 });
228213
229- onMount(() => {
230- if (!browser) return;
231-
232- // Detect mobile first
233- isMobile = window.innerWidth <= 768;
234-
235- // Load saved state from localStorage (only for desktop)
236- if (!isMobile) {
237- const savedState = loadWindowState();
238- // Only apply saved position/size if they're within current viewport bounds
239- const maxWidth = window.innerWidth - 40;
240- const maxHeight = window.innerHeight - 40;
214+ // ============ HELPERS ============
241215
242- windowWidth = Math.min(savedState.width, maxWidth);
243- windowHeight = Math.min(savedState.height, maxHeight);
216+ const debouncedSaveWindowState = debounce(saveWindowState, 300);
244217
245- // Handle centering: if x or y is -1, center the window
246- if (savedState.x === -1) {
247- windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
248- } else {
249- windowX = Math.max(0, Math.min(savedState.x, window.innerWidth - windowWidth));
250- }
251- if (savedState.y === -1) {
252- windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
253- } else {
254- windowY = Math.max(0, Math.min(savedState.y, window.innerHeight - windowHeight));
218+ function updateCooldownProgress() {
219+ if (!cooldownEndTime) return;
220+ const now = Date.now();
221+ if (now >= cooldownEndTime) {
222+ cooldownEndTime = null;
223+ cooldownProgress = 0;
224+ rateLimitWarning = null;
225+ if (cooldownInterval) {
226+ clearInterval(cooldownInterval);
227+ cooldownInterval = null;
255228 }
256-
257- isMaximized = savedState.isMaximized;
258- isMinimized = savedState.isMinimized;
259- showUserList = savedState.showUserList;
260- isCentered = savedState.isCentered;
229+ return;
261230 }
231+ cooldownProgress = (cooldownEndTime - now) / 1000;
232+ }
262233
263- const handleResize = () => {
264- const wasMobile = isMobile;
265- isMobile = window.innerWidth <= 768;
266- if (isMobile) {
267- windowWidth = window.innerWidth;
268- windowHeight = window.innerHeight;
269- windowX = 0;
270- windowY = 0;
271- } else if (wasMobile && !isMobile) {
272- // Switching from mobile to desktop - reload saved state
273- const savedState = loadWindowState();
274- const maxWidth = window.innerWidth - 40;
275- const maxHeight = window.innerHeight - 40;
276- windowWidth = Math.min(savedState.width, maxWidth);
277- windowHeight = Math.min(savedState.height, maxHeight);
278-
279- // Handle centering
280- if (savedState.x === -1) {
281- windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
282- } else {
283- windowX = Math.max(0, Math.min(savedState.x, window.innerWidth - windowWidth));
284- }
285- if (savedState.y === -1) {
286- windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
287- } else {
288- windowY = Math.max(0, Math.min(savedState.y, window.innerHeight - windowHeight));
289- }
290- } else if (!isMobile) {
291- // Desktop viewport changed (zoom in/out) - re-center if isCentered, otherwise keep in bounds
292- const maxWidth = window.innerWidth - 40;
293- const maxHeight = window.innerHeight - 40;
294- windowWidth = Math.min(windowWidth, maxWidth);
295- windowHeight = Math.min(windowHeight, maxHeight);
296-
297- if (isCentered) {
298- // Keep window centered
299- windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
300- windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
301- } else {
302- // Just keep window within bounds
303- windowX = Math.max(0, Math.min(windowX, window.innerWidth - windowWidth));
304- windowY = Math.max(0, Math.min(windowY, window.innerHeight - windowHeight));
305- }
306- }
307- };
308-
309- // Only run handleResize if on mobile (desktop already loaded from localStorage)
310- if (isMobile) {
311- handleResize();
312- }
234+ function startCooldownTimer(retryAfter: number) {
235+ cooldownEndTime = Date.now() + retryAfter;
236+ if (cooldownInterval) clearInterval(cooldownInterval);
237+ cooldownInterval = setInterval(updateCooldownProgress, 100);
238+ updateCooldownProgress();
239+ }
313240
314- // Add resize listener
315- window.addEventListener('resize', handleResize);
241+ // ============ HANDLERS ============
316242
317- // ---- New: Polling for public buddy list updates ----
318- if (!currentUser) {
319- const fetchPublicRoom = async () => {
320- const defaultRoomId = chatState.getDefaultChatRoomId();
321- try {
322- const response = await fetch(`/api/rooms/${defaultRoomId}?public=true`);
323- if (response.ok) {
324- const roomData = await response.json();
325- if (roomData.success && roomData.buddyList) {
326- // Update both chat state and local onlineUsers list
327- chatState.updateOnlineUsers(roomData.buddyList);
328- onlineUsers = roomData.buddyList;
329- console.debug('Public polling updated buddy list:', roomData.buddyList);
330- }
331- }
332- } catch (error) {
333- console.error('Error fetching public buddy list:', error);
334- }
335- };
243+ function handleClose() {
244+ showChatRoom = false;
245+ }
336246
337- // Initial fetch then poll every 30 seconds
338- fetchPublicRoom();
339- roomPollInterval = setInterval(fetchPublicRoom, 30000);
247+ function handleInputChange(e: Event) {
248+ const input = e.target as HTMLInputElement;
249+ if (input.value.length > MAX_MESSAGE_LENGTH) {
250+ input.value = input.value.slice(0, MAX_MESSAGE_LENGTH);
251+ currentMessage = input.value;
340252 }
341- // -------------------------------------------------------
342-
343- return () => {
344- window.removeEventListener('resize', handleResize);
345- if (cooldownInterval) clearInterval(cooldownInterval);
346- if (roomPollInterval) clearInterval(roomPollInterval);
347- };
348- });
349-
350- // Save window state to localStorage when it changes (desktop only)
351- $effect(() => {
352- if (!browser || isMobile) return;
353-
354- // Save current state (debounced)
355- debouncedSaveWindowState({
356- width: windowWidth,
357- height: windowHeight,
358- x: windowX,
359- y: windowY,
360- isMaximized,
361- isMinimized,
362- showUserList,
363- isCentered
364- });
365- });
253+ inputScrollLeft = input.scrollLeft;
254+ }
366255
367- // New reactive state to track when a message is being sent
368- let isSendingMessage = $state(false);
256+ function handleInputScroll(e: Event) {
257+ inputScrollLeft = (e.target as HTMLInputElement).scrollLeft;
258+ }
369259
370260 async function handleSubmit() {
371261 if (!currentMessage.trim() || !currentUser || cooldownEndTime) return;
372262
373263 isSendingMessage = true;
374264 try {
375- const response = await chatState.sendMessage(currentMessage, 'chat', currentTextStyle);
376- if (!response.success && response.isRateLimited && response.retryAfter) {
377- startCooldownTimer(response.retryAfter);
265+ const response = await fetch('/api/chat/messages', {
266+ method: 'POST',
267+ headers: { 'Content-Type': 'application/json' },
268+ credentials: 'include',
269+ body: JSON.stringify({
270+ content: currentMessage,
271+ type: 'chat',
272+ userId: currentUser.id,
273+ chatRoomId: roomId,
274+ styleData: currentTextStyle ? JSON.stringify(currentTextStyle) : undefined
275+ })
276+ });
277+
278+ const data = await response.json();
279+
280+ if (!data.success && data.isRateLimited && data.retryAfter) {
281+ startCooldownTimer(data.retryAfter);
378282 rateLimitWarning = "Whoa there! You're sending messages too quickly. Take a breather...";
379- } else if (!response.success) {
380- rateLimitWarning = response.error || 'Failed to send message. Please try again.';
283+ } else if (!data.success) {
284+ rateLimitWarning = data.error || 'Failed to send message. Please try again.';
381285 setTimeout(() => (rateLimitWarning = null), 3000);
382286 } else {
383287 currentMessage = '';
@@ -395,7 +299,6 @@
395299 function handleDragMove(event: CustomEvent<{ x: number; y: number }>) {
396300 windowX = event.detail.x;
397301 windowY = event.detail.y;
398- // User manually moved the window, no longer keep it centered on resize
399302 isCentered = false;
400303 }
401304
@@ -406,16 +309,12 @@
406309 function handleMaximize(event: MouseEvent) {
407310 const node = (event.currentTarget as HTMLElement).closest('.window') as MaximizableNode;
408311 if (node) {
409- // If minimized, restore first
410312 if (isMinimized) {
411313 const minimizableNode = node as unknown as MinimizableNode;
412314 if (minimizableNode?.toggleMinimize) {
413315 minimizableNode.toggleMinimize();
414316 }
415- // Wait for minimize animation to complete
416- setTimeout(() => {
417- node.toggleMaximize();
418- }, 300);
317+ setTimeout(() => node.toggleMaximize(), 300);
419318 } else {
420319 node.toggleMaximize();
421320 }
@@ -446,28 +345,19 @@
446345 const chatArea = event.target as HTMLElement;
447346 const { scrollTop } = chatArea;
448347
449- // Check if we've scrolled near the top (within 100px) and not already loading
450- if (scrollTop < 100 && !isLoadingMore && hasMoreMessages) {
348+ if (scrollTop < 100 && !isLoadingMore && hasMoreMessages && roomId) {
451349 isLoadingMore = true;
452-
453- // Store the scroll height and position before loading
454350 const scrollHeight = chatArea.scrollHeight;
455351 const currentScrollTop = chatArea.scrollTop;
456352
457- console.debug('Loading more messages...', {
458- oldestMessageTimestamp,
459- scrollHeight,
460- currentScrollTop
461- });
462-
463353 try {
464- const response = await fetch(
465- `/api/chat/messages?${new URLSearchParams({
466- before: oldestMessageTimestamp?.toString() || '',
467- roomId: currentRoomId,
468- ...(currentUser ? {} : { public: 'true' })
469- })}`
470- );
354+ const params = new URLSearchParams({
355+ before: oldestMessageTimestamp?.toString() || '',
356+ roomId: roomId
357+ });
358+ if (!currentUser) params.append('public', 'true');
359+
360+ const response = await fetch(`/api/chat/messages?${params}`);
471361
472362 if (response.ok) {
473363 const data = await response.json();
@@ -475,30 +365,16 @@
475365 if (data.messages.length === 0) {
476366 hasMoreMessages = false;
477367 } else {
478- // Update messages through chat state
479- chatState.prependMessages(data.messages, data.hasMore);
480-
481- // Use requestAnimationFrame to ensure DOM has updated
368+ // Messages will be updated via Convex subscription
369+ // Just update pagination state
482370 requestAnimationFrame(() => {
483- // Calculate how much the content height has increased
484371 const newScrollHeight = chatArea.scrollHeight;
485372 const heightDifference = newScrollHeight - scrollHeight;
486-
487- // Adjust scroll position to maintain relative position
488373 chatArea.scrollTop = currentScrollTop + heightDifference;
489-
490- console.debug('Scroll position adjusted:', {
491- heightDifference,
492- newScrollTop: chatArea.scrollTop,
493- newScrollHeight
494- });
495374 });
496375
497- // Update oldest timestamp
498376 const newOldest = Math.min(...data.messages.map((m: Message) => m.timestamp));
499377 oldestMessageTimestamp = newOldest;
500-
501- // Update hasMoreMessages from response
502378 hasMoreMessages = data.hasMore;
503379 }
504380 }
@@ -512,21 +388,6 @@
512388 }
513389 }
514390
515- // Add effect to track initial loading state
516- $effect(() => {
517- const stateMessages = chatState.getMessages();
518- if (stateMessages.length > 0 && isInitialLoading) {
519- isInitialLoading = false;
520- }
521- });
522-
523- // Add derived state for total users count
524- let totalUsers = $derived(onlineUsers.length);
525-
526- // Split users into online and offline groups
527- let usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline'));
528- let usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline'));
529-
530391 function openSignup() {
531392 showAuth = true;
532393 }
@@ -534,6 +395,105 @@
534395 function handleLoginSuccess() {
535396 showAuth = false;
536397 }
398+
399+ // ============ LIFECYCLE ============
400+
401+ onMount(() => {
402+ if (!browser) return;
403+
404+ isMobile = window.innerWidth <= 768;
405+
406+ if (!isMobile) {
407+ const savedState = loadWindowState();
408+ const maxWidth = window.innerWidth - 40;
409+ const maxHeight = window.innerHeight - 40;
410+
411+ windowWidth = Math.min(savedState.width, maxWidth);
412+ windowHeight = Math.min(savedState.height, maxHeight);
413+
414+ if (savedState.x === -1) {
415+ windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
416+ } else {
417+ windowX = Math.max(0, Math.min(savedState.x, window.innerWidth - windowWidth));
418+ }
419+ if (savedState.y === -1) {
420+ windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
421+ } else {
422+ windowY = Math.max(0, Math.min(savedState.y, window.innerHeight - windowHeight));
423+ }
424+
425+ isMaximized = savedState.isMaximized;
426+ isMinimized = savedState.isMinimized;
427+ showUserList = savedState.showUserList;
428+ isCentered = savedState.isCentered;
429+ }
430+
431+ const handleResize = () => {
432+ const wasMobile = isMobile;
433+ isMobile = window.innerWidth <= 768;
434+
435+ if (isMobile) {
436+ windowWidth = window.innerWidth;
437+ windowHeight = window.innerHeight;
438+ windowX = 0;
439+ windowY = 0;
440+ } else if (wasMobile && !isMobile) {
441+ const savedState = loadWindowState();
442+ const maxWidth = window.innerWidth - 40;
443+ const maxHeight = window.innerHeight - 40;
444+ windowWidth = Math.min(savedState.width, maxWidth);
445+ windowHeight = Math.min(savedState.height, maxHeight);
446+
447+ if (savedState.x === -1) {
448+ windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
449+ } else {
450+ windowX = Math.max(0, Math.min(savedState.x, window.innerWidth - windowWidth));
451+ }
452+ if (savedState.y === -1) {
453+ windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
454+ } else {
455+ windowY = Math.max(0, Math.min(savedState.y, window.innerHeight - windowHeight));
456+ }
457+ } else if (!isMobile) {
458+ const maxWidth = window.innerWidth - 40;
459+ const maxHeight = window.innerHeight - 40;
460+ windowWidth = Math.min(windowWidth, maxWidth);
461+ windowHeight = Math.min(windowHeight, maxHeight);
462+
463+ if (isCentered) {
464+ windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
465+ windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
466+ } else {
467+ windowX = Math.max(0, Math.min(windowX, window.innerWidth - windowWidth));
468+ windowY = Math.max(0, Math.min(windowY, window.innerHeight - windowHeight));
469+ }
470+ }
471+ };
472+
473+ if (isMobile) handleResize();
474+
475+ window.addEventListener('resize', handleResize);
476+
477+ return () => {
478+ window.removeEventListener('resize', handleResize);
479+ if (cooldownInterval) clearInterval(cooldownInterval);
480+ };
481+ });
482+
483+ // Save window state when it changes
484+ $effect(() => {
485+ if (!browser || isMobile) return;
486+ debouncedSaveWindowState({
487+ width: windowWidth,
488+ height: windowHeight,
489+ x: windowX,
490+ y: windowY,
491+ isMaximized,
492+ isMinimized,
493+ showUserList,
494+ isCentered
495+ });
496+ });
537497 </script>
538498
539499 {#if showChatRoom}
@@ -567,8 +527,8 @@
567527 Pdr Aim {#if currentUser}
568528 - {currentUser.nickname}{:else}
569529 - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if}
570- {#if sseError && !isMinimized}
571- <span class="connection-error">⚠️ Erreur de connexion</span>
530+ {#if connectionError && !isMinimized}
531+ <span class="connection-error">⚠️ {connectionError}</span>
572532 {/if}
573533 </div>
574534 <div class="title-bar-controls">
@@ -612,13 +572,9 @@
612572 class="window-body"
613573 style="display: flex; height: calc(100% - 2rem); margin: 0; padding: 0.5rem;"
614574 >
615- {#if sseError}
575+ {#if connectionError}
616576 <div class="error-banner">
617- {sseError}
618- {#if sseRetryAfter}
619- <br />
620- <small>Nouvelle tentative dans {Math.ceil(sseRetryAfter)}s...</small>
621- {/if}
577+ {connectionError}
622578 </div>
623579 {/if}
624580
@@ -896,7 +852,6 @@
896852 line-height: 1.4;
897853 }
898854
899- /* Fix gradient text alignment in messages */
900855 .message :global(.gradient-text-static) {
901856 display: inline;
902857 vertical-align: baseline;
@@ -923,7 +878,6 @@
923878 vertical-align: baseline;
924879 }
925880
926- /* Ensure formatted message content aligns properly with nickname */
927881 .message-content :global(.formatted-message),
928882 .message-content :global(.gradient-text-static) {
929883 display: inline;
@@ -975,15 +929,6 @@
975929 cursor: help;
976930 }
977931
978- /* .user .status-message {
979- font-size: 0.875rem;
980- color: #666;
981- font-style: italic;
982- overflow: hidden;
983- text-overflow: ellipsis;
984- white-space: nowrap;
985- } */
986-
987932 .sunken-panel {
988933 background: white;
989934 border: 0.125rem inset #dfdfdf;
@@ -994,9 +939,7 @@
994939 margin-right: 0.25rem;
995940 }
996941
997- /* Styled input - font is controlled by retro-font-* classes */
998942 .styled-input {
999- /* Font family is set by retro-font-* classes, don't override here */
1000943 font-style: inherit;
1001944 }
1002945
@@ -1132,7 +1075,6 @@
11321075 position: relative;
11331076 }
11341077
1135- /* Character counter styles */
11361078 .char-counter {
11371079 font-size: 0.75rem;
11381080 padding: 0 0.25rem;
@@ -1,5 +1,8 @@
1 <script lang="ts">1 <script lang="ts">
2 import { chatState } from '../states/chat.svelte';2 import { chatState } from '../states/chat.svelte';
3+ import { useQuery } from 'convex-svelte';
4+ import { api } from '../../convex/_generated/api';
5+ import type { Id } from '../../convex/_generated/dataModel';
3 import type { Message, EnrichedMessage, SafeUser } from '../types/chat';6 import type { Message, EnrichedMessage, SafeUser } from '../types/chat';
4 import { onMount } from 'svelte';7 import { onMount } from 'svelte';
5 import { browser } from '$app/environment';8 import { browser } from '$app/environment';
@@ -22,117 +25,156 @@
22 import { loadWindowState, saveWindowState, debounce } from '$lib/utils/chat-window-state';25 import { loadWindowState, saveWindowState, debounce } from '$lib/utils/chat-window-state';
23 import { MAX_MESSAGE_LENGTH } from '$lib/validation/message';26 import { MAX_MESSAGE_LENGTH } from '$lib/validation/message';
24 27
25- // Props destructuring must come first28+ // Props
26 let { showChatRoom = $bindable(), initialTextStyle = DEFAULT_TEXT_STYLE } = $props();29 let { showChatRoom = $bindable(), initialTextStyle = DEFAULT_TEXT_STYLE } = $props();
27 30
28- // Initialize with default values for SSR (800x600 - 16:10 aspect ratio)31+ // ============ CONVEX REAL-TIME SUBSCRIPTIONS ============
32+
33+ // Subscribe to default room (to get room ID)
34+ const defaultRoomQuery = useQuery(api.queries.getDefaultRoomPublic, {});
35+
36+ // Subscribe to users (buddy list) - real-time updates
37+ const usersQuery = useQuery(api.queries.getUsersPublic, {});
38+
39+ // Room ID from Convex query
40+ const roomId = $derived(defaultRoomQuery.data?.id as Id<'chatRooms'> | undefined);
41+
42+ // Subscribe to messages - only when room ID is available
43+ const messagesQuery = $derived(
44+ roomId ? useQuery(api.queries.getMessagesPublic, () => ({ roomId: roomId!, limit: 100 })) : null
45+ );
46+
47+ // Transform Convex users to SafeUser format
48+ const onlineUsers = $derived.by<SafeUser[]>(() => {
49+ if (!usersQuery.data) return [];
50+ return usersQuery.data
51+ .map((u) => ({
52+ id: u.id,
53+ nickname: u.nickname,
54+ status: u.status as SafeUser['status'],
55+ avatarUrl: u.avatarUrl ?? null,
56+ lastSeen: u.lastSeen ?? null
57+ }))
58+ .sort((a, b) => {
59+ const statusOrder: Record<string, number> = { online: 0, away: 1, busy: 2, offline: 3 };
60+ return (statusOrder[a.status] ?? 3) - (statusOrder[b.status] ?? 3);
61+ });
62+ });
63+
64+ // Transform Convex messages to EnrichedMessage format
65+ const messages = $derived.by<EnrichedMessage[]>(() => {
66+ if (!messagesQuery?.data) return [];
67+ return messagesQuery.data.map((msg) => ({
68+ id: msg.id,
69+ chatRoomId: msg.chatRoomId,
70+ senderId: msg.senderId,
71+ content: msg.content,
72+ type: msg.type as Message['type'],
73+ timestamp: msg.timestamp,
74+ styleData: msg.styleData,
75+ hasFormatting: msg.hasFormatting ?? false,
76+ user: msg.sender
77+ ? {
78+ id: msg.sender.id,
79+ nickname: msg.sender.nickname,
80+ status: msg.sender.status as SafeUser['status'],
81+ avatarUrl: msg.sender.avatarUrl ?? null,
82+ lastSeen: null
83+ }
84+ : {
85+ id: msg.senderId,
86+ nickname: 'Unknown User',
87+ status: 'offline' as const,
88+ avatarUrl: null,
89+ lastSeen: null
90+ }
91+ }));
92+ });
93+
94+ // Loading and error states from Convex
95+ const isInitialLoading = $derived(
96+ defaultRoomQuery.isLoading || usersQuery.isLoading || messagesQuery?.isLoading
97+ );
98+ const connectionError = $derived.by<string | null>(() => {
99+ if (defaultRoomQuery.error) return 'Erreur de connexion au salon';
100+ if (usersQuery.error) return 'Erreur de chargement des utilisateurs';
101+ if (messagesQuery?.error) return 'Erreur de chargement des messages';
102+ return null;
103+ });
104+
105+ // Update chatState with room ID when available
106+ $effect(() => {
107+ if (roomId) {
108+ chatState.setCurrentRoomId(roomId);
109+ chatState.setConnectionStatus('connected');
110+ }
111+ });
112+
113+ // ============ USER STATE ============
114+
115+ let currentUser = $state<SafeUser | null>(null);
116+
117+ // Sync current user from chatState
118+ $effect(() => {
119+ currentUser = chatState.getCurrentUser();
120+ });
121+
122+ // ============ WINDOW STATE ============
123+
29 let windowWidth = $state(800);124 let windowWidth = $state(800);
30 let windowHeight = $state(600);125 let windowHeight = $state(600);
31 let windowX = $state(0);126 let windowX = $state(0);
32 let windowY = $state(0);127 let windowY = $state(0);
33 let isMobile = $state(false);128 let isMobile = $state(false);
34- let currentMessage = $state('');
35 let showUserList = $state(false);129 let showUserList = $state(false);
36 let isMaximized = $state(false);130 let isMaximized = $state(false);
37- let isInitialLoading = $state(true);
38- let showAuth = $state(false);
39 let isMinimized = $state(false);131 let isMinimized = $state(false);
40- let isCentered = $state(true); // true = window stays centered on resize, false = user has manually positioned132+ let isCentered = $state(true);
41- let inputScrollLeft = $state(0); // Track input scroll position for gradient overlay sync
42 133
43- // Handle input changes - truncate and sync scroll134+ // ============ INPUT STATE ============
44- function handleInputChange(e: Event) {
45- const input = e.target as HTMLInputElement;
46- // Truncate if over limit (handles paste)
47- if (input.value.length > MAX_MESSAGE_LENGTH) {
48- input.value = input.value.slice(0, MAX_MESSAGE_LENGTH);
49- currentMessage = input.value;
50- }
51- // Sync scroll position for gradient overlay
52- inputScrollLeft = input.scrollLeft;
53- }
54-
55- function handleInputScroll(e: Event) {
56- const input = e.target as HTMLInputElement;
57- inputScrollLeft = input.scrollLeft;
58- }
59 135
60- // Text formatting state - initialize directly with merged initial style136+ let currentMessage = $state('');
137+ let inputScrollLeft = $state(0);
61 let currentTextStyle = $state<TextStyle>({138 let currentTextStyle = $state<TextStyle>({
62 ...DEFAULT_TEXT_STYLE,139 ...DEFAULT_TEXT_STYLE,
63 ...initialTextStyle,140 ...initialTextStyle,
64 color: initialTextStyle.color || '#000000'141 color: initialTextStyle.color || '#000000'
65 });142 });
66 143
67- // Rate limiting state144+ // ============ RATE LIMITING STATE ============
145+
68 let cooldownEndTime = $state<number | null>(null);146 let cooldownEndTime = $state<number | null>(null);
69 let cooldownProgress = $state(0);147 let cooldownProgress = $state(0);
70 let cooldownInterval: ReturnType<typeof setInterval> | null = null;148 let cooldownInterval: ReturnType<typeof setInterval> | null = null;
71 let rateLimitWarning = $state<string | null>(null);149 let rateLimitWarning = $state<string | null>(null);
150+ let isSendingMessage = $state(false);
72 151
73- // At the top along with other variable declarations, add:152+ // ============ AUTH STATE ============
74- let roomPollInterval: ReturnType<typeof setInterval> | null = null;
75-
76- // Debounced save function for window state persistence
77- const debouncedSaveWindowState = debounce(saveWindowState, 300);
78-
79- function updateCooldownProgress() {
80- if (!cooldownEndTime) return;
81-
82- const now = Date.now();
83- if (now >= cooldownEndTime) {
84- cooldownEndTime = null;
85- cooldownProgress = 0;
86- rateLimitWarning = null;
87- if (cooldownInterval) {
88- clearInterval(cooldownInterval);
89- cooldownInterval = null;
90- }
91- return;
92- }
93-
94- cooldownProgress = (cooldownEndTime - now) / 1000;
95- }
96 153
97- function startCooldownTimer(retryAfter: number) {154+ let showAuth = $state(false);
98- cooldownEndTime = Date.now() + retryAfter;
99- if (cooldownInterval) clearInterval(cooldownInterval);
100- cooldownInterval = setInterval(updateCooldownProgress, 100);
101- updateCooldownProgress();
102- }
103 155
104- function handleClose() {156+ // ============ SCROLL STATE ============
105- showChatRoom = false;
106- }
107 157
108- // Reactive state using derived values
109- let messages = $state<EnrichedMessage[]>([]);
110- let onlineUsers = $state<SafeUser[]>([]);
111- let currentUser = $state<SafeUser | null>(null);
112 let isLoadingMore = $state(false);158 let isLoadingMore = $state(false);
113 let hasMoreMessages = $state(true);159 let hasMoreMessages = $state(true);
114 let oldestMessageTimestamp = $state<number | null>(null);160 let oldestMessageTimestamp = $state<number | null>(null);
115- let currentRoomId = $state('');161+ let lastMessageCount = $state(0);
116-162+
117- // Compute visible messages based on login status163+ // ============ DERIVED STATE ============
118- let visibleMessages = $derived(164+
119- (() => {165+ // Visible messages (limit for non-logged-in users)
120- const isLoggedIn = Boolean(currentUser);166+ const visibleMessages = $derived.by<EnrichedMessage[]>(() => {
121- const messageCount = messages.length;167+ const isLoggedIn = Boolean(currentUser);
122- const result: EnrichedMessage[] = isLoggedIn168+ return isLoggedIn ? messages : messages.slice(Math.max(0, messages.length - 50));
123- ? messages169+ });
124- : messages.slice(Math.max(0, messageCount - 50));
125- return result;
126- })()
127- );
128 170
129- // Add a derived state to check if we're showing the registration prompt171+ // Show registration prompt for non-logged-in users
130- let showRegistrationPrompt = $derived(!currentUser && messages.length > 50);172+ const showRegistrationPrompt = $derived(!currentUser && messages.length > 50);
131 173
132- // Character counter - show when approaching limit (80%+)174+ // Character counter
133- const CHAR_WARNING_THRESHOLD = Math.floor(MAX_MESSAGE_LENGTH * 0.8); // 400 chars175+ const CHAR_WARNING_THRESHOLD = Math.floor(MAX_MESSAGE_LENGTH * 0.8);
134- let showCharCounter = $derived(currentMessage.length >= CHAR_WARNING_THRESHOLD);176+ const showCharCounter = $derived(currentMessage.length >= CHAR_WARNING_THRESHOLD);
135- let charCounterClass = $derived(177+ const charCounterClass = $derived(
136 currentMessage.length >= MAX_MESSAGE_LENGTH178 currentMessage.length >= MAX_MESSAGE_LENGTH
137 ? 'at-limit'179 ? 'at-limit'
138 : currentMessage.length >= MAX_MESSAGE_LENGTH * 0.95180 : currentMessage.length >= MAX_MESSAGE_LENGTH * 0.95
@@ -140,244 +182,106 @@
140 : 'warning'182 : 'warning'
141 );183 );
142 184
143- // Add SSE error state185+ // User counts
144- let sseError = $state<string | null>(null);186+ const totalUsers = $derived(onlineUsers.length);
145- let sseRetryAfter = $state<number | null>(null);187+ const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline'));
146-188+ const usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline'));
147- // Update the effect to check for SSE errors
148- $effect(() => {
149- const user = chatState.getCurrentUser();
150- if (user?.id !== currentUser?.id) {
151- currentUser = user;
152- hasMoreMessages = true; // Reset when user changes
153- }
154-
155- // Check for SSE errors
156- const { error, retryAfter } = chatState.getSSEError();
157- sseError = error;
158- sseRetryAfter = retryAfter;
159 189
160- // Update messages if there's any difference190+ // ============ EFFECTS ============
161- const stateMessages = chatState.getMessages();
162- if (JSON.stringify(stateMessages) !== JSON.stringify(messages)) {
163- messages = stateMessages;
164 191
165- // Update oldest message timestamp192+ // Auto-scroll to bottom when new messages arrive
166- if (messages.length > 0) {193+ $effect(() => {
167- oldestMessageTimestamp = Math.min(...messages.map((m) => m.timestamp));194+ if (messages.length > lastMessageCount && messages.length > 0) {
168- }195+ lastMessageCount = messages.length;
169-196+ if (browser) {
170- // Scroll to bottom when new messages arrive197+ const chatArea = document.querySelector('.chat-area');
171- const chatArea = document.querySelector('.chat-area');198+ if (chatArea) {
172- if (chatArea) {199+ setTimeout(() => {
173- setTimeout(() => {200+ chatArea.scrollTop = chatArea.scrollHeight;
174- chatArea.scrollTop = chatArea.scrollHeight;201+ }, 0);
175- }, 0);202+ }
176 }203 }
177 }204 }
178-
179- // Update online users
180- const stateOnlineUsers = chatState.getOnlineUsers();
181- if (JSON.stringify(stateOnlineUsers) !== JSON.stringify(onlineUsers)) {
182- onlineUsers = stateOnlineUsers;
183- }
184 });205 });
185 206
186- // Fetch public messages only once when needed207+ // Update oldest timestamp for pagination
187- let fetchedPublicMessages = false;
188- let fetchedPublicRoom = false;
189-
190 $effect(() => {208 $effect(() => {
191- if (!currentUser) {209+ if (messages.length > 0) {
192- // Fetch both public room data and messages in parallel but handle them in sequence210+ oldestMessageTimestamp = Math.min(...messages.map((m) => m.timestamp));
193- if (!fetchedPublicRoom && !fetchedPublicMessages) {
194- fetchedPublicRoom = true;
195- fetchedPublicMessages = true;
196-
197- // Get the default chat room id
198- const defaultRoomId = chatState.getDefaultChatRoomId();
199-
200- // Fetch both in parallel
201- Promise.all([
202- fetch(`/api/rooms/${defaultRoomId}?public=true`).then((r) => r.json()),
203- fetch('/api/chat/messages?public=true').then((r) => r.json())
204- ])
205- .then(([roomData, messagesData]) => {
206- if (roomData.success) {
207- // Update both the chat state and local state with the buddy list
208- chatState.updateOnlineUsers(roomData.buddyList);
209- onlineUsers = roomData.buddyList;
210- console.debug('Updated public buddy list:', roomData.buddyList);
211- }
212-
213- // Then update messages after buddy list is cached
214- if (messagesData.success && messagesData.messages) {
215- chatState.updateMessages(messagesData.messages);
216- messages = chatState.enrichMessages(messagesData.messages);
217- console.debug('Updated public messages:', messages);
218- }
219- })
220- .catch((error) => {
221- console.error('Error fetching public data:', error);
222- onlineUsers = [];
223- messages = [];
224- });
225- }
226 }211 }
227 });212 });
228 213
229- onMount(() => {214+ // ============ HELPERS ============
230- if (!browser) return;
231-
232- // Detect mobile first
233- isMobile = window.innerWidth <= 768;
234-
235- // Load saved state from localStorage (only for desktop)
236- if (!isMobile) {
237- const savedState = loadWindowState();
238- // Only apply saved position/size if they're within current viewport bounds
239- const maxWidth = window.innerWidth - 40;
240- const maxHeight = window.innerHeight - 40;
241 215
242- windowWidth = Math.min(savedState.width, maxWidth);216+ const debouncedSaveWindowState = debounce(saveWindowState, 300);
243- windowHeight = Math.min(savedState.height, maxHeight);
244 217
245- // Handle centering: if x or y is -1, center the window218+ function updateCooldownProgress() {
246- if (savedState.x === -1) {219+ if (!cooldownEndTime) return;
247- windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);220+ const now = Date.now();
248- } else {221+ if (now >= cooldownEndTime) {
249- windowX = Math.max(0, Math.min(savedState.x, window.innerWidth - windowWidth));222+ cooldownEndTime = null;
250- }223+ cooldownProgress = 0;
251- if (savedState.y === -1) {224+ rateLimitWarning = null;
252- windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);225+ if (cooldownInterval) {
253- } else {226+ clearInterval(cooldownInterval);
254- windowY = Math.max(0, Math.min(savedState.y, window.innerHeight - windowHeight));227+ cooldownInterval = null;
255 }228 }
256-229+ return;
257- isMaximized = savedState.isMaximized;
258- isMinimized = savedState.isMinimized;
259- showUserList = savedState.showUserList;
260- isCentered = savedState.isCentered;
261 }230 }
231+ cooldownProgress = (cooldownEndTime - now) / 1000;
232+ }
262 233
263- const handleResize = () => {234+ function startCooldownTimer(retryAfter: number) {
264- const wasMobile = isMobile;235+ cooldownEndTime = Date.now() + retryAfter;
265- isMobile = window.innerWidth <= 768;236+ if (cooldownInterval) clearInterval(cooldownInterval);
266- if (isMobile) {237+ cooldownInterval = setInterval(updateCooldownProgress, 100);
267- windowWidth = window.innerWidth;238+ updateCooldownProgress();
268- windowHeight = window.innerHeight;239+ }
269- windowX = 0;
270- windowY = 0;
271- } else if (wasMobile && !isMobile) {
272- // Switching from mobile to desktop - reload saved state
273- const savedState = loadWindowState();
274- const maxWidth = window.innerWidth - 40;
275- const maxHeight = window.innerHeight - 40;
276- windowWidth = Math.min(savedState.width, maxWidth);
277- windowHeight = Math.min(savedState.height, maxHeight);
278-
279- // Handle centering
280- if (savedState.x === -1) {
281- windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
282- } else {
283- windowX = Math.max(0, Math.min(savedState.x, window.innerWidth - windowWidth));
284- }
285- if (savedState.y === -1) {
286- windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
287- } else {
288- windowY = Math.max(0, Math.min(savedState.y, window.innerHeight - windowHeight));
289- }
290- } else if (!isMobile) {
291- // Desktop viewport changed (zoom in/out) - re-center if isCentered, otherwise keep in bounds
292- const maxWidth = window.innerWidth - 40;
293- const maxHeight = window.innerHeight - 40;
294- windowWidth = Math.min(windowWidth, maxWidth);
295- windowHeight = Math.min(windowHeight, maxHeight);
296-
297- if (isCentered) {
298- // Keep window centered
299- windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
300- windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
301- } else {
302- // Just keep window within bounds
303- windowX = Math.max(0, Math.min(windowX, window.innerWidth - windowWidth));
304- windowY = Math.max(0, Math.min(windowY, window.innerHeight - windowHeight));
305- }
306- }
307- };
308-
309- // Only run handleResize if on mobile (desktop already loaded from localStorage)
310- if (isMobile) {
311- handleResize();
312- }
313 240
314- // Add resize listener241+ // ============ HANDLERS ============
315- window.addEventListener('resize', handleResize);
316 242
317- // ---- New: Polling for public buddy list updates ----243+ function handleClose() {
318- if (!currentUser) {244+ showChatRoom = false;
319- const fetchPublicRoom = async () => {245+ }
320- const defaultRoomId = chatState.getDefaultChatRoomId();
321- try {
322- const response = await fetch(`/api/rooms/${defaultRoomId}?public=true`);
323- if (response.ok) {
324- const roomData = await response.json();
325- if (roomData.success && roomData.buddyList) {
326- // Update both chat state and local onlineUsers list
327- chatState.updateOnlineUsers(roomData.buddyList);
328- onlineUsers = roomData.buddyList;
329- console.debug('Public polling updated buddy list:', roomData.buddyList);
330- }
331- }
332- } catch (error) {
333- console.error('Error fetching public buddy list:', error);
334- }
335- };
336 246
337- // Initial fetch then poll every 30 seconds247+ function handleInputChange(e: Event) {
338- fetchPublicRoom();248+ const input = e.target as HTMLInputElement;
339- roomPollInterval = setInterval(fetchPublicRoom, 30000);249+ if (input.value.length > MAX_MESSAGE_LENGTH) {
250+ input.value = input.value.slice(0, MAX_MESSAGE_LENGTH);
251+ currentMessage = input.value;
340 }252 }
341- // -------------------------------------------------------253+ inputScrollLeft = input.scrollLeft;
342-254+ }
343- return () => {
344- window.removeEventListener('resize', handleResize);
345- if (cooldownInterval) clearInterval(cooldownInterval);
346- if (roomPollInterval) clearInterval(roomPollInterval);
347- };
348- });
349-
350- // Save window state to localStorage when it changes (desktop only)
351- $effect(() => {
352- if (!browser || isMobile) return;
353-
354- // Save current state (debounced)
355- debouncedSaveWindowState({
356- width: windowWidth,
357- height: windowHeight,
358- x: windowX,
359- y: windowY,
360- isMaximized,
361- isMinimized,
362- showUserList,
363- isCentered
364- });
365- });
366 255
367- // New reactive state to track when a message is being sent256+ function handleInputScroll(e: Event) {
368- let isSendingMessage = $state(false);257+ inputScrollLeft = (e.target as HTMLInputElement).scrollLeft;
258+ }
369 259
370 async function handleSubmit() {260 async function handleSubmit() {
371 if (!currentMessage.trim() || !currentUser || cooldownEndTime) return;261 if (!currentMessage.trim() || !currentUser || cooldownEndTime) return;
372 262
373 isSendingMessage = true;263 isSendingMessage = true;
374 try {264 try {
375- const response = await chatState.sendMessage(currentMessage, 'chat', currentTextStyle);265+ const response = await fetch('/api/chat/messages', {
376- if (!response.success && response.isRateLimited && response.retryAfter) {266+ method: 'POST',
377- startCooldownTimer(response.retryAfter);267+ headers: { 'Content-Type': 'application/json' },
268+ credentials: 'include',
269+ body: JSON.stringify({
270+ content: currentMessage,
271+ type: 'chat',
272+ userId: currentUser.id,
273+ chatRoomId: roomId,
274+ styleData: currentTextStyle ? JSON.stringify(currentTextStyle) : undefined
275+ })
276+ });
277+
278+ const data = await response.json();
279+
280+ if (!data.success && data.isRateLimited && data.retryAfter) {
281+ startCooldownTimer(data.retryAfter);
378 rateLimitWarning = "Whoa there! You're sending messages too quickly. Take a breather...";282 rateLimitWarning = "Whoa there! You're sending messages too quickly. Take a breather...";
379- } else if (!response.success) {283+ } else if (!data.success) {
380- rateLimitWarning = response.error || 'Failed to send message. Please try again.';284+ rateLimitWarning = data.error || 'Failed to send message. Please try again.';
381 setTimeout(() => (rateLimitWarning = null), 3000);285 setTimeout(() => (rateLimitWarning = null), 3000);
382 } else {286 } else {
383 currentMessage = '';287 currentMessage = '';
@@ -395,7 +299,6 @@
395 function handleDragMove(event: CustomEvent<{ x: number; y: number }>) {299 function handleDragMove(event: CustomEvent<{ x: number; y: number }>) {
396 windowX = event.detail.x;300 windowX = event.detail.x;
397 windowY = event.detail.y;301 windowY = event.detail.y;
398- // User manually moved the window, no longer keep it centered on resize
399 isCentered = false;302 isCentered = false;
400 }303 }
401 304
@@ -406,16 +309,12 @@
406 function handleMaximize(event: MouseEvent) {309 function handleMaximize(event: MouseEvent) {
407 const node = (event.currentTarget as HTMLElement).closest('.window') as MaximizableNode;310 const node = (event.currentTarget as HTMLElement).closest('.window') as MaximizableNode;
408 if (node) {311 if (node) {
409- // If minimized, restore first
410 if (isMinimized) {312 if (isMinimized) {
411 const minimizableNode = node as unknown as MinimizableNode;313 const minimizableNode = node as unknown as MinimizableNode;
412 if (minimizableNode?.toggleMinimize) {314 if (minimizableNode?.toggleMinimize) {
413 minimizableNode.toggleMinimize();315 minimizableNode.toggleMinimize();
414 }316 }
415- // Wait for minimize animation to complete317+ setTimeout(() => node.toggleMaximize(), 300);
416- setTimeout(() => {
417- node.toggleMaximize();
418- }, 300);
419 } else {318 } else {
420 node.toggleMaximize();319 node.toggleMaximize();
421 }320 }
@@ -446,28 +345,19 @@
446 const chatArea = event.target as HTMLElement;345 const chatArea = event.target as HTMLElement;
447 const { scrollTop } = chatArea;346 const { scrollTop } = chatArea;
448 347
449- // Check if we've scrolled near the top (within 100px) and not already loading348+ if (scrollTop < 100 && !isLoadingMore && hasMoreMessages && roomId) {
450- if (scrollTop < 100 && !isLoadingMore && hasMoreMessages) {
451 isLoadingMore = true;349 isLoadingMore = true;
452-
453- // Store the scroll height and position before loading
454 const scrollHeight = chatArea.scrollHeight;350 const scrollHeight = chatArea.scrollHeight;
455 const currentScrollTop = chatArea.scrollTop;351 const currentScrollTop = chatArea.scrollTop;
456 352
457- console.debug('Loading more messages...', {
458- oldestMessageTimestamp,
459- scrollHeight,
460- currentScrollTop
461- });
462-
463 try {353 try {
464- const response = await fetch(354+ const params = new URLSearchParams({
465- `/api/chat/messages?${new URLSearchParams({355+ before: oldestMessageTimestamp?.toString() || '',
466- before: oldestMessageTimestamp?.toString() || '',356+ roomId: roomId
467- roomId: currentRoomId,357+ });
468- ...(currentUser ? {} : { public: 'true' })358+ if (!currentUser) params.append('public', 'true');
469- })}`359+
470- );360+ const response = await fetch(`/api/chat/messages?${params}`);
471 361
472 if (response.ok) {362 if (response.ok) {
473 const data = await response.json();363 const data = await response.json();
@@ -475,30 +365,16 @@
475 if (data.messages.length === 0) {365 if (data.messages.length === 0) {
476 hasMoreMessages = false;366 hasMoreMessages = false;
477 } else {367 } else {
478- // Update messages through chat state368+ // Messages will be updated via Convex subscription
479- chatState.prependMessages(data.messages, data.hasMore);369+ // Just update pagination state
480-
481- // Use requestAnimationFrame to ensure DOM has updated
482 requestAnimationFrame(() => {370 requestAnimationFrame(() => {
483- // Calculate how much the content height has increased
484 const newScrollHeight = chatArea.scrollHeight;371 const newScrollHeight = chatArea.scrollHeight;
485 const heightDifference = newScrollHeight - scrollHeight;372 const heightDifference = newScrollHeight - scrollHeight;
486-
487- // Adjust scroll position to maintain relative position
488 chatArea.scrollTop = currentScrollTop + heightDifference;373 chatArea.scrollTop = currentScrollTop + heightDifference;
489-
490- console.debug('Scroll position adjusted:', {
491- heightDifference,
492- newScrollTop: chatArea.scrollTop,
493- newScrollHeight
494- });
495 });374 });
496 375
497- // Update oldest timestamp
498 const newOldest = Math.min(...data.messages.map((m: Message) => m.timestamp));376 const newOldest = Math.min(...data.messages.map((m: Message) => m.timestamp));
499 oldestMessageTimestamp = newOldest;377 oldestMessageTimestamp = newOldest;
500-
501- // Update hasMoreMessages from response
502 hasMoreMessages = data.hasMore;378 hasMoreMessages = data.hasMore;
503 }379 }
504 }380 }
@@ -512,21 +388,6 @@
512 }388 }
513 }389 }
514 390
515- // Add effect to track initial loading state
516- $effect(() => {
517- const stateMessages = chatState.getMessages();
518- if (stateMessages.length > 0 && isInitialLoading) {
519- isInitialLoading = false;
520- }
521- });
522-
523- // Add derived state for total users count
524- let totalUsers = $derived(onlineUsers.length);
525-
526- // Split users into online and offline groups
527- let usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline'));
528- let usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline'));
529-
530 function openSignup() {391 function openSignup() {
531 showAuth = true;392 showAuth = true;
532 }393 }
@@ -534,6 +395,105 @@
534 function handleLoginSuccess() {395 function handleLoginSuccess() {
535 showAuth = false;396 showAuth = false;
536 }397 }
398+
399+ // ============ LIFECYCLE ============
400+
401+ onMount(() => {
402+ if (!browser) return;
403+
404+ isMobile = window.innerWidth <= 768;
405+
406+ if (!isMobile) {
407+ const savedState = loadWindowState();
408+ const maxWidth = window.innerWidth - 40;
409+ const maxHeight = window.innerHeight - 40;
410+
411+ windowWidth = Math.min(savedState.width, maxWidth);
412+ windowHeight = Math.min(savedState.height, maxHeight);
413+
414+ if (savedState.x === -1) {
415+ windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
416+ } else {
417+ windowX = Math.max(0, Math.min(savedState.x, window.innerWidth - windowWidth));
418+ }
419+ if (savedState.y === -1) {
420+ windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
421+ } else {
422+ windowY = Math.max(0, Math.min(savedState.y, window.innerHeight - windowHeight));
423+ }
424+
425+ isMaximized = savedState.isMaximized;
426+ isMinimized = savedState.isMinimized;
427+ showUserList = savedState.showUserList;
428+ isCentered = savedState.isCentered;
429+ }
430+
431+ const handleResize = () => {
432+ const wasMobile = isMobile;
433+ isMobile = window.innerWidth <= 768;
434+
435+ if (isMobile) {
436+ windowWidth = window.innerWidth;
437+ windowHeight = window.innerHeight;
438+ windowX = 0;
439+ windowY = 0;
440+ } else if (wasMobile && !isMobile) {
441+ const savedState = loadWindowState();
442+ const maxWidth = window.innerWidth - 40;
443+ const maxHeight = window.innerHeight - 40;
444+ windowWidth = Math.min(savedState.width, maxWidth);
445+ windowHeight = Math.min(savedState.height, maxHeight);
446+
447+ if (savedState.x === -1) {
448+ windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
449+ } else {
450+ windowX = Math.max(0, Math.min(savedState.x, window.innerWidth - windowWidth));
451+ }
452+ if (savedState.y === -1) {
453+ windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
454+ } else {
455+ windowY = Math.max(0, Math.min(savedState.y, window.innerHeight - windowHeight));
456+ }
457+ } else if (!isMobile) {
458+ const maxWidth = window.innerWidth - 40;
459+ const maxHeight = window.innerHeight - 40;
460+ windowWidth = Math.min(windowWidth, maxWidth);
461+ windowHeight = Math.min(windowHeight, maxHeight);
462+
463+ if (isCentered) {
464+ windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
465+ windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
466+ } else {
467+ windowX = Math.max(0, Math.min(windowX, window.innerWidth - windowWidth));
468+ windowY = Math.max(0, Math.min(windowY, window.innerHeight - windowHeight));
469+ }
470+ }
471+ };
472+
473+ if (isMobile) handleResize();
474+
475+ window.addEventListener('resize', handleResize);
476+
477+ return () => {
478+ window.removeEventListener('resize', handleResize);
479+ if (cooldownInterval) clearInterval(cooldownInterval);
480+ };
481+ });
482+
483+ // Save window state when it changes
484+ $effect(() => {
485+ if (!browser || isMobile) return;
486+ debouncedSaveWindowState({
487+ width: windowWidth,
488+ height: windowHeight,
489+ x: windowX,
490+ y: windowY,
491+ isMaximized,
492+ isMinimized,
493+ showUserList,
494+ isCentered
495+ });
496+ });
537 </script>497 </script>
538 498
539 {#if showChatRoom}499 {#if showChatRoom}
@@ -567,8 +527,8 @@
567 Pdr Aim {#if currentUser}527 Pdr Aim {#if currentUser}
568 - {currentUser.nickname}{:else}528 - {currentUser.nickname}{:else}
569 - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if}529 - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if}
570- {#if sseError && !isMinimized}530+ {#if connectionError && !isMinimized}
571- <span class="connection-error">⚠️ Erreur de connexion</span>531+ <span class="connection-error">⚠️ {connectionError}</span>
572 {/if}532 {/if}
573 </div>533 </div>
574 <div class="title-bar-controls">534 <div class="title-bar-controls">
@@ -612,13 +572,9 @@
612 class="window-body"572 class="window-body"
613 style="display: flex; height: calc(100% - 2rem); margin: 0; padding: 0.5rem;"573 style="display: flex; height: calc(100% - 2rem); margin: 0; padding: 0.5rem;"
614 >574 >
615- {#if sseError}575+ {#if connectionError}
616 <div class="error-banner">576 <div class="error-banner">
617- {sseError}577+ {connectionError}
618- {#if sseRetryAfter}
619- <br />
620- <small>Nouvelle tentative dans {Math.ceil(sseRetryAfter)}s...</small>
621- {/if}
622 </div>578 </div>
623 {/if}579 {/if}
624 580
@@ -896,7 +852,6 @@
896 line-height: 1.4;852 line-height: 1.4;
897 }853 }
898 854
899- /* Fix gradient text alignment in messages */
900 .message :global(.gradient-text-static) {855 .message :global(.gradient-text-static) {
901 display: inline;856 display: inline;
902 vertical-align: baseline;857 vertical-align: baseline;
@@ -923,7 +878,6 @@
923 vertical-align: baseline;878 vertical-align: baseline;
924 }879 }
925 880
926- /* Ensure formatted message content aligns properly with nickname */
927 .message-content :global(.formatted-message),881 .message-content :global(.formatted-message),
928 .message-content :global(.gradient-text-static) {882 .message-content :global(.gradient-text-static) {
929 display: inline;883 display: inline;
@@ -975,15 +929,6 @@
975 cursor: help;929 cursor: help;
976 }930 }
977 931
978- /* .user .status-message {
979- font-size: 0.875rem;
980- color: #666;
981- font-style: italic;
982- overflow: hidden;
983- text-overflow: ellipsis;
984- white-space: nowrap;
985- } */
986-
987 .sunken-panel {932 .sunken-panel {
988 background: white;933 background: white;
989 border: 0.125rem inset #dfdfdf;934 border: 0.125rem inset #dfdfdf;
@@ -994,9 +939,7 @@
994 margin-right: 0.25rem;939 margin-right: 0.25rem;
995 }940 }
996 941
997- /* Styled input - font is controlled by retro-font-* classes */
998 .styled-input {942 .styled-input {
999- /* Font family is set by retro-font-* classes, don't override here */
1000 font-style: inherit;943 font-style: inherit;
1001 }944 }
1002 945
@@ -1132,7 +1075,6 @@
1132 position: relative;1075 position: relative;
1133 }1076 }
1134 1077
1135- /* Character counter styles */
1136 .char-counter {1078 .char-counter {
1137 font-size: 0.75rem;1079 font-size: 0.75rem;
1138 padding: 0 0.25rem;1080 padding: 0 0.25rem;
M src/lib/states/chat.svelte.ts +25 -689
@@ -1,730 +1,66 @@
11 /**
22 * Chat State Management
33 *
4- * Central state manager for chat functionality using Svelte 5 runes.
5- * Handles SSE connections, messages, and user state.
4+ * Minimal state manager for user session.
5+ * Real-time data (messages, users) comes from Convex subscriptions in components.
66 */
77
8-import type { User, Message, EnrichedMessage, SafeUser } from '../types/chat';
8+import type { User, SafeUser } from '../types/chat';
99 import { createSafeUser } from '../types/chat';
10-import type {
11- SendMessageRequest,
12- SendMessageResponse,
13- GetMessagesResponse
14-} from '../types/payloads';
15-import type { TextStyle } from '../types/text-formatting';
16-import { invalidate } from '$app/navigation';
17-import { env } from '$env/dynamic/public';
18-import { SSEClient, type SSEClientConfig } from '../sse/SSEClient.svelte';
19-import type { SSEEventMap } from '../sse/SSEEventEmitter';
20-import type { ConnectionStatus } from '../sse/config';
21-
22-// Use the public environment variable with a fallback
23-const DEFAULT_CHAT_ROOM_ID =
24- env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001';
2510
2611 class ChatState {
27- // User and message state
28- private users = $state<SafeUser[]>([]);
29- private messages = $state<Message[]>([]);
30- private currentUser = $state<SafeUser | null>(null);
31- private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID);
32- private userCache = $state<Record<string, SafeUser>>({});
33- private hasMoreMessages = $state(false);
34-
35- // SSE Client
36- private sseClient: SSEClient | null = null;
37-
38- // Connection state
39- private _connectionStatus = $state<ConnectionStatus>('disconnected');
40- private _sseError = $state<string | null>(null);
41-
42- // Internal state flags
43- private isInitializing = $state(false);
44- private isSettingUser = $state(false);
45-
46- // Memoization for enriched messages
47- private _memoizedEnrichedMessages: EnrichedMessage[] | null = null;
48- private _lastEnrichmentKey: string = '';
49-
50- // Buddy list tracking
51- private lastBuddyListHash = '';
12+ // Current user state (from session)
13+ private _currentUser = $state<SafeUser | null>(null);
14+ private _currentRoomId = $state<string | null>(null);
5215
53- // Public polling for unauthenticated users
54- private publicPollingInterval: ReturnType<typeof setInterval> | null = null;
16+ // Connection state for UI feedback
17+ private _connectionStatus = $state<'connected' | 'disconnected' | 'reconnecting'>('disconnected');
5518
56- // Derived state
19+ // Derived public state
5720 public connectionStatus = $derived(this._connectionStatus);
58- public sseError = $derived(this._sseError);
5921 public isConnected = $derived(this._connectionStatus === 'connected');
60- public isReconnecting = $derived(this._connectionStatus === 'reconnecting');
61-
62- /**
63- * Reinitialize the chat state (e.g., after login)
64- */
65- public async reinitialize() {
66- if (this.isInitializing) {
67- console.debug('Reinitialize already in progress, skipping.');
68- return;
69- }
70- this.isInitializing = true;
71- console.debug('Reinitializing chat state...');
72-
73- // Disconnect existing SSE client
74- if (this.sseClient) {
75- this.sseClient.disconnect();
76- this.sseClient = null;
77- }
78-
79- this.messages = [];
80- this.currentRoomId = DEFAULT_CHAT_ROOM_ID;
81-
82- if (this.currentUser) {
83- console.debug('Waiting for session cookie update before setting up SSE...');
84- await new Promise((resolve) => setTimeout(resolve, 1000));
85-
86- // Set up SSE connection
87- this.setupSSE();
88-
89- // Initialize messages and room users
90- await Promise.all([this.initializeMessages(), this.initializeRoomUsers()]);
91- }
92-
93- this.isInitializing = false;
94- }
95-
96- /**
97- * Get SSE error information
98- */
99- getSSEError() {
100- return {
101- error: this._sseError,
102- status: this._connectionStatus,
103- retryAfter: this.sseClient?.getReconnectDelay() ?? null
104- };
105- }
10622
10723 /**
10824 * Get the current user
10925 */
11026 getCurrentUser() {
111- return this.currentUser;
112- }
113-
114- /**
115- * Set the current user
116- */
117- async setCurrentUser(user: User | SafeUser | null) {
118- console.debug('setCurrentUser called with', user ? { ...user, password: '[REDACTED]' } : null);
119-
120- if (this.isSettingUser) {
121- console.debug('Already setting user, skipping');
122- return;
123- }
124-
125- this.isSettingUser = true;
126-
127- try {
128- if (!user && !this.currentUser) {
129- console.debug('No user to set and no current user, skipping');
130- return;
131- }
132-
133- const safeUser = user ? ('password' in user ? createSafeUser(user) : user) : null;
134-
135- // Check if same user with active connection
136- if (safeUser?.id === this.currentUser?.id) {
137- if (!this.sseClient || !this.sseClient.isConnected) {
138- console.debug('Same user being set but no active SSE connection, reinitializing.');
139- } else {
140- console.debug('Same user being set with active connection, skipping.');
141- return;
142- }
143- }
144-
145- this.messages = [];
146- this.currentUser = safeUser;
147-
148- if (safeUser) {
149- this.userCache[safeUser.id] = safeUser;
150- await this.reinitialize();
151- } else {
152- // Cleanup when user is removed
153- if (this.sseClient) {
154- this.sseClient.disconnect();
155- this.sseClient = null;
156- }
157- this.messages = [];
158- this.users = [];
159- this.userCache = {};
160- this._connectionStatus = 'disconnected';
161- }
162- } finally {
163- this.isSettingUser = false;
164- }
165- }
166-
167- /**
168- * Initialize room users
169- */
170- private async initializeRoomUsers() {
171- console.debug('Initializing room users for room:', this.currentRoomId);
172- try {
173- const isPublic = !this.currentUser;
174- const url = `/api/rooms/${this.currentRoomId}${isPublic ? '?public=true' : ''}`;
175- console.debug('Fetching room users from:', url);
176-
177- const response = await fetch(url, { credentials: 'include' });
178-
179- if (!response.ok) {
180- throw new Error('Failed to fetch room users');
181- }
182-
183- const data = await response.json();
184- if (!data.success) {
185- throw new Error(data.error);
186- }
187-
188- console.debug('Fetched room buddy list:', data.buddyList);
189-
190- // Merge fetched buddy list with existing cached users
191- // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary Map for merging
192- const mergedUsers = new Map<string, SafeUser>();
193- Object.values(this.userCache).forEach((user) => mergedUsers.set(user.id, user));
194- data.buddyList.forEach((user: SafeUser) => mergedUsers.set(user.id, user));
195-
196- this.users = Array.from(mergedUsers.values());
197- this.users.forEach((user) => {
198- this.userCache[user.id] = user;
199- });
200- } catch (error) {
201- console.debug('Error fetching room users:', error);
202- this.users = [];
203- }
27+ return this._currentUser;
20428 }
20529
20630 /**
207- * Get online users sorted by status
31+ * Set the current user (called from layout when session changes)
20832 */
209- getOnlineUsers() {
210- return [...this.users].sort((a: SafeUser, b: SafeUser) => {
211- const statusOrder: Record<SafeUser['status'], number> = {
212- online: 0,
213- away: 1,
214- busy: 2,
215- offline: 3
216- };
217- return statusOrder[a.status] - statusOrder[b.status];
218- });
219- }
220-
221- /**
222- * Get user by ID
223- */
224- getUserById(userId: string) {
225- if (this.userCache[userId]) {
226- return this.userCache[userId];
227- }
228-
229- const fallback: SafeUser = {
230- id: userId,
231- nickname: 'Unknown User',
232- status: 'offline',
233- lastSeen: null,
234- avatarUrl: null
235- };
236- this.userCache[userId] = fallback;
237- console.debug('getUserById: returning fallback for missing user', { userId, fallback });
238- return fallback;
239- }
240-
241- /**
242- * Update user status
243- */
244- updateUserStatus(userId: string, status: User['status'], lastSeen?: number) {
245- console.debug('Updating user status with:', {
246- userId,
247- status,
248- lastSeen,
249- currentCache: this.userCache[userId],
250- currentUsers: this.users.find((u) => u.id === userId)
251- });
252-
253- const existingUser = this.userCache[userId];
254- if (
255- existingUser &&
256- existingUser.status === status &&
257- lastSeen &&
258- Math.abs((existingUser.lastSeen ?? 0) - lastSeen) < 5000
259- ) {
260- console.debug('Skipping redundant status update for user:', userId);
261- return;
262- }
263-
264- let user: SafeUser | undefined = this.userCache[userId];
265-
33+ setCurrentUser(user: User | SafeUser | null) {
26634 if (!user) {
267- const foundUser = this.users.find((u: SafeUser) => u.id === userId);
268- if (foundUser) {
269- user = foundUser;
270- }
271- }
272-
273- const now = Date.now();
274- if (user) {
275- const updatedUser: SafeUser = {
276- ...user,
277- status,
278- lastSeen: lastSeen ?? (status === 'offline' ? now : user.lastSeen)
279- };
280-
281- this.userCache[userId] = updatedUser;
282-
283- const index = this.users.findIndex((u: SafeUser) => u.id === userId);
284- if (index !== -1) {
285- this.users = [...this.users.slice(0, index), updatedUser, ...this.users.slice(index + 1)];
286- } else {
287- this.users = [...this.users, updatedUser];
288- }
289-
290- console.debug('User status updated:', {
291- userId,
292- newStatus: status,
293- newLastSeen: new Date(updatedUser.lastSeen || now).toISOString()
294- });
295- } else {
296- console.debug('User not found in cache or list, fetching from API:', userId);
297- fetch(`/api/users/${userId}`, { credentials: 'include' })
298- .then((response) => response.json())
299- .then((data) => {
300- if (data.success && data.user) {
301- const newUser: SafeUser = {
302- ...data.user,
303- status,
304- lastSeen: lastSeen ?? (status === 'offline' ? now : data.user.lastSeen)
305- };
306- this.userCache[userId] = newUser;
307- this.users = [...this.users, newUser];
308-
309- console.debug('User fetched and status updated:', {
310- userId,
311- status: newUser.status
312- });
313- }
314- })
315- .catch((error) => console.error('Error fetching user data:', error));
316- }
317- }
318-
319- /**
320- * Get enriched messages with user data
321- */
322- getMessages() {
323- const key =
324- this.messages.map((m) => m.id).join(',') + '|' + Object.keys(this.userCache).sort().join(',');
325- if (key === this._lastEnrichmentKey && this._memoizedEnrichedMessages !== null) {
326- return this._memoizedEnrichedMessages;
327- }
328- this._lastEnrichmentKey = key;
329- console.debug('Enriching messages with user data', {
330- messagesCount: this.messages.length,
331- userCacheSize: Object.keys(this.userCache).length
332- });
333- this._memoizedEnrichedMessages = this.enrichMessages(this.messages);
334- return this._memoizedEnrichedMessages;
335- }
336-
337- /**
338- * Prepend messages (for loading older messages)
339- */
340- public prependMessages(newMessages: Message[], hasMore?: boolean) {
341- console.debug('Prepending messages:', { count: newMessages.length });
342-
343- const uniqueUserIds = new Set(newMessages.map((msg) => msg.senderId));
344- const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]);
345-
346- if (missingUserIds.length > 0) {
347- console.debug('Fetching missing user data for IDs:', missingUserIds);
348- Promise.all(
349- missingUserIds.map(async (userId) => {
350- try {
351- const userResponse = await fetch(`/api/users/${userId}`, {
352- credentials: 'include'
353- });
354- if (userResponse.ok) {
355- const userData = await userResponse.json();
356- if (userData.success && userData.user) {
357- this.userCache[userId] = userData.user;
358- if (!this.users.some((u) => u.id === userId)) {
359- this.users = [...this.users, userData.user];
360- }
361- }
362- }
363- } catch (error) {
364- console.debug('Error fetching user data:', { userId, error });
365- }
366- })
367- );
368- }
369-
370- const sortedNewMessages = newMessages.slice().sort((a, b) => a.timestamp - b.timestamp);
371- const uniqueNewMessages = sortedNewMessages.filter(
372- (msg) => !this.messages.some((m) => m.id === msg.id)
373- );
374- this.messages = [...uniqueNewMessages, ...this.messages];
375-
376- if (hasMore !== undefined) {
377- this.hasMoreMessages = hasMore;
378- }
379- }
380-
381- /**
382- * Update user cache
383- */
384- updateUserCache(users: (User | SafeUser)[]) {
385- console.debug(
386- 'Updating user cache with users:',
387- users.map((u) => ({ ...u, password: '[REDACTED]' }))
388- );
389- users.forEach((user) => {
390- const safeUser = 'password' in user ? createSafeUser(user) : user;
391- if (!this.userCache[user.id] || safeUser.status !== this.userCache[user.id].status) {
392- this.userCache[user.id] = safeUser;
393- const existingIndex = this.users.findIndex((u) => u.id === user.id);
394- if (existingIndex === -1) {
395- this.users = [...this.users, safeUser];
396- } else if (safeUser.status !== this.users[existingIndex].status) {
397- this.users = [
398- ...this.users.slice(0, existingIndex),
399- safeUser,
400- ...this.users.slice(existingIndex + 1)
401- ];
402- }
403- }
404- });
405- }
406-
407- /**
408- * Update online users (from buddy list updates)
409- */
410- updateOnlineUsers(users: (User | SafeUser)[]) {
411- const now = Date.now();
412- const safeUsers = users.map((user) => ('password' in user ? createSafeUser(user) : user));
413- const newHash = JSON.stringify(safeUsers);
414-
415- if (newHash !== this.lastBuddyListHash) {
416- const statusCounts = safeUsers.reduce(
417- (acc, user) => {
418- acc[user.status] = (acc[user.status] || 0) + 1;
419- return acc;
420- },
421- {} as Record<string, number>
422- );
423-
424- console.debug('Updating online users (data changed):', {
425- total: safeUsers.length,
426- byStatus: statusCounts,
427- timestamp: new Date(now).toISOString()
428- });
429-
430- this.users = safeUsers;
431- safeUsers.forEach((user) => {
432- this.userCache[user.id] = user;
433- });
434- this.lastBuddyListHash = newHash;
435- }
436- }
437-
438- /**
439- * Update messages (for public/unauthenticated access)
440- */
441- updateMessages(messages: Message[]) {
442- const uniqueMessages = Array.from(new Map(messages.map((msg) => [msg.id, msg])).values());
443- this.messages = uniqueMessages.sort((a, b) => a.timestamp - b.timestamp);
444- }
445-
446- /**
447- * Initialize messages from the server
448- */
449- async initializeMessages() {
450- try {
451- // eslint-disable-next-line svelte/prefer-svelte-reactivity -- URLSearchParams for URL building
452- const params = new URLSearchParams();
453- if (!this.currentUser) {
454- params.append('public', 'true');
455- }
456- params.append('roomId', this.currentRoomId);
457-
458- const response = await fetch(`/api/chat/messages?${params}`, {
459- credentials: 'include'
460- });
461- if (!response.ok) throw new Error('Failed to fetch messages');
462-
463- const data = (await response.json()) as GetMessagesResponse;
464- if (!data.success) {
465- throw new Error(data.error);
466- }
467-
468- const uniqueUserIds = new Set(data.messages.map((msg) => msg.senderId));
469- const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]);
470-
471- if (missingUserIds.length > 0) {
472- console.debug('Fetching missing user data for IDs:', missingUserIds);
473- await Promise.all(
474- missingUserIds.map(async (userId) => {
475- try {
476- const userResponse = await fetch(`/api/users/${userId}`, {
477- credentials: 'include'
478- });
479- if (userResponse.ok) {
480- const userData = await userResponse.json();
481- if (userData.success && userData.user) {
482- this.userCache[userId] = userData.user;
483- if (!this.users.some((u) => u.id === userId)) {
484- this.users = [...this.users, userData.user];
485- }
486- }
487- }
488- } catch (error) {
489- console.debug('Error fetching user data:', { userId, error });
490- }
491- })
492- );
493- }
494-
495- this.messages = data.messages.slice().sort((a, b) => a.timestamp - b.timestamp);
496-
497- if ('hasMore' in data) {
498- this.hasMoreMessages = data.hasMore;
499- }
500-
501- console.debug('Messages initialized:', {
502- count: data.messages.length,
503- isAuthenticated: !!this.currentUser,
504- hasMore: this.hasMoreMessages
505- });
506- } catch (error) {
507- console.debug('Error fetching initial messages:', error);
508- this.messages = [];
509- this.hasMoreMessages = false;
510- }
511- }
512-
513- /**
514- * Send a message
515- */
516- async sendMessage(
517- content: string,
518- type: Message['type'] = 'chat',
519- textStyle?: TextStyle
520- ): Promise<SendMessageResponse> {
521- const user = this.getCurrentUser();
522- if (!user) {
523- console.debug('Cannot send message: No current user');
524- return {
525- success: false,
526- error: 'Not logged in'
527- };
528- }
529-
530- try {
531- const payload: SendMessageRequest = {
532- content,
533- type,
534- userId: user.id,
535- chatRoomId: DEFAULT_CHAT_ROOM_ID,
536- styleData: textStyle ? JSON.stringify(textStyle) : undefined
537- };
538-
539- console.debug('Sending message with payload:', payload);
540-
541- const response = await fetch('/api/chat/messages', {
542- method: 'POST',
543- headers: {
544- 'Content-Type': 'application/json'
545- },
546- credentials: 'include',
547- body: JSON.stringify(payload)
548- });
549-
550- if (!response.ok) {
551- const errorData = await response.json();
552- throw new Error(errorData.error || 'Failed to save message');
553- }
554-
555- const data = (await response.json()) as SendMessageResponse;
556- if (!data.success) {
557- throw new Error(data.error);
558- }
559-
560- console.debug('Message sent successfully:', data.message);
561-
562- // Add message to state immediately for instant display (optimistic update)
563- // SSE deduplication will handle any duplicate when the broadcast arrives
564- if (data.message) {
565- this.messages = Array.from(
566- new Map([...this.messages, data.message].map((m) => [m.id, m])).values()
567- );
568- this.messages.sort((a, b) => a.timestamp - b.timestamp);
569- }
570-
571- await invalidate('chat:messages');
572- return data;
573- } catch (error) {
574- console.debug('Error sending message:', error);
575- return {
576- success: false,
577- error: 'Failed to send message'
578- };
579- }
580- }
581-
582- /**
583- * Set up SSE connection using the new SSEClient
584- */
585- private setupSSE() {
586- if (this.sseClient && this.sseClient.isConnected) {
587- console.debug('setupSSE: Active SSE connection exists, skipping setup.');
35+ this._currentUser = null;
36+ this._connectionStatus = 'disconnected';
58837 return;
58938 }
59039
591- if (!this.currentUser) {
592- console.debug('No current user, skipping SSE connection');
593- return;
594- }
595-
596- console.debug('Setting up SSE connection...');
597-
598- const config: SSEClientConfig = {
599- url: '/api/sse',
600- onMessage: this.handleSSEMessage.bind(this),
601- onStatusChange: this.handleConnectionStatusChange.bind(this),
602- onError: (error) => {
603- this._sseError = error;
604- console.debug('SSE error:', error);
605- }
606- };
607-
608- this.sseClient = new SSEClient(config);
609- this.sseClient.connect();
40+ const safeUser = 'password' in user ? createSafeUser(user) : user;
41+ this._currentUser = safeUser;
42+ this._connectionStatus = 'connected';
61043 }
61144
61245 /**
613- * Handle incoming SSE messages
46+ * Get the current room ID
61447 */
615- private handleSSEMessage<T extends keyof SSEEventMap>(type: T, data: SSEEventMap[T]) {
616- switch (type) {
617- case 'chatMessage':
618- this.handleChatMessage(data as SSEEventMap['chatMessage']);
619- break;
620- case 'buddyListUpdate':
621- this.updateOnlineUsers(data as SSEEventMap['buddyListUpdate']);
622- break;
623- case 'userStatusUpdate': {
624- const statusData = data as SSEEventMap['userStatusUpdate'];
625- this.updateUserStatus(statusData.userId, statusData.status, statusData.lastSeen);
626- break;
627- }
628- case 'heartbeat':
629- // Heartbeat is handled by SSEClient, no action needed here
630- break;
631- default:
632- console.debug('Unknown SSE event type:', type);
633- }
48+ getCurrentRoomId() {
49+ return this._currentRoomId;
63450 }
63551
63652 /**
637- * Handle chat message from SSE
53+ * Set the current room ID
63854 */
639- private async handleChatMessage(messageData: Message) {
640- console.debug('Received chat message via SSE:', messageData);
641-
642- // Deduplicate and update messages array
643- this.messages = Array.from(
644- new Map([...this.messages, messageData].map((m) => [m.id, m])).values()
645- );
646- this.messages.sort((a, b) => a.timestamp - b.timestamp);
647-
648- // Ensure the sender's data is available
649- await this.ensureUserData(messageData.senderId);
55+ setCurrentRoomId(roomId: string | null) {
56+ this._currentRoomId = roomId;
65057 }
65158
65259 /**
653- * Handle connection status changes
60+ * Set connection status
65461 */
655- private handleConnectionStatusChange(status: ConnectionStatus) {
62+ setConnectionStatus(status: 'connected' | 'disconnected' | 'reconnecting') {
65663 this._connectionStatus = status;
657- console.debug('SSE connection status changed:', status);
658-
659- if (status === 'connected') {
660- this._sseError = null;
661- }
662- }
663-
664- /**
665- * Ensure user data is loaded
666- */
667- private async ensureUserData(userId: string) {
668- if (this.userCache[userId]) return;
669-
670- console.debug('Fetching user data for:', userId);
671- try {
672- const response = await fetch(`/api/users/${userId}`, {
673- credentials: 'include'
674- });
675- if (response.ok) {
676- const userData = await response.json();
677- if (userData.success && userData.user) {
678- this.userCache[userId] = userData.user;
679- if (!this.users.some((u) => u.id === userId)) {
680- this.users = [...this.users, userData.user];
681- }
682- }
683- }
684- } catch (error) {
685- console.debug('Error fetching user data:', error);
686- }
687- }
688-
689- /**
690- * Enrich messages with user data
691- */
692- enrichMessages(messages: Message[]): EnrichedMessage[] {
693- console.debug('Enriching messages with user data', {
694- messagesCount: messages.length,
695- userCacheSize: Object.keys(this.userCache).length
696- });
697- return messages.map((message) => ({
698- ...message,
699- user: this.userCache[message.senderId] || {
700- id: message.senderId,
701- nickname: 'Unknown User',
702- status: 'offline',
703- avatarUrl: null,
704- lastSeen: null
705- }
706- }));
707- }
708-
709- /**
710- * Get the default chat room ID
711- */
712- getDefaultChatRoomId() {
713- return DEFAULT_CHAT_ROOM_ID;
714- }
715-
716- /**
717- * Disconnect SSE (for cleanup)
718- */
719- disconnect() {
720- if (this.sseClient) {
721- this.sseClient.disconnect();
722- this.sseClient = null;
723- }
724- if (this.publicPollingInterval) {
725- clearInterval(this.publicPollingInterval);
726- this.publicPollingInterval = null;
727- }
72864 }
72965 }
73066
@@ -1,730 +1,66 @@
1 /**1 /**
2 * Chat State Management2 * Chat State Management
3 *3 *
4- * Central state manager for chat functionality using Svelte 5 runes.4+ * Minimal state manager for user session.
5- * Handles SSE connections, messages, and user state.5+ * Real-time data (messages, users) comes from Convex subscriptions in components.
6 */6 */
7 7
8-import type { User, Message, EnrichedMessage, SafeUser } from '../types/chat';8+import type { User, SafeUser } from '../types/chat';
9 import { createSafeUser } from '../types/chat';9 import { createSafeUser } from '../types/chat';
10-import type {
11- SendMessageRequest,
12- SendMessageResponse,
13- GetMessagesResponse
14-} from '../types/payloads';
15-import type { TextStyle } from '../types/text-formatting';
16-import { invalidate } from '$app/navigation';
17-import { env } from '$env/dynamic/public';
18-import { SSEClient, type SSEClientConfig } from '../sse/SSEClient.svelte';
19-import type { SSEEventMap } from '../sse/SSEEventEmitter';
20-import type { ConnectionStatus } from '../sse/config';
21-
22-// Use the public environment variable with a fallback
23-const DEFAULT_CHAT_ROOM_ID =
24- env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001';
25 10
26 class ChatState {11 class ChatState {
27- // User and message state12+ // Current user state (from session)
28- private users = $state<SafeUser[]>([]);13+ private _currentUser = $state<SafeUser | null>(null);
29- private messages = $state<Message[]>([]);14+ private _currentRoomId = $state<string | null>(null);
30- private currentUser = $state<SafeUser | null>(null);
31- private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID);
32- private userCache = $state<Record<string, SafeUser>>({});
33- private hasMoreMessages = $state(false);
34-
35- // SSE Client
36- private sseClient: SSEClient | null = null;
37-
38- // Connection state
39- private _connectionStatus = $state<ConnectionStatus>('disconnected');
40- private _sseError = $state<string | null>(null);
41-
42- // Internal state flags
43- private isInitializing = $state(false);
44- private isSettingUser = $state(false);
45-
46- // Memoization for enriched messages
47- private _memoizedEnrichedMessages: EnrichedMessage[] | null = null;
48- private _lastEnrichmentKey: string = '';
49-
50- // Buddy list tracking
51- private lastBuddyListHash = '';
52 15
53- // Public polling for unauthenticated users16+ // Connection state for UI feedback
54- private publicPollingInterval: ReturnType<typeof setInterval> | null = null;17+ private _connectionStatus = $state<'connected' | 'disconnected' | 'reconnecting'>('disconnected');
55 18
56- // Derived state19+ // Derived public state
57 public connectionStatus = $derived(this._connectionStatus);20 public connectionStatus = $derived(this._connectionStatus);
58- public sseError = $derived(this._sseError);
59 public isConnected = $derived(this._connectionStatus === 'connected');21 public isConnected = $derived(this._connectionStatus === 'connected');
60- public isReconnecting = $derived(this._connectionStatus === 'reconnecting');
61-
62- /**
63- * Reinitialize the chat state (e.g., after login)
64- */
65- public async reinitialize() {
66- if (this.isInitializing) {
67- console.debug('Reinitialize already in progress, skipping.');
68- return;
69- }
70- this.isInitializing = true;
71- console.debug('Reinitializing chat state...');
72-
73- // Disconnect existing SSE client
74- if (this.sseClient) {
75- this.sseClient.disconnect();
76- this.sseClient = null;
77- }
78-
79- this.messages = [];
80- this.currentRoomId = DEFAULT_CHAT_ROOM_ID;
81-
82- if (this.currentUser) {
83- console.debug('Waiting for session cookie update before setting up SSE...');
84- await new Promise((resolve) => setTimeout(resolve, 1000));
85-
86- // Set up SSE connection
87- this.setupSSE();
88-
89- // Initialize messages and room users
90- await Promise.all([this.initializeMessages(), this.initializeRoomUsers()]);
91- }
92-
93- this.isInitializing = false;
94- }
95-
96- /**
97- * Get SSE error information
98- */
99- getSSEError() {
100- return {
101- error: this._sseError,
102- status: this._connectionStatus,
103- retryAfter: this.sseClient?.getReconnectDelay() ?? null
104- };
105- }
106 22
107 /**23 /**
108 * Get the current user24 * Get the current user
109 */25 */
110 getCurrentUser() {26 getCurrentUser() {
111- return this.currentUser;27+ return this._currentUser;
112- }
113-
114- /**
115- * Set the current user
116- */
117- async setCurrentUser(user: User | SafeUser | null) {
118- console.debug('setCurrentUser called with', user ? { ...user, password: '[REDACTED]' } : null);
119-
120- if (this.isSettingUser) {
121- console.debug('Already setting user, skipping');
122- return;
123- }
124-
125- this.isSettingUser = true;
126-
127- try {
128- if (!user && !this.currentUser) {
129- console.debug('No user to set and no current user, skipping');
130- return;
131- }
132-
133- const safeUser = user ? ('password' in user ? createSafeUser(user) : user) : null;
134-
135- // Check if same user with active connection
136- if (safeUser?.id === this.currentUser?.id) {
137- if (!this.sseClient || !this.sseClient.isConnected) {
138- console.debug('Same user being set but no active SSE connection, reinitializing.');
139- } else {
140- console.debug('Same user being set with active connection, skipping.');
141- return;
142- }
143- }
144-
145- this.messages = [];
146- this.currentUser = safeUser;
147-
148- if (safeUser) {
149- this.userCache[safeUser.id] = safeUser;
150- await this.reinitialize();
151- } else {
152- // Cleanup when user is removed
153- if (this.sseClient) {
154- this.sseClient.disconnect();
155- this.sseClient = null;
156- }
157- this.messages = [];
158- this.users = [];
159- this.userCache = {};
160- this._connectionStatus = 'disconnected';
161- }
162- } finally {
163- this.isSettingUser = false;
164- }
165- }
166-
167- /**
168- * Initialize room users
169- */
170- private async initializeRoomUsers() {
171- console.debug('Initializing room users for room:', this.currentRoomId);
172- try {
173- const isPublic = !this.currentUser;
174- const url = `/api/rooms/${this.currentRoomId}${isPublic ? '?public=true' : ''}`;
175- console.debug('Fetching room users from:', url);
176-
177- const response = await fetch(url, { credentials: 'include' });
178-
179- if (!response.ok) {
180- throw new Error('Failed to fetch room users');
181- }
182-
183- const data = await response.json();
184- if (!data.success) {
185- throw new Error(data.error);
186- }
187-
188- console.debug('Fetched room buddy list:', data.buddyList);
189-
190- // Merge fetched buddy list with existing cached users
191- // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary Map for merging
192- const mergedUsers = new Map<string, SafeUser>();
193- Object.values(this.userCache).forEach((user) => mergedUsers.set(user.id, user));
194- data.buddyList.forEach((user: SafeUser) => mergedUsers.set(user.id, user));
195-
196- this.users = Array.from(mergedUsers.values());
197- this.users.forEach((user) => {
198- this.userCache[user.id] = user;
199- });
200- } catch (error) {
201- console.debug('Error fetching room users:', error);
202- this.users = [];
203- }
204 }28 }
205 29
206 /**30 /**
207- * Get online users sorted by status31+ * Set the current user (called from layout when session changes)
208 */32 */
209- getOnlineUsers() {33+ setCurrentUser(user: User | SafeUser | null) {
210- return [...this.users].sort((a: SafeUser, b: SafeUser) => {
211- const statusOrder: Record<SafeUser['status'], number> = {
212- online: 0,
213- away: 1,
214- busy: 2,
215- offline: 3
216- };
217- return statusOrder[a.status] - statusOrder[b.status];
218- });
219- }
220-
221- /**
222- * Get user by ID
223- */
224- getUserById(userId: string) {
225- if (this.userCache[userId]) {
226- return this.userCache[userId];
227- }
228-
229- const fallback: SafeUser = {
230- id: userId,
231- nickname: 'Unknown User',
232- status: 'offline',
233- lastSeen: null,
234- avatarUrl: null
235- };
236- this.userCache[userId] = fallback;
237- console.debug('getUserById: returning fallback for missing user', { userId, fallback });
238- return fallback;
239- }
240-
241- /**
242- * Update user status
243- */
244- updateUserStatus(userId: string, status: User['status'], lastSeen?: number) {
245- console.debug('Updating user status with:', {
246- userId,
247- status,
248- lastSeen,
249- currentCache: this.userCache[userId],
250- currentUsers: this.users.find((u) => u.id === userId)
251- });
252-
253- const existingUser = this.userCache[userId];
254- if (
255- existingUser &&
256- existingUser.status === status &&
257- lastSeen &&
258- Math.abs((existingUser.lastSeen ?? 0) - lastSeen) < 5000
259- ) {
260- console.debug('Skipping redundant status update for user:', userId);
261- return;
262- }
263-
264- let user: SafeUser | undefined = this.userCache[userId];
265-
266 if (!user) {34 if (!user) {
267- const foundUser = this.users.find((u: SafeUser) => u.id === userId);35+ this._currentUser = null;
268- if (foundUser) {36+ this._connectionStatus = 'disconnected';
269- user = foundUser;
270- }
271- }
272-
273- const now = Date.now();
274- if (user) {
275- const updatedUser: SafeUser = {
276- ...user,
277- status,
278- lastSeen: lastSeen ?? (status === 'offline' ? now : user.lastSeen)
279- };
280-
281- this.userCache[userId] = updatedUser;
282-
283- const index = this.users.findIndex((u: SafeUser) => u.id === userId);
284- if (index !== -1) {
285- this.users = [...this.users.slice(0, index), updatedUser, ...this.users.slice(index + 1)];
286- } else {
287- this.users = [...this.users, updatedUser];
288- }
289-
290- console.debug('User status updated:', {
291- userId,
292- newStatus: status,
293- newLastSeen: new Date(updatedUser.lastSeen || now).toISOString()
294- });
295- } else {
296- console.debug('User not found in cache or list, fetching from API:', userId);
297- fetch(`/api/users/${userId}`, { credentials: 'include' })
298- .then((response) => response.json())
299- .then((data) => {
300- if (data.success && data.user) {
301- const newUser: SafeUser = {
302- ...data.user,
303- status,
304- lastSeen: lastSeen ?? (status === 'offline' ? now : data.user.lastSeen)
305- };
306- this.userCache[userId] = newUser;
307- this.users = [...this.users, newUser];
308-
309- console.debug('User fetched and status updated:', {
310- userId,
311- status: newUser.status
312- });
313- }
314- })
315- .catch((error) => console.error('Error fetching user data:', error));
316- }
317- }
318-
319- /**
320- * Get enriched messages with user data
321- */
322- getMessages() {
323- const key =
324- this.messages.map((m) => m.id).join(',') + '|' + Object.keys(this.userCache).sort().join(',');
325- if (key === this._lastEnrichmentKey && this._memoizedEnrichedMessages !== null) {
326- return this._memoizedEnrichedMessages;
327- }
328- this._lastEnrichmentKey = key;
329- console.debug('Enriching messages with user data', {
330- messagesCount: this.messages.length,
331- userCacheSize: Object.keys(this.userCache).length
332- });
333- this._memoizedEnrichedMessages = this.enrichMessages(this.messages);
334- return this._memoizedEnrichedMessages;
335- }
336-
337- /**
338- * Prepend messages (for loading older messages)
339- */
340- public prependMessages(newMessages: Message[], hasMore?: boolean) {
341- console.debug('Prepending messages:', { count: newMessages.length });
342-
343- const uniqueUserIds = new Set(newMessages.map((msg) => msg.senderId));
344- const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]);
345-
346- if (missingUserIds.length > 0) {
347- console.debug('Fetching missing user data for IDs:', missingUserIds);
348- Promise.all(
349- missingUserIds.map(async (userId) => {
350- try {
351- const userResponse = await fetch(`/api/users/${userId}`, {
352- credentials: 'include'
353- });
354- if (userResponse.ok) {
355- const userData = await userResponse.json();
356- if (userData.success && userData.user) {
357- this.userCache[userId] = userData.user;
358- if (!this.users.some((u) => u.id === userId)) {
359- this.users = [...this.users, userData.user];
360- }
361- }
362- }
363- } catch (error) {
364- console.debug('Error fetching user data:', { userId, error });
365- }
366- })
367- );
368- }
369-
370- const sortedNewMessages = newMessages.slice().sort((a, b) => a.timestamp - b.timestamp);
371- const uniqueNewMessages = sortedNewMessages.filter(
372- (msg) => !this.messages.some((m) => m.id === msg.id)
373- );
374- this.messages = [...uniqueNewMessages, ...this.messages];
375-
376- if (hasMore !== undefined) {
377- this.hasMoreMessages = hasMore;
378- }
379- }
380-
381- /**
382- * Update user cache
383- */
384- updateUserCache(users: (User | SafeUser)[]) {
385- console.debug(
386- 'Updating user cache with users:',
387- users.map((u) => ({ ...u, password: '[REDACTED]' }))
388- );
389- users.forEach((user) => {
390- const safeUser = 'password' in user ? createSafeUser(user) : user;
391- if (!this.userCache[user.id] || safeUser.status !== this.userCache[user.id].status) {
392- this.userCache[user.id] = safeUser;
393- const existingIndex = this.users.findIndex((u) => u.id === user.id);
394- if (existingIndex === -1) {
395- this.users = [...this.users, safeUser];
396- } else if (safeUser.status !== this.users[existingIndex].status) {
397- this.users = [
398- ...this.users.slice(0, existingIndex),
399- safeUser,
400- ...this.users.slice(existingIndex + 1)
401- ];
402- }
403- }
404- });
405- }
406-
407- /**
408- * Update online users (from buddy list updates)
409- */
410- updateOnlineUsers(users: (User | SafeUser)[]) {
411- const now = Date.now();
412- const safeUsers = users.map((user) => ('password' in user ? createSafeUser(user) : user));
413- const newHash = JSON.stringify(safeUsers);
414-
415- if (newHash !== this.lastBuddyListHash) {
416- const statusCounts = safeUsers.reduce(
417- (acc, user) => {
418- acc[user.status] = (acc[user.status] || 0) + 1;
419- return acc;
420- },
421- {} as Record<string, number>
422- );
423-
424- console.debug('Updating online users (data changed):', {
425- total: safeUsers.length,
426- byStatus: statusCounts,
427- timestamp: new Date(now).toISOString()
428- });
429-
430- this.users = safeUsers;
431- safeUsers.forEach((user) => {
432- this.userCache[user.id] = user;
433- });
434- this.lastBuddyListHash = newHash;
435- }
436- }
437-
438- /**
439- * Update messages (for public/unauthenticated access)
440- */
441- updateMessages(messages: Message[]) {
442- const uniqueMessages = Array.from(new Map(messages.map((msg) => [msg.id, msg])).values());
443- this.messages = uniqueMessages.sort((a, b) => a.timestamp - b.timestamp);
444- }
445-
446- /**
447- * Initialize messages from the server
448- */
449- async initializeMessages() {
450- try {
451- // eslint-disable-next-line svelte/prefer-svelte-reactivity -- URLSearchParams for URL building
452- const params = new URLSearchParams();
453- if (!this.currentUser) {
454- params.append('public', 'true');
455- }
456- params.append('roomId', this.currentRoomId);
457-
458- const response = await fetch(`/api/chat/messages?${params}`, {
459- credentials: 'include'
460- });
461- if (!response.ok) throw new Error('Failed to fetch messages');
462-
463- const data = (await response.json()) as GetMessagesResponse;
464- if (!data.success) {
465- throw new Error(data.error);
466- }
467-
468- const uniqueUserIds = new Set(data.messages.map((msg) => msg.senderId));
469- const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]);
470-
471- if (missingUserIds.length > 0) {
472- console.debug('Fetching missing user data for IDs:', missingUserIds);
473- await Promise.all(
474- missingUserIds.map(async (userId) => {
475- try {
476- const userResponse = await fetch(`/api/users/${userId}`, {
477- credentials: 'include'
478- });
479- if (userResponse.ok) {
480- const userData = await userResponse.json();
481- if (userData.success && userData.user) {
482- this.userCache[userId] = userData.user;
483- if (!this.users.some((u) => u.id === userId)) {
484- this.users = [...this.users, userData.user];
485- }
486- }
487- }
488- } catch (error) {
489- console.debug('Error fetching user data:', { userId, error });
490- }
491- })
492- );
493- }
494-
495- this.messages = data.messages.slice().sort((a, b) => a.timestamp - b.timestamp);
496-
497- if ('hasMore' in data) {
498- this.hasMoreMessages = data.hasMore;
499- }
500-
501- console.debug('Messages initialized:', {
502- count: data.messages.length,
503- isAuthenticated: !!this.currentUser,
504- hasMore: this.hasMoreMessages
505- });
506- } catch (error) {
507- console.debug('Error fetching initial messages:', error);
508- this.messages = [];
509- this.hasMoreMessages = false;
510- }
511- }
512-
513- /**
514- * Send a message
515- */
516- async sendMessage(
517- content: string,
518- type: Message['type'] = 'chat',
519- textStyle?: TextStyle
520- ): Promise<SendMessageResponse> {
521- const user = this.getCurrentUser();
522- if (!user) {
523- console.debug('Cannot send message: No current user');
524- return {
525- success: false,
526- error: 'Not logged in'
527- };
528- }
529-
530- try {
531- const payload: SendMessageRequest = {
532- content,
533- type,
534- userId: user.id,
535- chatRoomId: DEFAULT_CHAT_ROOM_ID,
536- styleData: textStyle ? JSON.stringify(textStyle) : undefined
537- };
538-
539- console.debug('Sending message with payload:', payload);
540-
541- const response = await fetch('/api/chat/messages', {
542- method: 'POST',
543- headers: {
544- 'Content-Type': 'application/json'
545- },
546- credentials: 'include',
547- body: JSON.stringify(payload)
548- });
549-
550- if (!response.ok) {
551- const errorData = await response.json();
552- throw new Error(errorData.error || 'Failed to save message');
553- }
554-
555- const data = (await response.json()) as SendMessageResponse;
556- if (!data.success) {
557- throw new Error(data.error);
558- }
559-
560- console.debug('Message sent successfully:', data.message);
561-
562- // Add message to state immediately for instant display (optimistic update)
563- // SSE deduplication will handle any duplicate when the broadcast arrives
564- if (data.message) {
565- this.messages = Array.from(
566- new Map([...this.messages, data.message].map((m) => [m.id, m])).values()
567- );
568- this.messages.sort((a, b) => a.timestamp - b.timestamp);
569- }
570-
571- await invalidate('chat:messages');
572- return data;
573- } catch (error) {
574- console.debug('Error sending message:', error);
575- return {
576- success: false,
577- error: 'Failed to send message'
578- };
579- }
580- }
581-
582- /**
583- * Set up SSE connection using the new SSEClient
584- */
585- private setupSSE() {
586- if (this.sseClient && this.sseClient.isConnected) {
587- console.debug('setupSSE: Active SSE connection exists, skipping setup.');
588 return;37 return;
589 }38 }
590 39
591- if (!this.currentUser) {40+ const safeUser = 'password' in user ? createSafeUser(user) : user;
592- console.debug('No current user, skipping SSE connection');41+ this._currentUser = safeUser;
593- return;42+ this._connectionStatus = 'connected';
594- }
595-
596- console.debug('Setting up SSE connection...');
597-
598- const config: SSEClientConfig = {
599- url: '/api/sse',
600- onMessage: this.handleSSEMessage.bind(this),
601- onStatusChange: this.handleConnectionStatusChange.bind(this),
602- onError: (error) => {
603- this._sseError = error;
604- console.debug('SSE error:', error);
605- }
606- };
607-
608- this.sseClient = new SSEClient(config);
609- this.sseClient.connect();
610 }43 }
611 44
612 /**45 /**
613- * Handle incoming SSE messages46+ * Get the current room ID
614 */47 */
615- private handleSSEMessage<T extends keyof SSEEventMap>(type: T, data: SSEEventMap[T]) {48+ getCurrentRoomId() {
616- switch (type) {49+ return this._currentRoomId;
617- case 'chatMessage':
618- this.handleChatMessage(data as SSEEventMap['chatMessage']);
619- break;
620- case 'buddyListUpdate':
621- this.updateOnlineUsers(data as SSEEventMap['buddyListUpdate']);
622- break;
623- case 'userStatusUpdate': {
624- const statusData = data as SSEEventMap['userStatusUpdate'];
625- this.updateUserStatus(statusData.userId, statusData.status, statusData.lastSeen);
626- break;
627- }
628- case 'heartbeat':
629- // Heartbeat is handled by SSEClient, no action needed here
630- break;
631- default:
632- console.debug('Unknown SSE event type:', type);
633- }
634 }50 }
635 51
636 /**52 /**
637- * Handle chat message from SSE53+ * Set the current room ID
638 */54 */
639- private async handleChatMessage(messageData: Message) {55+ setCurrentRoomId(roomId: string | null) {
640- console.debug('Received chat message via SSE:', messageData);56+ this._currentRoomId = roomId;
641-
642- // Deduplicate and update messages array
643- this.messages = Array.from(
644- new Map([...this.messages, messageData].map((m) => [m.id, m])).values()
645- );
646- this.messages.sort((a, b) => a.timestamp - b.timestamp);
647-
648- // Ensure the sender's data is available
649- await this.ensureUserData(messageData.senderId);
650 }57 }
651 58
652 /**59 /**
653- * Handle connection status changes60+ * Set connection status
654 */61 */
655- private handleConnectionStatusChange(status: ConnectionStatus) {62+ setConnectionStatus(status: 'connected' | 'disconnected' | 'reconnecting') {
656 this._connectionStatus = status;63 this._connectionStatus = status;
657- console.debug('SSE connection status changed:', status);
658-
659- if (status === 'connected') {
660- this._sseError = null;
661- }
662- }
663-
664- /**
665- * Ensure user data is loaded
666- */
667- private async ensureUserData(userId: string) {
668- if (this.userCache[userId]) return;
669-
670- console.debug('Fetching user data for:', userId);
671- try {
672- const response = await fetch(`/api/users/${userId}`, {
673- credentials: 'include'
674- });
675- if (response.ok) {
676- const userData = await response.json();
677- if (userData.success && userData.user) {
678- this.userCache[userId] = userData.user;
679- if (!this.users.some((u) => u.id === userId)) {
680- this.users = [...this.users, userData.user];
681- }
682- }
683- }
684- } catch (error) {
685- console.debug('Error fetching user data:', error);
686- }
687- }
688-
689- /**
690- * Enrich messages with user data
691- */
692- enrichMessages(messages: Message[]): EnrichedMessage[] {
693- console.debug('Enriching messages with user data', {
694- messagesCount: messages.length,
695- userCacheSize: Object.keys(this.userCache).length
696- });
697- return messages.map((message) => ({
698- ...message,
699- user: this.userCache[message.senderId] || {
700- id: message.senderId,
701- nickname: 'Unknown User',
702- status: 'offline',
703- avatarUrl: null,
704- lastSeen: null
705- }
706- }));
707- }
708-
709- /**
710- * Get the default chat room ID
711- */
712- getDefaultChatRoomId() {
713- return DEFAULT_CHAT_ROOM_ID;
714- }
715-
716- /**
717- * Disconnect SSE (for cleanup)
718- */
719- disconnect() {
720- if (this.sseClient) {
721- this.sseClient.disconnect();
722- this.sseClient = null;
723- }
724- if (this.publicPollingInterval) {
725- clearInterval(this.publicPollingInterval);
726- this.publicPollingInterval = null;
727- }
728 }64 }
729 }65 }
730 66
M src/routes/+layout.svelte +9 -5
@@ -1,9 +1,13 @@
11 <script lang="ts">
22 import '../app.css';
3+ import { setupConvex } from 'convex-svelte';
4+ import { PUBLIC_CONVEX_URL } from '$env/static/public';
35 import { chatState } from '$lib/states/chat.svelte';
46 import { onMount } from 'svelte';
57 import { browser } from '$app/environment';
6- import { SSE_CONFIG } from '$lib/sse/config';
8+
9+ // Initialize Convex client for real-time subscriptions
10+ setupConvex(PUBLIC_CONVEX_URL);
711
812 let { children, data } = $props();
913 let isVisible = $state(true);
@@ -12,14 +16,14 @@
1216 let updateInProgress = $state(false);
1317 let idleTimeout = $state<number | undefined>(undefined);
1418
15- // Timing constants from SSE config for consistency
19+ // Status update timing constants
1620 let lastSentStatus = $state<string | null>(null);
1721 let lastStatusSentAt = $state<number>(0);
1822 let lastUserActivity = $state<number>(Date.now());
19- const STATUS_UPDATE_INTERVAL = SSE_CONFIG.STATUS_UPDATE_INTERVAL; // 30 seconds
23+ const STATUS_UPDATE_INTERVAL = 30000; // 30 seconds
2024 const THROTTLE_TIME_MS = 10000; // 10 seconds
21- const FORCE_UPDATE_INTERVAL = SSE_CONFIG.CONNECTION_TIMEOUT; // 5 minutes
22- const IDLE_TIMEOUT = SSE_CONFIG.IDLE_TIMEOUT; // 2 minutes of inactivity = idle (aligned with server)
25+ const FORCE_UPDATE_INTERVAL = 5 * 60 * 1000; // 5 minutes
26+ const IDLE_TIMEOUT = 2 * 60 * 1000; // 2 minutes of inactivity = idle
2327
2428 type UserStatus = 'online' | 'offline' | 'busy' | 'idle';
2529
@@ -1,9 +1,13 @@
1 <script lang="ts">1 <script lang="ts">
2 import '../app.css';2 import '../app.css';
3+ import { setupConvex } from 'convex-svelte';
4+ import { PUBLIC_CONVEX_URL } from '$env/static/public';
3 import { chatState } from '$lib/states/chat.svelte';5 import { chatState } from '$lib/states/chat.svelte';
4 import { onMount } from 'svelte';6 import { onMount } from 'svelte';
5 import { browser } from '$app/environment';7 import { browser } from '$app/environment';
6- import { SSE_CONFIG } from '$lib/sse/config';8+
9+ // Initialize Convex client for real-time subscriptions
10+ setupConvex(PUBLIC_CONVEX_URL);
7 11
8 let { children, data } = $props();12 let { children, data } = $props();
9 let isVisible = $state(true);13 let isVisible = $state(true);
@@ -12,14 +16,14 @@
12 let updateInProgress = $state(false);16 let updateInProgress = $state(false);
13 let idleTimeout = $state<number | undefined>(undefined);17 let idleTimeout = $state<number | undefined>(undefined);
14 18
15- // Timing constants from SSE config for consistency19+ // Status update timing constants
16 let lastSentStatus = $state<string | null>(null);20 let lastSentStatus = $state<string | null>(null);
17 let lastStatusSentAt = $state<number>(0);21 let lastStatusSentAt = $state<number>(0);
18 let lastUserActivity = $state<number>(Date.now());22 let lastUserActivity = $state<number>(Date.now());
19- const STATUS_UPDATE_INTERVAL = SSE_CONFIG.STATUS_UPDATE_INTERVAL; // 30 seconds23+ const STATUS_UPDATE_INTERVAL = 30000; // 30 seconds
20 const THROTTLE_TIME_MS = 10000; // 10 seconds24 const THROTTLE_TIME_MS = 10000; // 10 seconds
21- const FORCE_UPDATE_INTERVAL = SSE_CONFIG.CONNECTION_TIMEOUT; // 5 minutes25+ const FORCE_UPDATE_INTERVAL = 5 * 60 * 1000; // 5 minutes
22- const IDLE_TIMEOUT = SSE_CONFIG.IDLE_TIMEOUT; // 2 minutes of inactivity = idle (aligned with server)26+ const IDLE_TIMEOUT = 2 * 60 * 1000; // 2 minutes of inactivity = idle
23 27
24 type UserStatus = 'online' | 'offline' | 'busy' | 'idle';28 type UserStatus = 'online' | 'offline' | 'busy' | 'idle';
25 29

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