refactor(frontend): use Convex real-time subscriptions instead of polling
- commit
- c624865f4d63d29336cf1eb181c0e3995cfe9532
- author
- bnema · · 2026-01-18 18:34 UTC
- parent
- 53f93b1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +343 -1061
M src/lib/components/chat-room.svelte +309 -367
| @@ -1,5 +1,8 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | 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 | 6 | import type { Message, EnrichedMessage, SafeUser } from '../types/chat'; | |
| 4 | 7 | import { onMount } from 'svelte'; | |
| 5 | 8 | import { browser } from '$app/environment'; | |
| @@ -22,117 +25,156 @@ | |||
| 22 | 25 | import { loadWindowState, saveWindowState, debounce } from '$lib/utils/chat-window-state'; | |
| 23 | 26 | import { MAX_MESSAGE_LENGTH } from '$lib/validation/message'; | |
| 24 | 27 | ||
| 25 | - | // Props destructuring must come first | |
| 28 | + | // Props | |
| 26 | 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 | 124 | let windowWidth = $state(800); | |
| 30 | 125 | let windowHeight = $state(600); | |
| 31 | 126 | let windowX = $state(0); | |
| 32 | 127 | let windowY = $state(0); | |
| 33 | 128 | let isMobile = $state(false); | |
| 34 | - | let currentMessage = $state(''); | |
| 35 | 129 | let showUserList = $state(false); | |
| 36 | 130 | let isMaximized = $state(false); | |
| 37 | - | let isInitialLoading = $state(true); | |
| 38 | - | let showAuth = $state(false); | |
| 39 | 131 | 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); | |
| 42 | 133 | ||
| 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 ============ | |
| 59 | 135 | ||
| 60 | - | // Text formatting state - initialize directly with merged initial style | |
| 136 | + | let currentMessage = $state(''); | |
| 137 | + | let inputScrollLeft = $state(0); | |
| 61 | 138 | let currentTextStyle = $state<TextStyle>({ | |
| 62 | 139 | ...DEFAULT_TEXT_STYLE, | |
| 63 | 140 | ...initialTextStyle, | |
| 64 | 141 | color: initialTextStyle.color || '#000000' | |
| 65 | 142 | }); | |
| 66 | 143 | ||
| 67 | - | // Rate limiting state | |
| 144 | + | // ============ RATE LIMITING STATE ============ | |
| 145 | + | ||
| 68 | 146 | let cooldownEndTime = $state<number | null>(null); | |
| 69 | 147 | let cooldownProgress = $state(0); | |
| 70 | 148 | let cooldownInterval: ReturnType<typeof setInterval> | null = null; | |
| 71 | 149 | let rateLimitWarning = $state<string | null>(null); | |
| 150 | + | let isSendingMessage = $state(false); | |
| 72 | 151 | ||
| 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 ============ | |
| 96 | 153 | ||
| 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); | |
| 103 | 155 | ||
| 104 | - | function handleClose() { | |
| 105 | - | showChatRoom = false; | |
| 106 | - | } | |
| 156 | + | // ============ SCROLL STATE ============ | |
| 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 | 158 | let isLoadingMore = $state(false); | |
| 113 | 159 | let hasMoreMessages = $state(true); | |
| 114 | 160 | 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 | + | }); | |
| 128 | 170 | ||
| 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); | |
| 131 | 173 | ||
| 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( | |
| 136 | 178 | currentMessage.length >= MAX_MESSAGE_LENGTH | |
| 137 | 179 | ? 'at-limit' | |
| 138 | 180 | : currentMessage.length >= MAX_MESSAGE_LENGTH * 0.95 | |
| @@ -140,244 +182,106 @@ | |||
| 140 | 182 | : 'warning' | |
| 141 | 183 | ); | |
| 142 | 184 | ||
| 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')); | |
| 159 | 189 | ||
| 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 ============ | |
| 164 | 191 | ||
| 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 | + | } | |
| 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 needed | |
| 187 | - | let fetchedPublicMessages = false; | |
| 188 | - | let fetchedPublicRoom = false; | |
| 189 | - | ||
| 207 | + | // Update oldest timestamp for pagination | |
| 190 | 208 | $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)); | |
| 226 | 211 | } | |
| 227 | 212 | }); | |
| 228 | 213 | ||
| 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 ============ | |
| 241 | 215 | ||
| 242 | - | windowWidth = Math.min(savedState.width, maxWidth); | |
| 243 | - | windowHeight = Math.min(savedState.height, maxHeight); | |
| 216 | + | const debouncedSaveWindowState = debounce(saveWindowState, 300); | |
| 244 | 217 | ||
| 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; | |
| 255 | 228 | } | |
| 256 | - | ||
| 257 | - | isMaximized = savedState.isMaximized; | |
| 258 | - | isMinimized = savedState.isMinimized; | |
| 259 | - | showUserList = savedState.showUserList; | |
| 260 | - | isCentered = savedState.isCentered; | |
| 229 | + | return; | |
| 261 | 230 | } | |
| 231 | + | cooldownProgress = (cooldownEndTime - now) / 1000; | |
| 232 | + | } | |
| 262 | 233 | ||
| 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 | + | } | |
| 313 | 240 | ||
| 314 | - | // Add resize listener | |
| 315 | - | window.addEventListener('resize', handleResize); | |
| 241 | + | // ============ HANDLERS ============ | |
| 316 | 242 | ||
| 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 | + | } | |
| 336 | 246 | ||
| 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; | |
| 340 | 252 | } | |
| 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 | + | } | |
| 366 | 255 | ||
| 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 | + | } | |
| 369 | 259 | ||
| 370 | 260 | async function handleSubmit() { | |
| 371 | 261 | if (!currentMessage.trim() || !currentUser || cooldownEndTime) return; | |
| 372 | 262 | ||
| 373 | 263 | isSendingMessage = true; | |
| 374 | 264 | 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); | |
| 378 | 282 | 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.'; | |
| 381 | 285 | setTimeout(() => (rateLimitWarning = null), 3000); | |
| 382 | 286 | } else { | |
| 383 | 287 | currentMessage = ''; | |
| @@ -395,7 +299,6 @@ | |||
| 395 | 299 | function handleDragMove(event: CustomEvent<{ x: number; y: number }>) { | |
| 396 | 300 | windowX = event.detail.x; | |
| 397 | 301 | windowY = event.detail.y; | |
| 398 | - | // User manually moved the window, no longer keep it centered on resize | |
| 399 | 302 | isCentered = false; | |
| 400 | 303 | } | |
| 401 | 304 | ||
| @@ -406,16 +309,12 @@ | |||
| 406 | 309 | function handleMaximize(event: MouseEvent) { | |
| 407 | 310 | const node = (event.currentTarget as HTMLElement).closest('.window') as MaximizableNode; | |
| 408 | 311 | if (node) { | |
| 409 | - | // If minimized, restore first | |
| 410 | 312 | if (isMinimized) { | |
| 411 | 313 | const minimizableNode = node as unknown as MinimizableNode; | |
| 412 | 314 | if (minimizableNode?.toggleMinimize) { | |
| 413 | 315 | minimizableNode.toggleMinimize(); | |
| 414 | 316 | } | |
| 415 | - | // Wait for minimize animation to complete | |
| 416 | - | setTimeout(() => { | |
| 417 | - | node.toggleMaximize(); | |
| 418 | - | }, 300); | |
| 317 | + | setTimeout(() => node.toggleMaximize(), 300); | |
| 419 | 318 | } else { | |
| 420 | 319 | node.toggleMaximize(); | |
| 421 | 320 | } | |
| @@ -446,28 +345,19 @@ | |||
| 446 | 345 | const chatArea = event.target as HTMLElement; | |
| 447 | 346 | const { scrollTop } = chatArea; | |
| 448 | 347 | ||
| 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) { | |
| 451 | 349 | isLoadingMore = true; | |
| 452 | - | ||
| 453 | - | // Store the scroll height and position before loading | |
| 454 | 350 | const scrollHeight = chatArea.scrollHeight; | |
| 455 | 351 | const currentScrollTop = chatArea.scrollTop; | |
| 456 | 352 | ||
| 457 | - | console.debug('Loading more messages...', { | |
| 458 | - | oldestMessageTimestamp, | |
| 459 | - | scrollHeight, | |
| 460 | - | currentScrollTop | |
| 461 | - | }); | |
| 462 | - | ||
| 463 | 353 | 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}`); | |
| 471 | 361 | ||
| 472 | 362 | if (response.ok) { | |
| 473 | 363 | const data = await response.json(); | |
| @@ -475,30 +365,16 @@ | |||
| 475 | 365 | if (data.messages.length === 0) { | |
| 476 | 366 | hasMoreMessages = false; | |
| 477 | 367 | } 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 | |
| 482 | 370 | requestAnimationFrame(() => { | |
| 483 | - | // Calculate how much the content height has increased | |
| 484 | 371 | const newScrollHeight = chatArea.scrollHeight; | |
| 485 | 372 | const heightDifference = newScrollHeight - scrollHeight; | |
| 486 | - | ||
| 487 | - | // Adjust scroll position to maintain relative position | |
| 488 | 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 | 376 | const newOldest = Math.min(...data.messages.map((m: Message) => m.timestamp)); | |
| 499 | 377 | oldestMessageTimestamp = newOldest; | |
| 500 | - | ||
| 501 | - | // Update hasMoreMessages from response | |
| 502 | 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 | 391 | function openSignup() { | |
| 531 | 392 | showAuth = true; | |
| 532 | 393 | } | |
| @@ -534,6 +395,105 @@ | |||
| 534 | 395 | function handleLoginSuccess() { | |
| 535 | 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 | 497 | </script> | |
| 538 | 498 | ||
| 539 | 499 | {#if showChatRoom} | |
| @@ -567,8 +527,8 @@ | |||
| 567 | 527 | Pdr Aim {#if currentUser} | |
| 568 | 528 | - {currentUser.nickname}{:else} | |
| 569 | 529 | - {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> | |
| 572 | 532 | {/if} | |
| 573 | 533 | </div> | |
| 574 | 534 | <div class="title-bar-controls"> | |
| @@ -612,13 +572,9 @@ | |||
| 612 | 572 | class="window-body" | |
| 613 | 573 | style="display: flex; height: calc(100% - 2rem); margin: 0; padding: 0.5rem;" | |
| 614 | 574 | > | |
| 615 | - | {#if sseError} | |
| 575 | + | {#if connectionError} | |
| 616 | 576 | <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} | |
| 622 | 578 | </div> | |
| 623 | 579 | {/if} | |
| 624 | 580 | ||
| @@ -896,7 +852,6 @@ | |||
| 896 | 852 | line-height: 1.4; | |
| 897 | 853 | } | |
| 898 | 854 | ||
| 899 | - | /* Fix gradient text alignment in messages */ | |
| 900 | 855 | .message :global(.gradient-text-static) { | |
| 901 | 856 | display: inline; | |
| 902 | 857 | vertical-align: baseline; | |
| @@ -923,7 +878,6 @@ | |||
| 923 | 878 | vertical-align: baseline; | |
| 924 | 879 | } | |
| 925 | 880 | ||
| 926 | - | /* Ensure formatted message content aligns properly with nickname */ | |
| 927 | 881 | .message-content :global(.formatted-message), | |
| 928 | 882 | .message-content :global(.gradient-text-static) { | |
| 929 | 883 | display: inline; | |
| @@ -975,15 +929,6 @@ | |||
| 975 | 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 | 932 | .sunken-panel { | |
| 988 | 933 | background: white; | |
| 989 | 934 | border: 0.125rem inset #dfdfdf; | |
| @@ -994,9 +939,7 @@ | |||
| 994 | 939 | margin-right: 0.25rem; | |
| 995 | 940 | } | |
| 996 | 941 | ||
| 997 | - | /* Styled input - font is controlled by retro-font-* classes */ | |
| 998 | 942 | .styled-input { | |
| 999 | - | /* Font family is set by retro-font-* classes, don't override here */ | |
| 1000 | 943 | font-style: inherit; | |
| 1001 | 944 | } | |
| 1002 | 945 | ||
| @@ -1132,7 +1075,6 @@ | |||
| 1132 | 1075 | position: relative; | |
| 1133 | 1076 | } | |
| 1134 | 1077 | ||
| 1135 | - | /* Character counter styles */ | |
| 1136 | 1078 | .char-counter { | |
| 1137 | 1079 | font-size: 0.75rem; | |
| 1138 | 1080 | 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 first | 28 | + | // 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 positioned | 132 | + | 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 scroll | 134 | + | // ============ 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 style | 136 | + | 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 state | 144 | + | // ============ 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 status | 163 | + | // ============ 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[] = isLoggedIn | 168 | + | return isLoggedIn ? messages : messages.slice(Math.max(0, messages.length - 50)); |
| 123 | - | ? messages | 169 | + | }); |
| 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 prompt | 171 | + | // 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 chars | 175 | + | 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_LENGTH | 178 | currentMessage.length >= MAX_MESSAGE_LENGTH | ||
| 137 | ? 'at-limit' | 179 | ? 'at-limit' | ||
| 138 | : currentMessage.length >= MAX_MESSAGE_LENGTH * 0.95 | 180 | : currentMessage.length >= MAX_MESSAGE_LENGTH * 0.95 | ||
| @@ -140,244 +182,106 @@ | |||||
| 140 | : 'warning' | 182 | : 'warning' | ||
| 141 | ); | 183 | ); | ||
| 142 | 184 | ||||
| 143 | - | // Add SSE error state | 185 | + | // 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 difference | 190 | + | // ============ EFFECTS ============ |
| 161 | - | const stateMessages = chatState.getMessages(); | |||
| 162 | - | if (JSON.stringify(stateMessages) !== JSON.stringify(messages)) { | |||
| 163 | - | messages = stateMessages; | |||
| 164 | 191 | ||||
| 165 | - | // Update oldest message timestamp | 192 | + | // 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 arrive | 197 | + | 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 needed | 207 | + | // 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 sequence | 210 | + | 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 window | 218 | + | 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 listener | 241 | + | // ============ 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 seconds | 247 | + | 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 sent | 256 | + | 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 complete | 317 | + | 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 loading | 348 | + | 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 state | 368 | + | // 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 @@ | |||
| 1 | 1 | /** | |
| 2 | 2 | * Chat State Management | |
| 3 | 3 | * | |
| 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. | |
| 6 | 6 | */ | |
| 7 | 7 | ||
| 8 | - | import type { User, Message, EnrichedMessage, SafeUser } from '../types/chat'; | |
| 8 | + | import type { User, SafeUser } from '../types/chat'; | |
| 9 | 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 | 11 | 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); | |
| 52 | 15 | ||
| 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'); | |
| 55 | 18 | ||
| 56 | - | // Derived state | |
| 19 | + | // Derived public state | |
| 57 | 20 | public connectionStatus = $derived(this._connectionStatus); | |
| 58 | - | public sseError = $derived(this._sseError); | |
| 59 | 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 | 24 | * Get the current user | |
| 109 | 25 | */ | |
| 110 | 26 | 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; | |
| 204 | 28 | } | |
| 205 | 29 | ||
| 206 | 30 | /** | |
| 207 | - | * Get online users sorted by status | |
| 31 | + | * Set the current user (called from layout when session changes) | |
| 208 | 32 | */ | |
| 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) { | |
| 266 | 34 | 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'; | |
| 588 | 37 | return; | |
| 589 | 38 | } | |
| 590 | 39 | ||
| 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'; | |
| 610 | 43 | } | |
| 611 | 44 | ||
| 612 | 45 | /** | |
| 613 | - | * Handle incoming SSE messages | |
| 46 | + | * Get the current room ID | |
| 614 | 47 | */ | |
| 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; | |
| 634 | 50 | } | |
| 635 | 51 | ||
| 636 | 52 | /** | |
| 637 | - | * Handle chat message from SSE | |
| 53 | + | * Set the current room ID | |
| 638 | 54 | */ | |
| 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; | |
| 650 | 57 | } | |
| 651 | 58 | ||
| 652 | 59 | /** | |
| 653 | - | * Handle connection status changes | |
| 60 | + | * Set connection status | |
| 654 | 61 | */ | |
| 655 | - | private handleConnectionStatusChange(status: ConnectionStatus) { | |
| 62 | + | setConnectionStatus(status: 'connected' | 'disconnected' | 'reconnecting') { | |
| 656 | 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 | ||
| @@ -1,730 +1,66 @@ | |||||
| 1 | /** | 1 | /** | ||
| 2 | * Chat State Management | 2 | * 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 state | 12 | + | // 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 users | 16 | + | // 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 state | 19 | + | // 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 user | 24 | * 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 status | 31 | + | * 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 messages | 46 | + | * 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 SSE | 53 | + | * 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 changes | 60 | + | * 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 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | 2 | import '../app.css'; | |
| 3 | + | import { setupConvex } from 'convex-svelte'; | |
| 4 | + | import { PUBLIC_CONVEX_URL } from '$env/static/public'; | |
| 3 | 5 | import { chatState } from '$lib/states/chat.svelte'; | |
| 4 | 6 | import { onMount } from 'svelte'; | |
| 5 | 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 | 12 | let { children, data } = $props(); | |
| 9 | 13 | let isVisible = $state(true); | |
| @@ -12,14 +16,14 @@ | |||
| 12 | 16 | let updateInProgress = $state(false); | |
| 13 | 17 | let idleTimeout = $state<number | undefined>(undefined); | |
| 14 | 18 | ||
| 15 | - | // Timing constants from SSE config for consistency | |
| 19 | + | // Status update timing constants | |
| 16 | 20 | let lastSentStatus = $state<string | null>(null); | |
| 17 | 21 | let lastStatusSentAt = $state<number>(0); | |
| 18 | 22 | 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 | |
| 20 | 24 | 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 | |
| 23 | 27 | ||
| 24 | 28 | type UserStatus = 'online' | 'offline' | 'busy' | 'idle'; | |
| 25 | 29 | ||
| @@ -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 consistency | 19 | + | // 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 seconds | 23 | + | const STATUS_UPDATE_INTERVAL = 30000; // 30 seconds |
| 20 | const THROTTLE_TIME_MS = 10000; // 10 seconds | 24 | const THROTTLE_TIME_MS = 10000; // 10 seconds | ||
| 21 | - | const FORCE_UPDATE_INTERVAL = SSE_CONFIG.CONNECTION_TIMEOUT; // 5 minutes | 25 | + | 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 | ||||