main f6d8405

chat-room.svelte RawHistoryFolder

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, '&amp;')
328			.replace(/</g, '&lt;')
329			.replace(/>/g, '&gt;');
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>

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close