1<script lang="ts">
2 import { chatState } from '../states/chat.svelte';
3 import { useConvexClient, useQuery } from 'convex-svelte';
4 import { api as convexApi } from '../../convex/_generated/api';
5 import type { Id } from '../../convex/_generated/dataModel';
6 import type { Message, EnrichedMessage, SafeUser } from '../types/chat';
7 import { onMount, tick } from 'svelte';
8 import { SvelteSet, SvelteMap } from 'svelte/reactivity';
9 import { browser } from '$app/environment';
10 import { env } from '$env/dynamic/public';
11 import { api } from '$lib/api/client';
12 import LoadingButton from './ui/button-loading.svelte';
13 import Tooltip from './ui/tooltip.svelte';
14 import LoadingDots from './ui/loading-dots.svelte';
15 import { desktop } from '$lib/states/desktop.svelte';
16 import { authQuery, tokenHash, useAimClient } from '$lib/aim/client';
17 import { PUBLIC_PREVIEW_MESSAGES } from '$lib/validation/message';
18 import { STATUS_LABELS_FR, type AimStatus } from '$lib/types/aim';
19 import FormattedMessage from './formatted-message.svelte';
20 import TextFormattingToolbar from './text-formatting-toolbar.svelte';
21 import MentionPicker from './mention-picker.svelte';
22 import {
23 MAX_MENTION_SUGGESTIONS,
24 MENTION_PICKER,
25 findMentionContext,
26 highlightMentionsInput,
27 containsMention,
28 calculatePickerHeight,
29 calculatePickerWidth
30 } from '../utils/mention';
31 import { DEFAULT_TEXT_STYLE, type TextStyle } from '../types/text-formatting';
32 import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format';
33 import { debounce } from '$lib/utils/chat-window-state';
34 import { MAX_MESSAGE_LENGTH } from '$lib/validation/message';
35
36 // Props
37 let {
38 roomId: requestedRoomId = null,
39 initialTextStyle = DEFAULT_TEXT_STYLE,
40 showUserList = false
41 } = $props<{
42 roomId?: Id<'chatRooms'> | null;
43 initialTextStyle?: TextStyle;
44 /** Mobile only: whether the people list is expanded */
45 showUserList?: boolean;
46 }>();
47
48 const isClient = browser && Boolean(env.PUBLIC_CONVEX_URL);
49 const aim = useAimClient();
50
51 // ============ CONVEX REAL-TIME SUBSCRIPTIONS ============
52
53 // Subscribe to default room (to get room ID) when no explicit room was requested
54 // svelte-ignore state_referenced_locally
55 const defaultRoomQuery =
56 isClient && !requestedRoomId ? useQuery(convexApi.queries.getDefaultRoomPublic, {}) : null;
57
58 // Subscribe to users (buddy list) - real-time updates
59 const usersQuery = isClient ? useQuery(convexApi.queries.getUsersPublic, {}) : null;
60
61 // Room ID: explicit prop or the default room
62 const roomId = $derived(
63 (requestedRoomId ?? defaultRoomQuery?.data?.id) as Id<'chatRooms'> | undefined
64 );
65
66 // Subscribe to messages - skipped until the room ID is available.
67 // Members get the full live feed; visitors only the public preview of the default room.
68 const memberMessagesQuery = authQuery(convexApi.queries.getMessages, () =>
69 roomId ? { roomId } : 'skip'
70 );
71 const publicMessagesQuery = isClient
72 ? useQuery(convexApi.queries.getMessagesPublic, () =>
73 roomId && !tokenHash() ? { roomId } : 'skip'
74 )
75 : null;
76 const messagesQuery = $derived(tokenHash() ? memberMessagesQuery : publicMessagesQuery);
77
78 const convexClient = isClient ? useConvexClient() : null;
79
80 // Transform Convex users to SafeUser format
81 const onlineUsers = $derived.by<SafeUser[]>(() => {
82 if (!usersQuery?.data) return [];
83 return usersQuery.data
84 .map((u) => ({
85 id: u.id,
86 nickname: u.nickname,
87 status: u.status as SafeUser['status'],
88 avatarUrl: u.avatarUrl ?? null,
89 lastSeen: u.lastSeen ?? null,
90 awayMessage: u.awayMessage
91 }))
92 .sort((a, b) => {
93 const statusOrder: Record<string, number> = {
94 online: 0,
95 idle: 1,
96 away: 2,
97 busy: 3,
98 offline: 4
99 };
100 const diff = (statusOrder[a.status] ?? 4) - (statusOrder[b.status] ?? 4);
101 return diff !== 0 ? diff : a.nickname.localeCompare(b.nickname);
102 });
103 });
104
105 // Transform Convex messages to EnrichedMessage format
106 const messages = $derived.by<EnrichedMessage[]>(() => {
107 if (!messagesQuery?.data) return [];
108 return messagesQuery.data.map((msg) => ({
109 id: msg.id,
110 chatRoomId: msg.chatRoomId,
111 senderId: msg.senderId,
112 content: msg.content,
113 type: msg.type as Message['type'],
114 timestamp: msg.timestamp,
115 styleData: msg.styleData,
116 hasFormatting: msg.hasFormatting ?? false,
117 user: msg.sender
118 ? {
119 id: msg.sender.id,
120 nickname: msg.sender.nickname,
121 status: msg.sender.status as SafeUser['status'],
122 avatarUrl: msg.sender.avatarUrl ?? null,
123 lastSeen: null
124 }
125 : {
126 id: msg.senderId,
127 nickname: 'Unknown User',
128 status: 'offline' as const,
129 avatarUrl: null,
130 lastSeen: null
131 }
132 }));
133 });
134
135 // Messages loaded via pagination (older history)
136 let pagedMessages = $state<Message[]>([]);
137
138 // Merge paginated messages with live subscription data
139 const allMessages = $derived.by<EnrichedMessage[]>(() => {
140 // Using object for deduplication (avoiding Map for svelte reactivity compliance)
141 const merged: Record<string, EnrichedMessage> = {};
142 for (const msg of pagedMessages) {
143 merged[msg.id] = enrichMessage(msg);
144 }
145 for (const msg of messages) {
146 merged[msg.id] = msg;
147 }
148 return Object.values(merged).sort((a, b) => a.timestamp - b.timestamp);
149 });
150
151 // Loading and error states from Convex
152 const isInitialLoading = $derived(
153 (defaultRoomQuery?.isLoading ?? false) ||
154 (usersQuery?.isLoading ?? false) ||
155 (messagesQuery?.isLoading ?? false)
156 );
157 const connectionError = $derived.by<string | null>(() => {
158 if (defaultRoomQuery?.error) return 'Erreur de connexion au salon';
159 if (usersQuery?.error) return 'Erreur de chargement des utilisateurs';
160 if (messagesQuery?.error) return 'Erreur de chargement des messages';
161 return null;
162 });
163
164 // Update chatState with room ID when available
165 $effect(() => {
166 if (roomId) {
167 chatState.setCurrentRoomId(roomId);
168 chatState.setConnectionStatus('connected');
169 }
170 });
171
172 // ============ USER STATE ============
173
174 const currentUser = $derived(chatState.getCurrentUser());
175
176 // ============ WINDOW STATE ============
177
178 let isMobile = $state(false);
179
180 // ============ USER LIST INTERACTIONS (AIM-style) ============
181
182 let selectedUserId = $state<string | null>(null);
183 let userMenu = $state<{ x: number; y: number; user: SafeUser } | null>(null);
184 let roomPanel = $state<HTMLDivElement | null>(null);
185
186 async function openImWith(user: SafeUser) {
187 if (!currentUser || user.id === currentUser.id) return;
188 try {
189 const { id } = await aim.getOrCreateDirectRoom(user.id as Id<'users'>);
190 desktop.openIm({
191 roomId: id,
192 otherUserId: user.id as Id<'users'>,
193 otherNickname: user.nickname
194 });
195 } catch (err) {
196 console.error('Failed to open IM', err);
197 }
198 }
199
200 function openUserMenu(event: MouseEvent, user: SafeUser) {
201 event.preventDefault();
202 selectedUserId = user.id;
203 const rect = roomPanel?.getBoundingClientRect();
204 userMenu = {
205 x: event.clientX - (rect?.left ?? 0),
206 y: event.clientY - (rect?.top ?? 0),
207 user
208 };
209 }
210
211 function closeUserMenu() {
212 userMenu = null;
213 }
214
215 function statusLabel(user: SafeUser) {
216 const label = STATUS_LABELS_FR[(user.status as AimStatus) ?? 'offline'] ?? user.status;
217 if (user.status === 'away' && user.awayMessage) return `${label} : ${user.awayMessage}`;
218 if (user.status === 'offline') {
219 return `${label} — dernière connexion : ${formatFrenchRelativeTimeSafe(user.lastSeen)}`;
220 }
221 return label;
222 }
223
224 // ============ INPUT STATE ============
225
226 let currentMessage = $state('');
227 let inputScrollLeft = $state(0);
228 // Initialize text style with defaults - user-specific style applied after API fetch
229 let currentTextStyle = $state<TextStyle>({ ...DEFAULT_TEXT_STYLE });
230 // Track if we've applied the user's style from the API/DB (to avoid repeated fetch resets)
231 let hasAppliedUserStyle = $state(false);
232 let lastInitialStyleJson = $state<string | null>(null);
233 let styleFetchNonce = $state(0);
234 let styleFetchAttempts = 0;
235 let styleFetchInFlight = false;
236 let lastUserId = $state<string | null>(null);
237
238 // ============ MENTION STATE ============
239
240 let messageInput = $state<HTMLInputElement | null>(null);
241 let mentionOpen = $state(false);
242 let mentionQuery = $state('');
243 let mentionAnchorIndex = $state<number | null>(null);
244 let mentionSelectedIndex = $state(0);
245 let mentionObservedIds = $state<Set<string>>(new Set());
246 let mentionMessageIds = $state<Set<string>>(new Set());
247 let mentionUnreadCount = $state(0);
248 let mentionDismissKey = $state<string | null>(null);
249 let mentionObserver: IntersectionObserver | null = null;
250 let pendingMentionTimestamp = $state<number | null>(null);
251
252 let baseTitle = $state('');
253 const mentionListBoxId = `mentions-${Math.random().toString(36).slice(2, 8)}`;
254 let mentionActiveId = $state<string | null>(null);
255 let chatArea = $state<HTMLDivElement | null>(null);
256 let mentionListStyle = $state('');
257 let mentionListInside = $state(true);
258 let mentionMeasureContext: CanvasRenderingContext2D | null = null;
259
260 // ============ RATE LIMITING STATE ============
261
262 let cooldownEndTime = $state<number | null>(null);
263 let cooldownProgress = $state(0);
264 let cooldownInterval: ReturnType<typeof setInterval> | null = null;
265 let rateLimitWarning = $state<string | null>(null);
266 let isSendingMessage = $state(false);
267
268 // ============ SCROLL STATE ============
269
270 let isLoadingMore = $state(false);
271 let hasMoreMessages = $state(true);
272 let oldestMessageTimestamp = $state<number | null>(null);
273 let lastMessageSignature = $state<string | null>(null);
274 let hasInitialScrolled = $state(false);
275
276 // ============ DERIVED STATE ============
277
278 // Visible messages (limit for non-logged-in users)
279 const visibleMessages = $derived.by<EnrichedMessage[]>(() => {
280 const isLoggedIn = Boolean(currentUser);
281 return isLoggedIn
282 ? allMessages
283 : allMessages.slice(Math.max(0, allMessages.length - PUBLIC_PREVIEW_MESSAGES));
284 });
285
286 // Show registration prompt for non-logged-in users
287 const showRegistrationPrompt = $derived(
288 !currentUser && allMessages.length >= PUBLIC_PREVIEW_MESSAGES
289 );
290
291 // Character counter
292 const CHAR_WARNING_THRESHOLD = Math.floor(MAX_MESSAGE_LENGTH * 0.8);
293 const showCharCounter = $derived(currentMessage.length >= CHAR_WARNING_THRESHOLD);
294 const charCounterClass = $derived(
295 currentMessage.length >= MAX_MESSAGE_LENGTH
296 ? 'at-limit'
297 : currentMessage.length >= MAX_MESSAGE_LENGTH * 0.95
298 ? 'near-limit'
299 : 'warning'
300 );
301
302 // User counts
303 const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline'));
304 const usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline'));
305 const selectedUser = $derived(onlineUsers.find((u) => u.id === selectedUserId) ?? null);
306
307 const mentionableUsers = $derived(
308 onlineUsers.filter((user) => !currentUser || user.id !== currentUser.id)
309 );
310
311 // Filter users for mention autocomplete
312 // Empty query (just "@") shows all users to let user browse the list
313 const mentionSuggestions = $derived.by<SafeUser[]>(() => {
314 if (!mentionOpen) return [];
315 const query = mentionQuery.trim().toLowerCase();
316 const candidates = query
317 ? mentionableUsers.filter((user) => user.nickname.toLowerCase().startsWith(query))
318 : mentionableUsers;
319 return candidates.slice(0, MAX_MENTION_SUGGESTIONS);
320 });
321
322 // Format input message with mention highlighting (for overlay display)
323 const formattedInputMessage = $derived.by(() => {
324 if (!currentMessage) return '';
325 // Escape HTML entities first
326 const escaped = currentMessage
327 .replace(/&/g, '&')
328 .replace(/</g, '<')
329 .replace(/>/g, '>');
330 // Apply mention styling using shared utility
331 return highlightMentionsInput(escaped);
332 });
333
334 // mentionUnreadCount is managed in state updates
335 const hasMentionHighlight = $derived.by<Record<string, boolean>>(() => {
336 const highlights: Record<string, boolean> = {};
337 if (!currentUser) return highlights;
338 for (const message of visibleMessages) {
339 highlights[message.id] = shouldHighlightMention(message);
340 }
341 return highlights;
342 });
343
344 // ============ EFFECTS ============
345
346 // Update text style when user logs in - fetch preferences directly from API
347 $effect(() => {
348 const _styleFetchNonce = styleFetchNonce;
349 if (_styleFetchNonce < 0) return;
350 const userId = currentUser?.id ?? null;
351 const defaultStyleJson = JSON.stringify(DEFAULT_TEXT_STYLE);
352 const initialStyleJson = JSON.stringify(initialTextStyle ?? DEFAULT_TEXT_STYLE);
353
354 if (initialStyleJson !== lastInitialStyleJson) {
355 lastInitialStyleJson = initialStyleJson;
356 currentTextStyle = {
357 ...DEFAULT_TEXT_STYLE,
358 ...(initialTextStyle ?? DEFAULT_TEXT_STYLE),
359 color: (initialTextStyle?.color ?? DEFAULT_TEXT_STYLE.color) || '#000000'
360 };
361 if (!currentUser) {
362 hasAppliedUserStyle = false;
363 } else if (initialStyleJson !== defaultStyleJson) {
364 hasAppliedUserStyle = true;
365 }
366 }
367
368 // User changed (logged in or different user)
369 if (userId !== lastUserId) {
370 lastUserId = userId;
371 hasAppliedUserStyle = false;
372 styleFetchAttempts = 0;
373 styleFetchInFlight = false;
374 }
375
376 // Fetch and apply user's saved style when they log in
377 if (!currentUser || hasAppliedUserStyle || styleFetchInFlight || !browser) return;
378
379 let isActive = true;
380 const activeUserId = currentUser.id;
381 const attempt = styleFetchAttempts + 1;
382 styleFetchAttempts = attempt;
383 styleFetchInFlight = true;
384 // Fetch user preferences directly to avoid timing issues with invalidateAll
385 api.textPreferences
386 .get()
387 .then((prefs) => {
388 if (!isActive || currentUser?.id !== activeUserId) return;
389 if (!prefs?.defaultStyle) return;
390 currentTextStyle = {
391 ...DEFAULT_TEXT_STYLE,
392 ...prefs.defaultStyle,
393 color: prefs.defaultStyle.color || '#000000'
394 };
395 hasAppliedUserStyle = true;
396 })
397 .finally(() => {
398 if (!isActive || currentUser?.id !== activeUserId) return;
399 if (!hasAppliedUserStyle && attempt < 3) {
400 const retryDelay = Math.min(1500, 300 * attempt);
401 window.setTimeout(() => {
402 styleFetchInFlight = false;
403 styleFetchNonce += 1;
404 }, retryDelay);
405 return;
406 }
407 styleFetchInFlight = false;
408 });
409
410 return () => {
411 isActive = false;
412 };
413 });
414
415 // Save text style to database when it changes (after initial load)
416 let lastSavedStyleJson = $state<string | null>(null);
417 $effect(() => {
418 // Only save if user is logged in and style has been initialized
419 if (!currentUser || !hasAppliedUserStyle || !browser) return;
420
421 const styleJson = JSON.stringify(currentTextStyle);
422
423 // Skip if this is the initial load (set lastSavedStyleJson to current)
424 if (lastSavedStyleJson === null) {
425 lastSavedStyleJson = styleJson;
426 return;
427 }
428
429 // Only save if style actually changed
430 if (styleJson !== lastSavedStyleJson) {
431 lastSavedStyleJson = styleJson;
432 debouncedSaveTextStyle(currentTextStyle);
433 }
434 });
435
436 // Auto-scroll to bottom when new messages arrive
437 $effect(() => {
438 if (!browser || !chatArea) return;
439
440 // Reference visibleMessages so effect re-runs when messages change
441 const latestMessage = visibleMessages[visibleMessages.length - 1];
442 if (!latestMessage) return;
443 const firstMessage = visibleMessages[0];
444 const messageSignature = `${firstMessage?.id ?? 'none'}:${latestMessage.id}:${visibleMessages.length}`;
445 const hasMessageUpdate = messageSignature !== lastMessageSignature;
446 lastMessageSignature = messageSignature;
447
448 const isNearBottom = chatArea.scrollTop + chatArea.offsetHeight >= chatArea.scrollHeight - 40;
449
450 // Scroll to bottom on initial load OR when messages update (if user was near bottom)
451 if (!hasInitialScrolled || (hasMessageUpdate && isNearBottom)) {
452 hasInitialScrolled = true;
453 // Use rAF to ensure browser has completed layout after DOM update
454 requestAnimationFrame(() => {
455 if (chatArea) {
456 chatArea.scrollTo(0, chatArea.scrollHeight);
457 }
458 });
459 }
460 });
461
462 // Update oldest timestamp for pagination
463 $effect(() => {
464 if (allMessages.length > 0) {
465 oldestMessageTimestamp = Math.min(...allMessages.map((m) => m.timestamp));
466 }
467 });
468
469 $effect(() => {
470 if (!browser) return;
471 if (!baseTitle) baseTitle = document.title;
472 const totalMentions = chatState.getMentionTotal();
473 if (totalMentions > 0) {
474 document.title = `(${totalMentions}) ${baseTitle}`;
475 } else {
476 document.title = baseTitle;
477 }
478 });
479
480 $effect(() => {
481 chatState.setRoomMentionCount(roomId ?? null, mentionUnreadCount);
482 });
483
484 $effect(() => {
485 if (!browser || !currentUser) {
486 mentionObservedIds = new SvelteSet();
487 mentionMessageIds = new SvelteSet();
488 if (mentionUnreadCount !== 0) {
489 mentionUnreadCount = 0;
490 }
491 // Reset pending timestamp when user logs out
492 pendingMentionTimestamp = null;
493 // mention observer reset
494 if (mentionObserver) {
495 mentionObserver.disconnect();
496 mentionObserver = null;
497 }
498 return;
499 }
500 const mentionIds = new SvelteSet<string>();
501 const lastReadTimestamp = currentUser.lastReadMentionTimestamp ?? 0;
502 const mentionIdsToPreObserve: string[] = [];
503
504 for (const message of visibleMessages) {
505 if (shouldHighlightMention(message)) {
506 mentionIds.add(message.id);
507 // Pre-mark as observed if message is older than lastReadMentionTimestamp
508 if (message.timestamp <= lastReadTimestamp && !mentionObservedIds.has(message.id)) {
509 mentionIdsToPreObserve.push(message.id);
510 }
511 }
512 }
513 // Batch update observed IDs to avoid repeated Set allocations
514 if (mentionIdsToPreObserve.length > 0) {
515 mentionObservedIds = new SvelteSet([...mentionObservedIds, ...mentionIdsToPreObserve]);
516 }
517 const nextMentionIds = mentionIds;
518 if (!setsEqual(nextMentionIds, mentionMessageIds)) {
519 mentionMessageIds = nextMentionIds;
520 }
521 const nextUnreadCount = Array.from(nextMentionIds).filter(
522 (messageId) => !mentionObservedIds.has(messageId)
523 ).length;
524 // mentionObservedIds is a local Set to avoid effect loops
525 if (mentionUnreadCount !== nextUnreadCount) {
526 mentionUnreadCount = nextUnreadCount;
527 }
528 });
529
530 $effect(() => {
531 const currentMentionIds = mentionMessageIds;
532 if (!browser || !chatArea || !currentUser) return;
533 if (mentionObserver) {
534 mentionObserver.disconnect();
535 }
536 // Precompute message timestamp map to avoid O(n) lookup in callback
537 const messageTimestampMap = new SvelteMap<string, number>();
538 for (const msg of visibleMessages) {
539 messageTimestampMap.set(msg.id, msg.timestamp);
540 }
541 mentionObserver = new IntersectionObserver(
542 (entries) => {
543 const updatedObserved = new SvelteSet(mentionObservedIds);
544 let changed = false;
545 const currentObserved = mentionObservedIds;
546 const currentUnread = mentionUnreadCount;
547 let maxTimestamp = pendingMentionTimestamp ?? 0;
548
549 for (const entry of entries) {
550 const target = entry.target as HTMLElement;
551 const messageId = target.dataset.messageId;
552 if (!messageId || !currentMentionIds.has(messageId)) continue;
553 if (entry.isIntersecting && !updatedObserved.has(messageId)) {
554 updatedObserved.add(messageId);
555 changed = true;
556
557 // Get timestamp from precomputed map (O(1) instead of O(n))
558 const timestamp = messageTimestampMap.get(messageId);
559 if (timestamp !== undefined && timestamp > maxTimestamp) {
560 maxTimestamp = timestamp;
561 }
562 }
563 }
564 if (changed && !setsEqual(updatedObserved, currentObserved)) {
565 mentionObservedIds = updatedObserved;
566 const nextUnread = Math.max(0, currentMentionIds.size - updatedObserved.size);
567 if (currentUnread !== nextUnread) {
568 mentionUnreadCount = nextUnread;
569 }
570
571 // Update the server with the new max timestamp
572 if (maxTimestamp > (pendingMentionTimestamp ?? 0)) {
573 pendingMentionTimestamp = maxTimestamp;
574 debouncedUpdateMentionTimestamp(maxTimestamp);
575 }
576 }
577 },
578 { root: chatArea, threshold: 0.6 }
579 );
580 const nodes = Array.from(chatArea.querySelectorAll<HTMLElement>('[data-message-id]'));
581 for (const node of nodes) {
582 const messageId = node.dataset.messageId;
583 if (messageId && currentMentionIds.has(messageId)) {
584 mentionObserver.observe(node);
585 }
586 }
587 // only observe mention messages
588 // mention observer reset
589 return () => {
590 mentionObserver?.disconnect();
591 };
592 });
593
594 $effect(() => {
595 if (!mentionOpen || mentionSuggestions.length === 0) {
596 if (mentionActiveId !== null) {
597 mentionActiveId = null;
598 }
599 return;
600 }
601 const currentActiveId = mentionActiveId;
602 let selectedIndex = mentionSelectedIndex;
603 if (selectedIndex >= mentionSuggestions.length) {
604 selectedIndex = 0;
605 if (mentionSelectedIndex !== 0) {
606 mentionSelectedIndex = 0;
607 }
608 }
609 const active = mentionSuggestions[selectedIndex];
610 const nextActiveId = active ? getMentionListItemId(active.id) : null;
611 if (currentActiveId !== nextActiveId) {
612 mentionActiveId = nextActiveId;
613 }
614 });
615
616 $effect(() => {
617 if (!mentionOpen || !messageInput) {
618 if (mentionListStyle) mentionListStyle = '';
619 return;
620 }
621 const currentListStyle = mentionListStyle;
622 const currentInside = mentionListInside;
623 const inputRect = messageInput.getBoundingClientRect();
624 const containerRect = messageInput.closest('.chat-container')?.getBoundingClientRect();
625 // Use shared utility for height calculation
626 const listHeight = calculatePickerHeight(mentionSuggestions.length);
627 const spaceBelow = containerRect
628 ? containerRect.bottom - inputRect.bottom
629 : window.innerHeight - inputRect.bottom;
630 const spaceAbove = containerRect ? inputRect.top - containerRect.top : inputRect.top;
631 const showAbove = spaceBelow < listHeight && spaceAbove > listHeight;
632 const maxNameWidth = mentionSuggestions.reduce((max, user) => {
633 const width = measureMentionText(user.nickname, true, 13.6);
634 return Math.max(max, width);
635 }, 0);
636 // Status uses 0.7rem (11.2px) font size and uppercase (add ~10% for letter-spacing)
637 const statusWidth = measureMentionText('OFFLINE', false, 11.2) * 1.1;
638 // Use shared utility for width calculation
639 const listWidth = calculatePickerWidth(maxNameWidth, statusWidth);
640 if (containerRect) {
641 if (!currentInside) mentionListInside = true;
642 const top = showAbove
643 ? inputRect.top - containerRect.top - listHeight - MENTION_PICKER.OFFSET_GAP
644 : inputRect.bottom - containerRect.top + MENTION_PICKER.OFFSET_GAP;
645 const left = inputRect.left - containerRect.left;
646 const nextStyle = `top: ${Math.max(MENTION_PICKER.EDGE_MARGIN, top)}px; left: ${Math.max(MENTION_PICKER.EDGE_MARGIN, left)}px; width: ${listWidth}px;`;
647 if (currentListStyle !== nextStyle) {
648 mentionListStyle = nextStyle;
649 }
650 } else {
651 if (currentInside) mentionListInside = false;
652 const top = showAbove
653 ? inputRect.top - listHeight - MENTION_PICKER.OFFSET_GAP
654 : inputRect.bottom + MENTION_PICKER.OFFSET_GAP;
655 const nextStyle = `top: ${Math.max(MENTION_PICKER.EDGE_MARGIN, top)}px; left: ${Math.max(MENTION_PICKER.EDGE_MARGIN, inputRect.left)}px; width: ${listWidth}px;`;
656 if (currentListStyle !== nextStyle) {
657 mentionListStyle = nextStyle;
658 }
659 }
660 if (mentionSuggestions.length === 0 && mentionActiveId !== null) {
661 mentionActiveId = null;
662 }
663 if (mentionSelectedIndex >= mentionSuggestions.length && mentionSuggestions.length > 0) {
664 mentionSelectedIndex = 0;
665 }
666 });
667
668 // ============ HELPERS ============
669
670 function resolveUser(senderId: string): SafeUser {
671 const knownUser = onlineUsers.find((user) => user.id === senderId);
672 if (knownUser) return knownUser;
673 return {
674 id: senderId,
675 nickname: 'Unknown User',
676 status: 'offline',
677 avatarUrl: null,
678 lastSeen: null
679 };
680 }
681
682 function enrichMessage(message: Message): EnrichedMessage {
683 return {
684 ...message,
685 user: resolveUser(message.senderId)
686 };
687 }
688
689 function mergePagedMessages(incoming: Message[]) {
690 if (incoming.length === 0) return;
691 const existingIds = new Set(pagedMessages.map((message) => message.id));
692 const newMessages = incoming.filter((message) => !existingIds.has(message.id));
693 if (newMessages.length === 0) return;
694 pagedMessages = [...pagedMessages, ...newMessages];
695 }
696
697 // Debounced function to update the server with last read mention timestamp
698 const debouncedUpdateMentionTimestamp = debounce(async (timestamp: number) => {
699 if (!currentUser || !browser) return;
700 await api.mentions.markRead(timestamp);
701 }, 1000);
702
703 // Debounced function to save text style preferences to the database
704 const debouncedSaveTextStyle = debounce(async (style: TextStyle) => {
705 if (!currentUser || !browser) return;
706 await api.textPreferences.save({
707 defaultStyle: style,
708 allowFormatting: true,
709 maxMessageLength: MAX_MESSAGE_LENGTH
710 });
711 }, 1500);
712
713 function setsEqual(a: Set<string>, b: Set<string>) {
714 if (a.size !== b.size) return false;
715 for (const value of a) {
716 if (!b.has(value)) return false;
717 }
718 return true;
719 }
720
721 function measureMentionText(text: string, bold: boolean = true, fontSizePx: number = 13.6) {
722 if (!mentionMeasureContext) {
723 const canvas = document.createElement('canvas');
724 mentionMeasureContext = canvas.getContext('2d');
725 }
726 if (!mentionMeasureContext) return 0;
727 mentionMeasureContext.font = `${bold ? 'bold ' : ''}${fontSizePx}px 'Pixelated MS Sans Serif', Tahoma, sans-serif`;
728 return mentionMeasureContext.measureText(text).width;
729 }
730
731 function updateMentionState(input: HTMLInputElement) {
732 if (!currentUser) {
733 mentionOpen = false;
734 return;
735 }
736 const cursorIndex = input.selectionStart ?? input.value.length;
737 const context = findMentionContext(input.value, cursorIndex);
738 if (!context) {
739 closeMentionPicker();
740 return;
741 }
742 const mentionKey = `${context.atIndex}:${context.query}`;
743 if (mentionDismissKey === mentionKey) {
744 return;
745 }
746 const wasOpen = mentionOpen;
747 const previousQuery = mentionQuery;
748 const sameAnchor = mentionAnchorIndex === context.atIndex;
749 mentionOpen = true;
750 mentionQuery = context.query;
751 mentionAnchorIndex = context.atIndex;
752 mentionDismissKey = null;
753 if (!wasOpen || !sameAnchor || previousQuery !== context.query) {
754 mentionSelectedIndex = 0;
755 }
756 }
757
758 function registerMentionNode(node: HTMLElement) {
759 if (!node) return;
760 const messageId = node.dataset.messageId;
761 if (!messageId) return;
762 if (mentionObserver) {
763 mentionObserver.observe(node);
764 }
765 // Return destroy method to properly cleanup when node is removed
766 return {
767 destroy() {
768 if (mentionObserver) {
769 mentionObserver.unobserve(node);
770 }
771 }
772 };
773 }
774
775 function closeMentionPicker() {
776 mentionOpen = false;
777 mentionQuery = '';
778 mentionAnchorIndex = null;
779 mentionSelectedIndex = 0;
780 mentionActiveId = null;
781 mentionDismissKey = null;
782 if (messageInput) {
783 messageInput.focus();
784 }
785 }
786
787 function clampMentionIndex(nextIndex: number) {
788 if (mentionSuggestions.length === 0) return 0;
789 const maxIndex = mentionSuggestions.length - 1;
790 if (nextIndex < 0) return maxIndex;
791 if (nextIndex > maxIndex) return 0;
792 return nextIndex;
793 }
794
795 function applyMentionSelection(user: SafeUser) {
796 if (!messageInput || mentionAnchorIndex === null) return;
797 const selectionStart = messageInput.selectionStart ?? currentMessage.length;
798 const before = currentMessage.slice(0, mentionAnchorIndex);
799 const after = currentMessage.slice(selectionStart);
800 const mentionText = `@${user.nickname} `;
801 currentMessage = `${before}${mentionText}${after}`;
802 void tick().then(() => {
803 if (!messageInput) return;
804 const cursor = before.length + mentionText.length;
805 messageInput.focus();
806 messageInput.setSelectionRange(cursor, cursor);
807 });
808 closeMentionPicker();
809 }
810
811 function handleMentionKeydown(event: KeyboardEvent) {
812 if (!mentionOpen || mentionSuggestions.length === 0) return;
813 if (event.key === 'ArrowDown') {
814 event.preventDefault();
815 mentionSelectedIndex = clampMentionIndex(mentionSelectedIndex + 1);
816 void tick().then(() => scrollMentionOptionIntoView());
817 return;
818 }
819 if (event.key === 'ArrowUp') {
820 event.preventDefault();
821 mentionSelectedIndex = clampMentionIndex(mentionSelectedIndex - 1);
822 void tick().then(() => scrollMentionOptionIntoView());
823 return;
824 }
825 if (event.key === 'Enter' || event.key === 'Tab') {
826 event.preventDefault();
827 const choice = mentionSuggestions[mentionSelectedIndex];
828 if (choice) applyMentionSelection(choice);
829 return;
830 }
831 if (event.key === 'Escape') {
832 event.preventDefault();
833 if (mentionOpen && mentionAnchorIndex !== null) {
834 mentionDismissKey = `${mentionAnchorIndex}:${mentionQuery}`;
835 }
836 closeMentionPicker();
837 }
838 }
839
840 function getMentionListItemId(userId: string) {
841 return `${mentionListBoxId}-${userId}`;
842 }
843
844 function scrollMentionOptionIntoView() {
845 if (!mentionActiveId) return;
846 const option = document.getElementById(mentionActiveId);
847 option?.scrollIntoView({ block: 'nearest' });
848 }
849
850 function updateCooldownProgress() {
851 if (!cooldownEndTime) return;
852 const now = Date.now();
853 if (now >= cooldownEndTime) {
854 cooldownEndTime = null;
855 cooldownProgress = 0;
856 rateLimitWarning = null;
857 if (cooldownInterval) {
858 clearInterval(cooldownInterval);
859 cooldownInterval = null;
860 }
861 return;
862 }
863 cooldownProgress = (cooldownEndTime - now) / 1000;
864 }
865
866 function startCooldownTimer(retryAfter: number) {
867 cooldownEndTime = Date.now() + retryAfter;
868 if (cooldownInterval) clearInterval(cooldownInterval);
869 cooldownInterval = setInterval(updateCooldownProgress, 100);
870 updateCooldownProgress();
871 }
872
873 // ============ HANDLERS ============
874
875 function shouldHighlightMention(message: EnrichedMessage) {
876 if (!currentUser) return false;
877 if (message.senderId === currentUser.id) return false;
878 return containsMention(message.content, currentUser.nickname);
879 }
880
881 function handleInputChange(e: Event) {
882 const input = e.target as HTMLInputElement;
883 if (input.value.length > MAX_MESSAGE_LENGTH) {
884 input.value = input.value.slice(0, MAX_MESSAGE_LENGTH);
885 currentMessage = input.value;
886 }
887 inputScrollLeft = input.scrollLeft;
888 updateMentionState(input);
889 if (!mentionOpen && mentionQuery) {
890 mentionQuery = '';
891 }
892 }
893
894 function handleInputScroll(e: Event) {
895 inputScrollLeft = (e.target as HTMLInputElement).scrollLeft;
896 }
897
898 function handleInputFocus() {
899 if (!messageInput) return;
900 updateMentionState(messageInput);
901 }
902
903 function handleInputClick() {
904 if (!messageInput) return;
905 updateMentionState(messageInput);
906 }
907
908 function handleInputKeydown(event: KeyboardEvent) {
909 if (!messageInput) return;
910 if (event.key === 'Escape') {
911 event.preventDefault();
912 if (mentionOpen && mentionAnchorIndex !== null) {
913 mentionDismissKey = `${mentionAnchorIndex}:${mentionQuery}`;
914 }
915 closeMentionPicker();
916 return;
917 }
918 if (mentionOpen) {
919 handleMentionKeydown(event);
920 return;
921 }
922 if (event.key === 'Enter') {
923 event.preventDefault();
924 void handleSubmit();
925 }
926 }
927
928 function handleInputKeyup() {
929 if (!messageInput) return;
930 updateMentionState(messageInput);
931 }
932
933 async function handleSubmit() {
934 if (!currentMessage.trim() || !currentUser || cooldownEndTime) return;
935
936 isSendingMessage = true;
937 try {
938 const result = await api.chat.sendMessage({
939 content: currentMessage,
940 type: 'chat',
941 userId: currentUser.id,
942 chatRoomId: roomId,
943 styleData: currentTextStyle ? JSON.stringify(currentTextStyle) : undefined
944 });
945
946 if (!result.success && result.isRateLimited && result.retryAfter) {
947 startCooldownTimer(result.retryAfter);
948 rateLimitWarning = "Whoa there! You're sending messages too quickly. Take a breather...";
949 } else if (!result.success) {
950 rateLimitWarning = result.error || 'Failed to send message. Please try again.';
951 setTimeout(() => (rateLimitWarning = null), 3000);
952 } else {
953 currentMessage = '';
954 rateLimitWarning = null;
955 }
956 } catch (error) {
957 console.error('Failed to send message:', error);
958 rateLimitWarning = 'Failed to send message. Please try again.';
959 setTimeout(() => (rateLimitWarning = null), 3000);
960 } finally {
961 isSendingMessage = false;
962 }
963 }
964
965 async function handleScroll(event: Event) {
966 const target = event.target as HTMLElement;
967 const { scrollTop } = target;
968
969 if (
970 scrollTop < 100 &&
971 !isLoadingMore &&
972 hasMoreMessages &&
973 roomId &&
974 chatArea &&
975 convexClient
976 ) {
977 const hash = tokenHash();
978 if (!hash) return;
979 isLoadingMore = true;
980 const prevScrollHeight = chatArea.scrollHeight;
981 const prevScrollTop = chatArea.scrollTop;
982
983 try {
984 const data = await convexClient.query(convexApi.queries.getMessagesPage, {
985 tokenHash: hash,
986 roomId,
987 limit: 100,
988 beforeTimestamp: oldestMessageTimestamp ?? undefined
989 });
990
991 if (data.messages.length === 0) {
992 hasMoreMessages = false;
993 } else {
994 const fetchedMessages = data.messages as Message[];
995 mergePagedMessages(fetchedMessages);
996
997 requestAnimationFrame(() => {
998 if (!chatArea) return;
999 const newScrollHeight = chatArea.scrollHeight;
1000 const heightDifference = newScrollHeight - prevScrollHeight;
1001 chatArea.scrollTop = prevScrollTop + heightDifference;
1002 });
1003
1004 const newOldest = Math.min(...fetchedMessages.map((m) => m.timestamp));
1005 oldestMessageTimestamp = oldestMessageTimestamp
1006 ? Math.min(oldestMessageTimestamp, newOldest)
1007 : newOldest;
1008 hasMoreMessages = data.hasMore;
1009 }
1010 } catch (error) {
1011 console.error('Error loading more messages:', error);
1012 hasMoreMessages = false;
1013 } finally {
1014 isLoadingMore = false;
1015 }
1016 }
1017 }
1018
1019 function insertSmiley(code: string) {
1020 const needsSpace = currentMessage.length > 0 && !currentMessage.endsWith(' ');
1021 currentMessage = `${currentMessage}${needsSpace ? ' ' : ''}${code} `;
1022 void tick().then(() => messageInput?.focus());
1023 }
1024
1025 function openSignup() {
1026 desktop.openLogin('signup');
1027 }
1028
1029 // ============ LIFECYCLE ============
1030
1031 onMount(() => {
1032 if (!browser) return;
1033
1034 isMobile = window.innerWidth <= 768;
1035 const handleResize = () => {
1036 isMobile = window.innerWidth <= 768;
1037 };
1038 window.addEventListener('resize', handleResize);
1039
1040 const onGlobalClick = () => closeUserMenu();
1041 const onGlobalKey = (e: KeyboardEvent) => {
1042 if (e.key === 'Escape') closeUserMenu();
1043 };
1044 window.addEventListener('click', onGlobalClick);
1045 window.addEventListener('keydown', onGlobalKey);
1046
1047 return () => {
1048 window.removeEventListener('resize', handleResize);
1049 window.removeEventListener('click', onGlobalClick);
1050 window.removeEventListener('keydown', onGlobalKey);
1051 if (cooldownInterval) clearInterval(cooldownInterval);
1052 // Clean up canvas context used for text measurement
1053 mentionMeasureContext = null;
1054 };
1055 });
1056
1057 // Scroll selected mention option into view when selection changes
1058 $effect(() => {
1059 if (!mentionOpen || mentionSuggestions.length === 0) return;
1060 // Track mentionSelectedIndex to trigger scroll on keyboard navigation
1061 void mentionSelectedIndex;
1062 void tick().then(() => scrollMentionOptionIntoView());
1063 });
1064</script>
1065
1066<div class="chat-room">
1067 {#if mentionUnreadCount > 0 || connectionError}
1068 <div class="room-status">
1069 {#if mentionUnreadCount > 0}
1070 <span class="room-mentions"
1071 >({mentionUnreadCount} mention{mentionUnreadCount > 1 ? 's' : ''})</span
1072 >
1073 {/if}
1074 {#if connectionError}
1075 <span class="connection-error">⚠️ {connectionError}</span>
1076 {/if}
1077 </div>
1078 {/if}
1079
1080 <div class="room-panel" bind:this={roomPanel}>
1081 <div class="chat-container">
1082 {#if rateLimitWarning}
1083 <div class="rate-limit-warning" class:with-progress={cooldownEndTime}>
1084 <span>{rateLimitWarning}</span>
1085 {#if cooldownEndTime}
1086 <small>Vous pourrez envoyer un autre message dans {cooldownProgress.toFixed(1)}s</small>
1087 {/if}
1088 </div>
1089 {/if}
1090
1091 <div
1092 class="sunken-panel chat-area"
1093 style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"
1094 onscroll={(e) => handleScroll(e)}
1095 bind:this={chatArea}
1096 >
1097 {#if isInitialLoading}
1098 <div class="initial-loading">
1099 <LoadingDots text="Chargement des messages" />
1100 </div>
1101 {:else if isLoadingMore}
1102 <div class="loading-messages">
1103 <LoadingDots text="Chargement" />
1104 </div>
1105 {/if}
1106 {#if showRegistrationPrompt}
1107 <div class="registration-prompt">
1108 <img src="/aim/xp-info-16.png" alt="" width="16" height="16" />
1109 <span>Vous devez être inscrit pour lire l'historique complet de ce salon.</span>
1110 <button type="button" onclick={openSignup}>S'inscrire</button>
1111 </div>
1112 {/if}
1113 {#each visibleMessages as message (message.id)}
1114 <div
1115 class="message {message.type} text"
1116 class:mention-highlight={hasMentionHighlight[message.id]}
1117 data-message-id={message.id}
1118 data-mention={hasMentionHighlight[message.id]
1119 ? `Mention de @${currentUser?.nickname ?? ''}`
1120 : undefined}
1121 aria-label={hasMentionHighlight[message.id]
1122 ? `Message mentionnant ${currentUser?.nickname ?? ''}`
1123 : undefined}
1124 use:registerMentionNode
1125 >
1126 {#if message.type === 'emote'}
1127 <span class="emote-text">
1128 <Tooltip
1129 data={{
1130 text: formatFrenchDateTime(new Date(message.timestamp)),
1131 direction: 'left',
1132 closeDelay: 1000,
1133 touchBehavior: 'remove',
1134 interactive: false
1135 }}
1136 >
1137 <span class="nickname pointer-events-none">{message.user.nickname}</span>
1138 </Tooltip>
1139 {message.content}
1140 </span>
1141 {:else}
1142 <Tooltip
1143 data={{
1144 text: formatFrenchDateTime(new Date(message.timestamp)),
1145 direction: 'left',
1146 closeDelay: 1000,
1147 touchBehavior: 'remove',
1148 interactive: false
1149 }}
1150 >
1151 <span class="nickname pointer-events-none">{message.user.nickname}:</span>
1152 </Tooltip>
1153 <span class="message-content">
1154 <FormattedMessage {message} allowFormatting={true} />
1155 </span>
1156 {/if}
1157 </div>
1158 {/each}
1159 </div>
1160
1161 <!-- Text Formatting Toolbar -->
1162 {#if currentUser}
1163 <div style="margin-bottom: 0.25rem;">
1164 <TextFormattingToolbar
1165 bind:style={currentTextStyle}
1166 compact={true}
1167 showFontSelector={true}
1168 onSmiley={insertSmiley}
1169 />
1170 </div>
1171 {/if}
1172
1173 <div class="field-row input-container" style="margin: 0; position: relative;">
1174 {#if currentTextStyle.gradient && currentTextStyle.gradient.length > 1}
1175 <div
1176 class="gradient-input-wrapper"
1177 style="flex: 1; position: relative; background: white; overflow: hidden;"
1178 >
1179 <!-- Gradient text overlay that syncs with input scroll -->
1180 <span
1181 class="gradient-text-overlay retro-font-{currentTextStyle.fontFamily}"
1182 style="
1183 position: absolute;
1184 left: 3px;
1185 top: 50%;
1186 transform: translateY(-50%) translateX(-{inputScrollLeft}px);
1187 pointer-events: none;
1188 white-space: nowrap;
1189 font-size: {currentTextStyle.fontSize}px;
1190 {currentTextStyle.bold
1191 ? currentTextStyle.fontFamily === 'tahoma'
1192 ? 'font-weight: 200;'
1193 : 'font-weight: 700;'
1194 : ''}
1195 {currentTextStyle.italic ? 'font-style: italic;' : ''}
1196 {currentTextStyle.underline || currentTextStyle.strikethrough
1197 ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1198 : ''}
1199 background: linear-gradient(to right, {currentTextStyle.gradient.join(', ')});
1200 -webkit-background-clip: text;
1201 -webkit-text-fill-color: transparent;
1202 background-clip: text;
1203 "
1204 aria-hidden="true"
1205 >
1206 <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedInputMessage is sanitized by highlightMentionsInput -->
1207 {@html formattedInputMessage}
1208 </span>
1209 <input
1210 type="text"
1211 bind:value={currentMessage}
1212 maxlength={MAX_MESSAGE_LENGTH}
1213 class="styled-input retro-font-{currentTextStyle.fontFamily}"
1214 style="width: 100%; background: transparent; color: transparent; caret-color: black; font-size: {currentTextStyle.fontSize}px; padding-left: 3px; {currentTextStyle.bold
1215 ? currentTextStyle.fontFamily === 'tahoma'
1216 ? 'font-weight: 200;'
1217 : 'font-weight: 700;'
1218 : ''} {currentTextStyle.italic
1219 ? 'font-style: italic;'
1220 : ''} {currentTextStyle.underline || currentTextStyle.strikethrough
1221 ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1222 : ''}"
1223 onkeydown={handleInputKeydown}
1224 onkeyup={handleInputKeyup}
1225 oninput={handleInputChange}
1226 onscroll={handleInputScroll}
1227 onfocus={handleInputFocus}
1228 onclick={handleInputClick}
1229 bind:this={messageInput}
1230 placeholder={!currentUser
1231 ? 'Inscris-toi pour participer.'
1232 : cooldownEndTime
1233 ? `Patientez ${cooldownProgress.toFixed(1)}s...`
1234 : 'Écrivez un message...'}
1235 disabled={!currentUser || Boolean(cooldownEndTime)}
1236 role="combobox"
1237 aria-autocomplete="list"
1238 aria-controls={mentionOpen ? mentionListBoxId : undefined}
1239 aria-expanded={mentionOpen}
1240 />
1241 </div>
1242 {:else}
1243 <div
1244 class="mention-input-wrapper"
1245 style="flex: 1; position: relative; background: white; overflow: hidden;"
1246 >
1247 <!-- Text overlay with mention highlighting -->
1248 <span
1249 class="mention-text-overlay retro-font-{currentTextStyle.fontFamily}"
1250 style="
1251 position: absolute;
1252 left: 3px;
1253 top: 50%;
1254 transform: translateY(-50%) translateX(-{inputScrollLeft}px);
1255 pointer-events: none;
1256 white-space: nowrap;
1257 font-size: {currentTextStyle.fontSize}px;
1258 color: {currentTextStyle.color || '#000000'};
1259 {currentTextStyle.bold
1260 ? currentTextStyle.fontFamily === 'tahoma'
1261 ? 'font-weight: 200;'
1262 : 'font-weight: 700;'
1263 : ''}
1264 {currentTextStyle.italic ? 'font-style: italic;' : ''}
1265 {currentTextStyle.underline || currentTextStyle.strikethrough
1266 ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1267 : ''}
1268 "
1269 aria-hidden="true"
1270 >
1271 <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedInputMessage is sanitized by highlightMentionsInput -->
1272 {@html formattedInputMessage}
1273 </span>
1274 <input
1275 type="text"
1276 bind:value={currentMessage}
1277 maxlength={MAX_MESSAGE_LENGTH}
1278 class="styled-input mention-overlay-input retro-font-{currentTextStyle.fontFamily}"
1279 style="width: 100%; background: transparent; color: transparent; font-size: {currentTextStyle.fontSize}px; padding-left: 3px; {currentTextStyle.bold
1280 ? currentTextStyle.fontFamily === 'tahoma'
1281 ? 'font-weight: 200;'
1282 : 'font-weight: 700;'
1283 : ''} {currentTextStyle.italic
1284 ? 'font-style: italic;'
1285 : ''} {currentTextStyle.underline || currentTextStyle.strikethrough
1286 ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1287 : ''} caret-color: {currentTextStyle.color || 'black'};"
1288 onkeydown={handleInputKeydown}
1289 onkeyup={handleInputKeyup}
1290 oninput={handleInputChange}
1291 onscroll={handleInputScroll}
1292 onfocus={handleInputFocus}
1293 onclick={handleInputClick}
1294 bind:this={messageInput}
1295 placeholder={!currentUser
1296 ? 'Inscris-toi pour participer.'
1297 : cooldownEndTime
1298 ? `Patientez ${cooldownProgress.toFixed(1)}s...`
1299 : 'Écrivez un message...'}
1300 disabled={!currentUser || Boolean(cooldownEndTime)}
1301 role="combobox"
1302 aria-autocomplete="list"
1303 aria-controls={mentionOpen ? mentionListBoxId : undefined}
1304 aria-expanded={mentionOpen}
1305 />
1306 </div>
1307 {/if}
1308 <LoadingButton
1309 onclick={handleSubmit}
1310 disabled={!currentUser || Boolean(cooldownEndTime)}
1311 loading={isSendingMessage}
1312 text={cooldownEndTime ? `${cooldownProgress.toFixed(1)}s` : 'Envoyer'}
1313 />
1314 {#if showCharCounter}
1315 <span class="char-counter {charCounterClass}">
1316 {currentMessage.length}/{MAX_MESSAGE_LENGTH}
1317 </span>
1318 {/if}
1319 </div>
1320 {#if mentionOpen}
1321 <MentionPicker
1322 suggestions={mentionSuggestions}
1323 selectedIndex={mentionSelectedIndex}
1324 activeId={mentionActiveId}
1325 listBoxId={mentionListBoxId}
1326 listStyle={mentionListStyle}
1327 isPortal={!mentionListInside}
1328 onSelect={applyMentionSelection}
1329 />
1330 {/if}
1331
1332 {#if cooldownEndTime}
1333 <div
1334 class="cooldown-progress"
1335 style="width: {100 - ((cooldownProgress * 100) / (cooldownEndTime - Date.now())) * 1000}%"
1336 ></div>
1337 {/if}
1338 </div>
1339
1340 <!-- People here (AIM chat room) -->
1341 <div class="people-panel" class:mobile={isMobile} class:hidden={isMobile && !showUserList}>
1342 <p class="people-count">
1343 {usersOnline.length}
1344 {usersOnline.length === 1 ? 'personne ici' : 'personnes ici'}
1345 </p>
1346 <div class="sunken-panel users-list" role="listbox" aria-label="Personnes dans le salon">
1347 {#each usersOnline as user (user.id)}
1348 <div
1349 class="user {user.status}"
1350 class:selected={selectedUserId === user.id}
1351 class:me={currentUser?.id === user.id}
1352 role="option"
1353 aria-selected={selectedUserId === user.id}
1354 tabindex="0"
1355 title={statusLabel(user)}
1356 onclick={(e) => {
1357 e.stopPropagation();
1358 selectedUserId = user.id;
1359 closeUserMenu();
1360 }}
1361 ondblclick={() => openImWith(user)}
1362 oncontextmenu={(e) => openUserMenu(e, user)}
1363 onkeydown={(e) => {
1364 if (e.key === 'Enter') openImWith(user);
1365 }}
1366 >
1367 <span class="nickname">{user.nickname}</span>
1368 </div>
1369 {/each}
1370 {#if usersOffline.length > 0}
1371 <div class="offline-separator" role="presentation">Hors ligne</div>
1372 {#each usersOffline as user (user.id)}
1373 <div
1374 class="user offline"
1375 class:selected={selectedUserId === user.id}
1376 role="option"
1377 aria-selected={selectedUserId === user.id}
1378 tabindex="0"
1379 title={statusLabel(user)}
1380 onclick={(e) => {
1381 e.stopPropagation();
1382 selectedUserId = user.id;
1383 closeUserMenu();
1384 }}
1385 ondblclick={() => openImWith(user)}
1386 oncontextmenu={(e) => openUserMenu(e, user)}
1387 onkeydown={(e) => {
1388 if (e.key === 'Enter') openImWith(user);
1389 }}
1390 >
1391 <span class="nickname">{user.nickname}</span>
1392 </div>
1393 {/each}
1394 {/if}
1395 </div>
1396 <div class="people-actions">
1397 <button
1398 type="button"
1399 disabled={!selectedUser || selectedUser.id === currentUser?.id}
1400 onclick={() => selectedUser && openImWith(selectedUser)}>IM</button
1401 >
1402 <button
1403 type="button"
1404 disabled={!selectedUser}
1405 onclick={() =>
1406 selectedUser &&
1407 desktop.openProfile({
1408 userId: selectedUser.id as Id<'users'>,
1409 nickname: selectedUser.nickname
1410 })}>Infos</button
1411 >
1412 <button
1413 type="button"
1414 disabled={!selectedUser || selectedUser.id === currentUser?.id}
1415 onclick={() =>
1416 selectedUser && void aim.addBuddy(selectedUser.id as Id<'users'>).catch(console.error)}
1417 >Ajouter</button
1418 >
1419 </div>
1420 </div>
1421
1422 {#if userMenu}
1423 <div
1424 class="user-menu"
1425 style="left: {userMenu.x}px; top: {userMenu.y}px;"
1426 role="menu"
1427 tabindex="-1"
1428 onclick={(e) => e.stopPropagation()}
1429 onkeydown={(e) => {
1430 if (e.key === 'Escape') closeUserMenu();
1431 }}
1432 >
1433 <div class="user-menu-title">{userMenu.user.nickname}</div>
1434 {#if currentUser && userMenu.user.id !== currentUser.id}
1435 <button
1436 type="button"
1437 role="menuitem"
1438 onclick={() => {
1439 const u = userMenu?.user;
1440 closeUserMenu();
1441 if (u) void openImWith(u);
1442 }}>Envoyer un message instantané</button
1443 >
1444 <button
1445 type="button"
1446 role="menuitem"
1447 onclick={() => {
1448 const u = userMenu?.user;
1449 closeUserMenu();
1450 if (u) void aim.addBuddy(u.id as Id<'users'>).catch(console.error);
1451 }}>Ajouter à ma liste de contacts</button
1452 >
1453 {/if}
1454 <button
1455 type="button"
1456 role="menuitem"
1457 onclick={() => {
1458 const u = userMenu?.user;
1459 closeUserMenu();
1460 if (u) desktop.openProfile({ userId: u.id as Id<'users'>, nickname: u.nickname });
1461 }}>Infos sur {userMenu.user.nickname}</button
1462 >
1463 </div>
1464 {/if}
1465 </div>
1466</div>
1467
1468<style>
1469 .chat-room {
1470 flex: 1 1 auto;
1471 min-height: 0;
1472 display: flex;
1473 flex-direction: column;
1474 }
1475
1476 .chat-area,
1477 .users-list {
1478 font-size: 1rem;
1479 font-family: Arial, 'Pixelated MS Sans Serif', Verdana, Tahoma, sans-serif;
1480 }
1481
1482 input:not(.styled-input) {
1483 font-size: 1rem;
1484 font-family: 'Pixelated MS Sans Serif', Arial, Verdana, Tahoma, sans-serif;
1485 }
1486
1487 /* ===== Layout inside the XP window ===== */
1488 .room-status {
1489 display: flex;
1490 justify-content: flex-end;
1491 gap: 6px;
1492 font-size: 11px;
1493 margin-bottom: 3px;
1494 }
1495
1496 .room-mentions {
1497 margin-left: auto;
1498 font-weight: bold;
1499 color: #c00000;
1500 white-space: nowrap;
1501 }
1502
1503 .room-panel {
1504 position: relative;
1505 flex: 1 1 auto;
1506 min-height: 0;
1507 display: flex;
1508 gap: 6px;
1509 }
1510
1511 .chat-container {
1512 flex: 1 1 auto;
1513 min-width: 0;
1514 display: flex;
1515 flex-direction: column;
1516 }
1517
1518 .people-panel {
1519 width: 9.5rem;
1520 flex: 0 0 auto;
1521 display: flex;
1522 flex-direction: column;
1523 min-height: 0;
1524 }
1525
1526 .people-count {
1527 margin: 0 0 3px 0;
1528 font-weight: bold;
1529 font-size: 0.8rem;
1530 white-space: nowrap;
1531 }
1532
1533 .users-list {
1534 flex: 1 1 auto;
1535 min-height: 0;
1536 padding: 2px;
1537 overflow-y: auto;
1538 font-size: 0.85rem;
1539 }
1540
1541 .people-actions {
1542 display: flex;
1543 gap: 3px;
1544 margin-top: 4px;
1545 }
1546
1547 .people-actions button {
1548 flex: 1 1 0;
1549 min-width: 0;
1550 padding: 0 4px;
1551 font-size: 0.75rem;
1552 }
1553
1554 .user {
1555 display: flex;
1556 align-items: center;
1557 padding: 1px 3px;
1558 cursor: default;
1559 border: 1px dotted transparent;
1560 user-select: none;
1561 overflow: hidden;
1562 }
1563
1564 .offline-separator {
1565 margin: 4px 2px 2px;
1566 padding-top: 3px;
1567 border-top: 1px solid #d4d0c8;
1568 color: #808080;
1569 font-size: 0.7rem;
1570 user-select: none;
1571 }
1572
1573 .user.offline .nickname {
1574 color: #808080;
1575 font-style: italic;
1576 }
1577
1578 .user .nickname {
1579 overflow: hidden;
1580 text-overflow: ellipsis;
1581 white-space: nowrap;
1582 }
1583
1584 .user.selected {
1585 background: #316ac5;
1586 color: #fff;
1587 border-color: #fff;
1588 }
1589
1590 .user.me .nickname {
1591 font-weight: bold;
1592 }
1593
1594 .user.away .nickname,
1595 .user.idle .nickname {
1596 color: #777;
1597 font-style: italic;
1598 }
1599
1600 .user.selected .nickname {
1601 color: #fff;
1602 }
1603
1604 .user-menu {
1605 position: absolute;
1606 z-index: 50;
1607 min-width: 200px;
1608 background: #fff;
1609 border: 1px solid #aca899;
1610 box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
1611 padding: 2px;
1612 font-family: Tahoma, 'Pixelated MS Sans Serif', sans-serif;
1613 font-size: 11px;
1614 display: flex;
1615 flex-direction: column;
1616 }
1617
1618 .user-menu-title {
1619 font-weight: bold;
1620 padding: 3px 20px;
1621 border-bottom: 1px solid #aca899;
1622 margin-bottom: 2px;
1623 color: #444;
1624 }
1625
1626 .user-menu button {
1627 text-align: left;
1628 background: transparent;
1629 border: none;
1630 box-shadow: none;
1631 border-radius: 0;
1632 padding: 3px 20px;
1633 min-width: 0;
1634 font-size: 11px;
1635 }
1636
1637 .user-menu button:hover {
1638 background: #316ac5;
1639 color: #fff;
1640 }
1641
1642 .chat-area {
1643 line-height: 1.4;
1644 }
1645
1646 .message {
1647 margin-bottom: 0.25rem;
1648 word-break: break-word;
1649 line-height: 1.4;
1650 }
1651
1652 .message :global(.gradient-text-static) {
1653 display: inline;
1654 vertical-align: baseline;
1655 line-height: inherit;
1656 }
1657
1658 .message :global(.gradient-char) {
1659 display: inline;
1660 vertical-align: baseline;
1661 line-height: inherit;
1662 }
1663
1664 .message .nickname {
1665 display: inline;
1666 font-weight: bold;
1667 color: #2d31a6;
1668 cursor: help;
1669 margin-right: 0.25rem;
1670 vertical-align: baseline;
1671 }
1672
1673 .message-content {
1674 display: inline;
1675 vertical-align: baseline;
1676 }
1677
1678 .message-content :global(.formatted-message),
1679 .message-content :global(.gradient-text-static) {
1680 display: inline;
1681 vertical-align: baseline;
1682 }
1683
1684 .message.emote {
1685 color: #666;
1686 font-style: italic;
1687 }
1688
1689 .message.emote .nickname {
1690 color: #666;
1691 }
1692
1693 .message-content :global(.mention-token) {
1694 color: inherit;
1695 font-weight: bold;
1696 }
1697
1698 /* Input mention highlighting */
1699 .mention-input-wrapper,
1700 .gradient-input-wrapper {
1701 flex: 1;
1702 position: relative;
1703 background: white;
1704 overflow: hidden;
1705 }
1706
1707 .mention-text-overlay,
1708 .gradient-text-overlay {
1709 position: absolute;
1710 left: 3px;
1711 top: 50%;
1712 pointer-events: none;
1713 white-space: nowrap;
1714 }
1715
1716 .mention-text-overlay :global(.input-mention),
1717 .gradient-text-overlay :global(.input-mention) {
1718 color: #1e2a72;
1719 -webkit-text-fill-color: #1e2a72;
1720 }
1721
1722 /* Force input text to be transparent when using overlay */
1723 .mention-overlay-input {
1724 color: transparent !important;
1725 -webkit-text-fill-color: transparent !important;
1726 }
1727
1728 .sunken-panel {
1729 background: white;
1730 border: 0.125rem inset #dfdfdf;
1731 }
1732
1733 input[type='text'] {
1734 font-size: 1rem;
1735 margin-right: 0.25rem;
1736 }
1737
1738 .styled-input {
1739 font-style: inherit;
1740 }
1741
1742 .input-container :global(> button) {
1743 height: 21px;
1744 min-height: 21px;
1745 min-width: 0;
1746 padding: 0 10px;
1747 margin-left: 4px;
1748 font-size: 11px;
1749 white-space: nowrap;
1750 }
1751
1752 .chat-container {
1753 position: relative;
1754 z-index: 1;
1755 }
1756
1757 .input-container {
1758 position: relative;
1759 z-index: 2;
1760 }
1761
1762 .input-container input:disabled {
1763 opacity: 0.7;
1764 background-color: rgba(128, 128, 128, 0.3) !important;
1765 cursor: not-allowed;
1766 }
1767
1768 /* Fade input wrapper when disabled */
1769 .input-container:has(input:disabled) .gradient-input-wrapper,
1770 .input-container:has(input:disabled) .mention-input-wrapper {
1771 opacity: 0.6;
1772 background-color: #f0f0f0 !important;
1773 }
1774
1775 .hidden {
1776 display: none !important;
1777 }
1778
1779 @media (max-width: 768px) {
1780 .room-panel {
1781 flex-direction: column;
1782 }
1783
1784 .people-panel.mobile {
1785 width: 100%;
1786 max-height: 30vh;
1787 }
1788 }
1789
1790 .relative-time {
1791 color: #666;
1792 font-size: 0.75rem;
1793 margin-right: 0.5rem;
1794 font-style: italic;
1795 }
1796
1797 .cooldown-progress {
1798 position: absolute;
1799 bottom: 0;
1800 left: 0;
1801 height: 2px;
1802 background: #2d31a6;
1803 transition: width 0.1s linear;
1804 }
1805
1806 .input-container {
1807 position: relative;
1808 }
1809
1810 .char-counter {
1811 font-size: 0.75rem;
1812 padding: 0 0.25rem;
1813 margin-left: 0.25rem;
1814 white-space: nowrap;
1815 font-family: 'Pixelated MS Sans Serif', Tahoma, sans-serif;
1816 }
1817
1818 .char-counter.warning {
1819 color: var(--color-muted-foreground);
1820 }
1821
1822 .char-counter.near-limit {
1823 color: var(--color-warning);
1824 font-weight: bold;
1825 }
1826
1827 .char-counter.at-limit {
1828 color: var(--color-destructive);
1829 font-weight: bold;
1830 }
1831
1832 .connection-error {
1833 font-size: 0.75rem;
1834 color: #ff4444;
1835 margin-left: 0.5rem;
1836 }
1837
1838 .error-banner {
1839 position: absolute;
1840 top: 2rem;
1841 left: 0;
1842 right: 0;
1843 background: #ffebee;
1844 color: #c62828;
1845 padding: 0.5rem;
1846 text-align: center;
1847 z-index: 100;
1848 border-bottom: 1px solid #ffcdd2;
1849 font-size: 0.875rem;
1850 }
1851
1852 .rate-limit-warning {
1853 background: #fff3e0;
1854 color: #e65100;
1855 padding: 0.5rem;
1856 margin-bottom: 0.5rem;
1857 border-radius: 4px;
1858 font-size: 0.875rem;
1859 display: flex;
1860 flex-direction: column;
1861 gap: 0.25rem;
1862 animation: slideIn 0.3s ease-out;
1863 border: 1px solid #ffe0b2;
1864 }
1865
1866 .rate-limit-warning.with-progress {
1867 border-left: 4px solid #e65100;
1868 }
1869
1870 .rate-limit-warning small {
1871 color: #666;
1872 font-size: 0.75rem;
1873 }
1874
1875 @keyframes slideIn {
1876 from {
1877 transform: translateY(-100%);
1878 opacity: 0;
1879 }
1880 to {
1881 transform: translateY(0);
1882 opacity: 1;
1883 }
1884 }
1885
1886 .message.mention-highlight {
1887 background: rgba(255, 237, 186, 0.45);
1888 border-radius: 4px;
1889 padding: 0.2rem 0.35rem;
1890 box-shadow: inset 0 0 0 1px rgba(178, 136, 0, 0.2);
1891 }
1892
1893 .cooldown-progress {
1894 position: absolute;
1895 bottom: 0;
1896 left: 0;
1897 height: 2px;
1898 background: #e65100;
1899 transition: width 0.1s linear;
1900 }
1901
1902 .initial-loading {
1903 display: flex;
1904 justify-content: center;
1905 align-items: center;
1906 height: 100%;
1907 font-size: 1.1rem;
1908 color: #666;
1909 }
1910
1911 .loading-messages {
1912 text-align: center;
1913 padding: 1rem;
1914 color: #666;
1915 font-style: italic;
1916 background: rgba(0, 0, 0, 0.05);
1917 margin-bottom: 1rem;
1918 border-radius: 4px;
1919 }
1920
1921 .registration-prompt {
1922 display: flex;
1923 align-items: center;
1924 gap: 8px;
1925 background: #ece9d8;
1926 color: #000;
1927 padding: 6px 8px;
1928 margin-bottom: 6px;
1929 border: 1px solid #aca899;
1930 font-size: 0.85rem;
1931 }
1932
1933 .registration-prompt span {
1934 flex: 1;
1935 }
1936
1937 .registration-prompt button {
1938 min-width: 0;
1939 padding: 0 10px;
1940 white-space: nowrap;
1941 }
1942
1943 .chat-window.minimized {
1944 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
1945 }
1946
1947 .minimized :global(.resize-handle) {
1948 display: none;
1949 }
1950
1951 .title-bar-text {
1952 flex: 1;
1953 min-width: 0;
1954 padding-right: 0.5rem;
1955 }
1956
1957 .title-bar-controls {
1958 display: flex;
1959 gap: 2px;
1960 margin-left: auto;
1961 }
1962
1963 .pointer-events-none {
1964 pointer-events: none;
1965 }
1966
1967 .gradient-input-wrapper {
1968 border: 1px inset #dfdfdf;
1969 padding: 2px;
1970 }
1971
1972 .gradient-input-wrapper input {
1973 border: none !important;
1974 outline: none !important;
1975 padding: 1px 3px;
1976 }
1977</style>