detached 3f6429f

feat(chat): add @mention autocomplete with highlighting and unread tracking

- Add mention picker with keyboard navigation (Arrow keys, Enter, Tab, Escape)
- Highlight @mentions in messages with blue/bold styling
- Track unread mentions per room with IntersectionObserver
- Show mention count in title bar and browser tab
- Add input overlay to highlight mentions while typing
- Fix font dropdown overflow using shortName property
- Add type safety to aim-login props and activeTab state
commit
3f6429f91ac8494c6c6247824f60112911948e9d
author
Kit · · 2026-01-18 20:39 UTC
committer
Kit · · 2026-01-19 13:33 UTC
parent
6aaf2c1
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
7 files · +783 -40
M src/lib/components/aim-login.svelte +16 -4
@@ -15,9 +15,21 @@
1515 } from '../validation/password';
1616 import { z } from 'zod';
1717
18- let { showAuth = $bindable(), onLoginSuccess, activeTab: initialTab = 'signin' } = $props();
19-
20- let activeTab = $state(initialTab);
18+ let {
19+ showAuth = $bindable(),
20+ onLoginSuccess,
21+ activeTab: initialTab = 'signin'
22+ } = $props<{
23+ showAuth?: boolean;
24+ onLoginSuccess?: () => void;
25+ activeTab?: 'signin' | 'signup';
26+ }>();
27+
28+ let activeTab = $state<'signin' | 'signup'>('signin');
29+
30+ $effect(() => {
31+ activeTab = initialTab;
32+ });
2133
2234 // Sign In state
2335 let siUsername = $state('');
@@ -229,7 +241,7 @@
229241 }
230242 }
231243
232- function switchTab(tab: string) {
244+ function switchTab(tab: 'signin' | 'signup') {
233245 activeTab = tab;
234246 error = '';
235247 signupError = '';
@@ -15,9 +15,21 @@
15 } from '../validation/password';15 } from '../validation/password';
16 import { z } from 'zod';16 import { z } from 'zod';
17 17
18- let { showAuth = $bindable(), onLoginSuccess, activeTab: initialTab = 'signin' } = $props();18+ let {
19-19+ showAuth = $bindable(),
20- let activeTab = $state(initialTab);20+ onLoginSuccess,
21+ activeTab: initialTab = 'signin'
22+ } = $props<{
23+ showAuth?: boolean;
24+ onLoginSuccess?: () => void;
25+ activeTab?: 'signin' | 'signup';
26+ }>();
27+
28+ let activeTab = $state<'signin' | 'signup'>('signin');
29+
30+ $effect(() => {
31+ activeTab = initialTab;
32+ });
21 33
22 // Sign In state34 // Sign In state
23 let siUsername = $state('');35 let siUsername = $state('');
@@ -229,7 +241,7 @@
229 }241 }
230 }242 }
231 243
232- function switchTab(tab: string) {244+ function switchTab(tab: 'signin' | 'signup') {
233 activeTab = tab;245 activeTab = tab;
234 error = '';246 error = '';
235 signupError = '';247 signupError = '';
M src/lib/components/chat-room.svelte +681 -28
@@ -4,7 +4,7 @@
44 import { api } from '../../convex/_generated/api';
55 import type { Id } from '../../convex/_generated/dataModel';
66 import type { Message, EnrichedMessage, SafeUser } from '../types/chat';
7- import { onMount } from 'svelte';
7+ import { onMount, tick } from 'svelte';
88 import { browser } from '$app/environment';
99 import { draggable } from '$lib/actions/draggable';
1010 import { resizable } from '$lib/actions/resizable';
@@ -146,11 +146,41 @@
146146
147147 let currentMessage = $state('');
148148 let inputScrollLeft = $state(0);
149- let currentTextStyle = $state<TextStyle>({
150- ...DEFAULT_TEXT_STYLE,
151- ...initialTextStyle,
152- color: initialTextStyle.color || '#000000'
153- });
149+ // Initialize text style with prop value (intentionally capturing initial value only)
150+ let currentTextStyle = $state<TextStyle>(
151+ (() => {
152+ const style = { ...initialTextStyle };
153+ return {
154+ ...DEFAULT_TEXT_STYLE,
155+ ...style,
156+ color: style.color || '#000000'
157+ };
158+ })()
159+ );
160+
161+ // ============ MENTION STATE ============
162+
163+ const USERNAME_PATTERN = /^[a-zA-Z0-9_-]{1,32}$/;
164+ const MAX_MENTION_SUGGESTIONS = 6;
165+ let messageInput = $state<HTMLInputElement | null>(null);
166+ let mentionOpen = $state(false);
167+ let mentionQuery = $state('');
168+ let mentionAnchorIndex = $state<number | null>(null);
169+ let mentionSelectedIndex = $state(0);
170+ let mentionObservedIds = new Set<string>();
171+ let mentionMessageIds = $state<Set<string>>(new Set());
172+ let mentionUnreadCount = $state(0);
173+ let mentionDismissKey = $state<string | null>(null);
174+ let mentionObserver: IntersectionObserver | null = null;
175+
176+ let baseTitle = $state('');
177+ const mentionListBoxId = `mentions-${Math.random().toString(36).slice(2, 8)}`;
178+ let mentionActiveId = $state<string | null>(null);
179+ let chatArea = $state<HTMLDivElement | null>(null);
180+ let mentionListContainer = $state<HTMLDivElement | null>(null);
181+ let mentionListStyle = $state('');
182+ let mentionListInside = $state(true);
183+ let mentionMeasureContext: CanvasRenderingContext2D | null = null;
154184
155185 // ============ RATE LIMITING STATE ============
156186
@@ -198,6 +228,46 @@
198228 const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline'));
199229 const usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline'));
200230
231+ const mentionableUsers = $derived(
232+ onlineUsers.filter((user) => !currentUser || user.id !== currentUser.id)
233+ );
234+
235+ const mentionSuggestions = $derived.by<SafeUser[]>(() => {
236+ if (!mentionOpen) return [];
237+ const query = mentionQuery.trim().toLowerCase();
238+ const candidates = query
239+ ? mentionableUsers.filter((user) => user.nickname.toLowerCase().startsWith(query))
240+ : mentionableUsers;
241+ return candidates.slice(0, MAX_MENTION_SUGGESTIONS);
242+ });
243+
244+ const mentionListMaxHeight = MAX_MENTION_SUGGESTIONS * 32 + 12;
245+
246+ // Format input message with mention highlighting (for overlay display)
247+ const formattedInputMessage = $derived.by(() => {
248+ if (!currentMessage) return '';
249+ // Escape HTML entities first
250+ const escaped = currentMessage
251+ .replace(/&/g, '&amp;')
252+ .replace(/</g, '&lt;')
253+ .replace(/>/g, '&gt;');
254+ // Apply mention styling
255+ return escaped.replace(
256+ /(^|\s)@([a-zA-Z0-9_-]{1,32})(?=$|\s|[^a-zA-Z0-9_-])/g,
257+ '$1<span class="input-mention">@$2</span>'
258+ );
259+ });
260+
261+ // mentionUnreadCount is managed in state updates
262+ const hasMentionHighlight = $derived.by<Record<string, boolean>>(() => {
263+ const highlights: Record<string, boolean> = {};
264+ if (!currentUser) return highlights;
265+ for (const message of visibleMessages) {
266+ highlights[message.id] = shouldHighlightMention(message);
267+ }
268+ return highlights;
269+ });
270+
201271 // ============ EFFECTS ============
202272
203273 // Auto-scroll to bottom when new messages arrive
@@ -222,6 +292,173 @@
222292 }
223293 });
224294
295+ $effect(() => {
296+ if (!browser) return;
297+ if (!baseTitle) baseTitle = document.title;
298+ const totalMentions = chatState.getMentionTotal();
299+ if (totalMentions > 0) {
300+ document.title = `(${totalMentions}) ${baseTitle}`;
301+ } else {
302+ document.title = baseTitle;
303+ }
304+ });
305+
306+ $effect(() => {
307+ chatState.setRoomMentionCount(roomId ?? null, mentionUnreadCount);
308+ });
309+
310+ $effect(() => {
311+ if (!browser || !currentUser) {
312+ mentionObservedIds = new Set();
313+ mentionMessageIds = new Set();
314+ if (mentionUnreadCount !== 0) {
315+ mentionUnreadCount = 0;
316+ }
317+ // mention observer reset
318+ if (mentionObserver) {
319+ mentionObserver.disconnect();
320+ mentionObserver = null;
321+ }
322+ return;
323+ }
324+ const mentionIds = new Set<string>();
325+ for (const message of visibleMessages) {
326+ if (shouldHighlightMention(message)) {
327+ mentionIds.add(message.id);
328+ }
329+ }
330+ const nextMentionIds = mentionIds;
331+ if (!setsEqual(nextMentionIds, mentionMessageIds)) {
332+ mentionMessageIds = nextMentionIds;
333+ }
334+ const nextUnreadCount = Array.from(nextMentionIds).filter(
335+ (messageId) => !mentionObservedIds.has(messageId)
336+ ).length;
337+ // mentionObservedIds is a local Set to avoid effect loops
338+ if (mentionUnreadCount !== nextUnreadCount) {
339+ mentionUnreadCount = nextUnreadCount;
340+ }
341+ });
342+
343+ $effect(() => {
344+ const currentMentionIds = mentionMessageIds;
345+ if (!browser || !chatArea || !currentUser) return;
346+ if (mentionObserver) {
347+ mentionObserver.disconnect();
348+ }
349+ mentionObserver = new IntersectionObserver(
350+ (entries) => {
351+ const updatedObserved = new Set(mentionObservedIds);
352+ let changed = false;
353+ const currentObserved = mentionObservedIds;
354+ const currentUnread = mentionUnreadCount;
355+ for (const entry of entries) {
356+ const target = entry.target as HTMLElement;
357+ const messageId = target.dataset.messageId;
358+ if (!messageId || !currentMentionIds.has(messageId)) continue;
359+ if (entry.isIntersecting && !updatedObserved.has(messageId)) {
360+ updatedObserved.add(messageId);
361+ changed = true;
362+ }
363+ }
364+ if (changed && !setsEqual(updatedObserved, currentObserved)) {
365+ mentionObservedIds = updatedObserved;
366+ const nextUnread = Math.max(0, currentMentionIds.size - updatedObserved.size);
367+ if (currentUnread !== nextUnread) {
368+ mentionUnreadCount = nextUnread;
369+ }
370+ }
371+ },
372+ { root: chatArea, threshold: 0.6 }
373+ );
374+ const nodes = Array.from(chatArea.querySelectorAll<HTMLElement>('[data-message-id]'));
375+ for (const node of nodes) {
376+ const messageId = node.dataset.messageId;
377+ if (messageId && currentMentionIds.has(messageId)) {
378+ mentionObserver.observe(node);
379+ }
380+ }
381+ // only observe mention messages
382+ // mention observer reset
383+ return () => {
384+ mentionObserver?.disconnect();
385+ };
386+ });
387+
388+ $effect(() => {
389+ if (!mentionOpen || mentionSuggestions.length === 0) {
390+ if (mentionActiveId !== null) {
391+ mentionActiveId = null;
392+ }
393+ return;
394+ }
395+ const currentActiveId = mentionActiveId;
396+ let selectedIndex = mentionSelectedIndex;
397+ if (selectedIndex >= mentionSuggestions.length) {
398+ selectedIndex = 0;
399+ if (mentionSelectedIndex !== 0) {
400+ mentionSelectedIndex = 0;
401+ }
402+ }
403+ const active = mentionSuggestions[selectedIndex];
404+ const nextActiveId = active ? getMentionListItemId(active.id) : null;
405+ if (currentActiveId !== nextActiveId) {
406+ mentionActiveId = nextActiveId;
407+ }
408+ });
409+
410+ $effect(() => {
411+ if (!mentionOpen || !messageInput) {
412+ if (mentionListStyle) mentionListStyle = '';
413+ return;
414+ }
415+ const currentListStyle = mentionListStyle;
416+ const currentInside = mentionListInside;
417+ const inputRect = messageInput.getBoundingClientRect();
418+ const containerRect = messageInput.closest('.chat-container')?.getBoundingClientRect();
419+ // Use actual height: single row for empty state, otherwise based on suggestion count
420+ const listHeight = mentionSuggestions.length
421+ ? Math.min(mentionSuggestions.length, MAX_MENTION_SUGGESTIONS) * 32 + 12
422+ : 44; // Single "Aucun utilisateur" row height
423+ const spaceBelow = containerRect
424+ ? containerRect.bottom - inputRect.bottom
425+ : window.innerHeight - inputRect.bottom;
426+ const spaceAbove = containerRect ? inputRect.top - containerRect.top : inputRect.top;
427+ const showAbove = spaceBelow < listHeight && spaceAbove > listHeight;
428+ const maxNameWidth = mentionSuggestions.reduce((max, user) => {
429+ const width = measureMentionText(user.nickname, true, 13.6);
430+ return Math.max(max, width);
431+ }, 0);
432+ // Status uses 0.7rem (11.2px) font size and uppercase (add ~10% for letter-spacing)
433+ const statusWidth = measureMentionText('OFFLINE', false, 11.2) * 1.1;
434+ const paddingWidth = 68;
435+ const listWidth = Math.max(220, Math.ceil(maxNameWidth + statusWidth + paddingWidth));
436+ if (containerRect) {
437+ if (!currentInside) mentionListInside = true;
438+ const top = showAbove
439+ ? inputRect.top - containerRect.top - listHeight - 6
440+ : inputRect.bottom - containerRect.top + 6;
441+ const left = inputRect.left - containerRect.left;
442+ const nextStyle = `top: ${Math.max(8, top)}px; left: ${Math.max(8, left)}px; width: ${listWidth}px;`;
443+ if (currentListStyle !== nextStyle) {
444+ mentionListStyle = nextStyle;
445+ }
446+ } else {
447+ if (currentInside) mentionListInside = false;
448+ const top = showAbove ? inputRect.top - listHeight - 6 : inputRect.bottom + 6;
449+ const nextStyle = `top: ${Math.max(8, top)}px; left: ${Math.max(8, inputRect.left)}px; width: ${listWidth}px;`;
450+ if (currentListStyle !== nextStyle) {
451+ mentionListStyle = nextStyle;
452+ }
453+ }
454+ if (mentionSuggestions.length === 0 && mentionActiveId !== null) {
455+ mentionActiveId = null;
456+ }
457+ if (mentionSelectedIndex >= mentionSuggestions.length && mentionSuggestions.length > 0) {
458+ mentionSelectedIndex = 0;
459+ }
460+ });
461+
225462 // ============ HELPERS ============
226463
227464 function resolveUser(senderId: string): SafeUser {
@@ -253,6 +490,156 @@
253490
254491 const debouncedSaveWindowState = debounce(saveWindowState, 300);
255492
493+ function isWordStart(value: string, index: number) {
494+ if (index <= 0) return true;
495+ return /\s/.test(value[index - 1]);
496+ }
497+
498+ function setsEqual(a: Set<string>, b: Set<string>) {
499+ if (a.size !== b.size) return false;
500+ for (const value of a) {
501+ if (!b.has(value)) return false;
502+ }
503+ return true;
504+ }
505+
506+ function measureMentionText(text: string, bold: boolean = true, fontSizePx: number = 13.6) {
507+ if (!mentionMeasureContext) {
508+ const canvas = document.createElement('canvas');
509+ mentionMeasureContext = canvas.getContext('2d');
510+ }
511+ if (!mentionMeasureContext) return 0;
512+ mentionMeasureContext.font = `${bold ? 'bold ' : ''}${fontSizePx}px 'Pixelated MS Sans Serif', Tahoma, sans-serif`;
513+ return mentionMeasureContext.measureText(text).width;
514+ }
515+
516+ function findMentionContext(value: string, cursorIndex: number) {
517+ const beforeCursor = value.slice(0, cursorIndex);
518+ const atIndex = beforeCursor.lastIndexOf('@');
519+ if (atIndex === -1) return null;
520+ if (!isWordStart(value, atIndex)) return null;
521+ const query = value.slice(atIndex + 1, cursorIndex);
522+ if (query.includes(' ') || query.includes('\n') || query.includes('\t')) return null;
523+ if (query.length > 32) return null;
524+ if (query && !USERNAME_PATTERN.test(query)) return null;
525+ return { atIndex, query };
526+ }
527+
528+ function updateMentionState(input: HTMLInputElement) {
529+ if (!currentUser) {
530+ mentionOpen = false;
531+ return;
532+ }
533+ const cursorIndex = input.selectionStart ?? input.value.length;
534+ const context = findMentionContext(input.value, cursorIndex);
535+ if (!context) {
536+ closeMentionPicker();
537+ return;
538+ }
539+ const mentionKey = `${context.atIndex}:${context.query}`;
540+ if (mentionDismissKey === mentionKey) {
541+ return;
542+ }
543+ const wasOpen = mentionOpen;
544+ const previousQuery = mentionQuery;
545+ const sameAnchor = mentionAnchorIndex === context.atIndex;
546+ mentionOpen = true;
547+ mentionQuery = context.query;
548+ mentionAnchorIndex = context.atIndex;
549+ mentionDismissKey = null;
550+ if (!wasOpen || !sameAnchor || previousQuery !== context.query) {
551+ mentionSelectedIndex = 0;
552+ }
553+ }
554+
555+ function registerMentionNode(node: HTMLElement) {
556+ if (!node) return;
557+ const messageId = node.dataset.messageId;
558+ if (!messageId) return;
559+ if (mentionObserver) {
560+ mentionObserver.observe(node);
561+ return;
562+ }
563+ // observer will scan after setup
564+ }
565+
566+ function closeMentionPicker() {
567+ mentionOpen = false;
568+ mentionQuery = '';
569+ mentionAnchorIndex = null;
570+ mentionSelectedIndex = 0;
571+ mentionActiveId = null;
572+ mentionDismissKey = null;
573+ if (messageInput) {
574+ messageInput.focus();
575+ }
576+ }
577+
578+ function clampMentionIndex(nextIndex: number) {
579+ if (mentionSuggestions.length === 0) return 0;
580+ const maxIndex = mentionSuggestions.length - 1;
581+ if (nextIndex < 0) return maxIndex;
582+ if (nextIndex > maxIndex) return 0;
583+ return nextIndex;
584+ }
585+
586+ function applyMentionSelection(user: SafeUser) {
587+ if (!messageInput || mentionAnchorIndex === null) return;
588+ const selectionStart = messageInput.selectionStart ?? currentMessage.length;
589+ const before = currentMessage.slice(0, mentionAnchorIndex);
590+ const after = currentMessage.slice(selectionStart);
591+ const mentionText = `@${user.nickname} `;
592+ currentMessage = `${before}${mentionText}${after}`;
593+ void tick().then(() => {
594+ if (!messageInput) return;
595+ const cursor = before.length + mentionText.length;
596+ messageInput.focus();
597+ messageInput.setSelectionRange(cursor, cursor);
598+ });
599+ closeMentionPicker();
600+ }
601+
602+ function handleMentionKeydown(event: KeyboardEvent) {
603+ if (!mentionOpen || mentionSuggestions.length === 0) return;
604+ if (event.key === 'ArrowDown') {
605+ event.preventDefault();
606+ mentionSelectedIndex = clampMentionIndex(mentionSelectedIndex + 1);
607+ void tick().then(() => scrollMentionOptionIntoView());
608+ return;
609+ }
610+ if (event.key === 'ArrowUp') {
611+ event.preventDefault();
612+ mentionSelectedIndex = clampMentionIndex(mentionSelectedIndex - 1);
613+ void tick().then(() => scrollMentionOptionIntoView());
614+ return;
615+ }
616+ if (event.key === 'Enter' || event.key === 'Tab') {
617+ event.preventDefault();
618+ const choice = mentionSuggestions[mentionSelectedIndex];
619+ if (choice) applyMentionSelection(choice);
620+ return;
621+ }
622+ if (event.key === 'Escape') {
623+ event.preventDefault();
624+ if (mentionOpen && mentionAnchorIndex !== null) {
625+ mentionDismissKey = `${mentionAnchorIndex}:${mentionQuery}`;
626+ }
627+ closeMentionPicker();
628+ }
629+ }
630+
631+ function getMentionListItemId(userId: string) {
632+ return `${mentionListBoxId}-${userId}`;
633+ }
634+
635+ function scrollMentionOptionIntoView() {
636+ if (!mentionActiveId) return;
637+ const option =
638+ document.getElementById(mentionActiveId) ??
639+ mentionListContainer?.querySelector(`[id='${mentionActiveId}']`);
640+ option?.scrollIntoView({ block: 'nearest' });
641+ }
642+
256643 function updateCooldownProgress() {
257644 if (!cooldownEndTime) return;
258645 const now = Date.now();
@@ -282,6 +669,14 @@
282669 showChatRoom = false;
283670 }
284671
672+ function shouldHighlightMention(message: EnrichedMessage) {
673+ if (!currentUser) return false;
674+ if (message.senderId === currentUser.id) return false;
675+ const escapedNickname = currentUser.nickname.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
676+ const mentionRegex = new RegExp(`(?:^|\\s)@(${escapedNickname})(?=$|\\s|[^a-zA-Z0-9_-])`, 'i');
677+ return mentionRegex.test(message.content);
678+ }
679+
285680 function handleInputChange(e: Event) {
286681 const input = e.target as HTMLInputElement;
287682 if (input.value.length > MAX_MESSAGE_LENGTH) {
@@ -289,12 +684,51 @@
289684 currentMessage = input.value;
290685 }
291686 inputScrollLeft = input.scrollLeft;
687+ updateMentionState(input);
688+ if (!mentionOpen && mentionQuery) {
689+ mentionQuery = '';
690+ }
292691 }
293692
294693 function handleInputScroll(e: Event) {
295694 inputScrollLeft = (e.target as HTMLInputElement).scrollLeft;
296695 }
297696
697+ function handleInputFocus() {
698+ if (!messageInput) return;
699+ updateMentionState(messageInput);
700+ }
701+
702+ function handleInputClick() {
703+ if (!messageInput) return;
704+ updateMentionState(messageInput);
705+ }
706+
707+ function handleInputKeydown(event: KeyboardEvent) {
708+ if (!messageInput) return;
709+ if (event.key === 'Escape') {
710+ event.preventDefault();
711+ if (mentionOpen && mentionAnchorIndex !== null) {
712+ mentionDismissKey = `${mentionAnchorIndex}:${mentionQuery}`;
713+ }
714+ closeMentionPicker();
715+ return;
716+ }
717+ if (mentionOpen) {
718+ handleMentionKeydown(event);
719+ return;
720+ }
721+ if (event.key === 'Enter') {
722+ event.preventDefault();
723+ void handleSubmit();
724+ }
725+ }
726+
727+ function handleInputKeyup() {
728+ if (!messageInput) return;
729+ updateMentionState(messageInput);
730+ }
731+
298732 async function handleSubmit() {
299733 if (!currentMessage.trim() || !currentUser || cooldownEndTime) return;
300734
@@ -439,6 +873,11 @@
439873 showAuth = false;
440874 }
441875
876+ function handleMentionMouseDown(event: MouseEvent, user: SafeUser) {
877+ event.preventDefault();
878+ applyMentionSelection(user);
879+ }
880+
442881 // ============ LIFECYCLE ============
443882
444883 onMount(() => {
@@ -520,6 +959,8 @@
520959 return () => {
521960 window.removeEventListener('resize', handleResize);
522961 if (cooldownInterval) clearInterval(cooldownInterval);
962+ // Clean up canvas context used for text measurement
963+ mentionMeasureContext = null;
523964 };
524965 });
525966
@@ -537,6 +978,14 @@
537978 isCentered
538979 });
539980 });
981+
982+ // Scroll selected mention option into view when selection changes
983+ $effect(() => {
984+ if (!mentionOpen || mentionSuggestions.length === 0) return;
985+ // Track mentionSelectedIndex to trigger scroll on keyboard navigation
986+ const _index = mentionSelectedIndex;
987+ void tick().then(() => scrollMentionOptionIntoView());
988+ });
540989 </script>
541990
542991 {#if showChatRoom}
@@ -567,7 +1016,8 @@
5671016 class="title-bar-text"
5681017 style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: 'MS Sans Serif', 'Pixelated MS Sans Serif', sans-serif;"
5691018 >
570- Pdr Aim {#if currentUser}
1019+ Pdr Aim {#if mentionUnreadCount > 0}({mentionUnreadCount})
1020+ {/if}{#if currentUser}
5711021 - {currentUser.nickname}{:else}
5721022 - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if}
5731023 {#if connectionError && !isMinimized}
@@ -640,6 +1090,7 @@
6401090 class="sunken-panel chat-area"
6411091 style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"
6421092 onscroll={(e) => handleScroll(e)}
1093+ bind:this={chatArea}
6431094 >
6441095 {#if isInitialLoading}
6451096 <div class="initial-loading">
@@ -659,7 +1110,18 @@
6591110 </div>
6601111 {/if}
6611112 {#each visibleMessages as message (message.id)}
662- <div class="message {message.type} text">
1113+ <div
1114+ class="message {message.type} text"
1115+ class:mention-highlight={hasMentionHighlight[message.id]}
1116+ data-message-id={message.id}
1117+ data-mention={hasMentionHighlight[message.id]
1118+ ? `Mention de @${currentUser?.nickname ?? ''}`
1119+ : undefined}
1120+ aria-label={hasMentionHighlight[message.id]
1121+ ? `Message mentionnant ${currentUser?.nickname ?? ''}`
1122+ : undefined}
1123+ use:registerMentionNode
1124+ >
6631125 {#if message.type === 'emote'}
6641126 <span class="emote-text">
6651127 <Tooltip
@@ -737,7 +1199,7 @@
7371199 -webkit-text-fill-color: transparent;
7381200 background-clip: text;
7391201 "
740- aria-hidden="true">{currentMessage}</span
1202+ aria-hidden="true">{@html formattedInputMessage}</span
7411203 >
7421204 <input
7431205 type="text"
@@ -753,9 +1215,13 @@
7531215 : ''} {currentTextStyle.underline || currentTextStyle.strikethrough
7541216 ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
7551217 : ''}"
756- onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
1218+ onkeydown={handleInputKeydown}
1219+ onkeyup={handleInputKeyup}
7571220 oninput={handleInputChange}
7581221 onscroll={handleInputScroll}
1222+ onfocus={handleInputFocus}
1223+ onclick={handleInputClick}
1224+ bind:this={messageInput}
7591225 placeholder={cooldownEndTime
7601226 ? `Patientez ${cooldownProgress.toFixed(1)}s...`
7611227 : 'Écrivez un message...'}
@@ -763,18 +1229,61 @@
7631229 />
7641230 </div>
7651231 {:else}
766- <input
767- type="text"
768- bind:value={currentMessage}
769- maxlength={MAX_MESSAGE_LENGTH}
770- class="styled-input retro-font-{currentTextStyle.fontFamily}"
771- style="flex: 1; {generateInputCSSStyle(currentTextStyle)}"
772- onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
773- placeholder={cooldownEndTime
774- ? `Patientez ${cooldownProgress.toFixed(1)}s...`
775- : 'Écrivez un message...'}
776- disabled={!currentUser || Boolean(cooldownEndTime)}
777- />
1232+ <div
1233+ class="mention-input-wrapper"
1234+ style="flex: 1; position: relative; background: white; overflow: hidden;"
1235+ >
1236+ <!-- Text overlay with mention highlighting -->
1237+ <span
1238+ class="mention-text-overlay retro-font-{currentTextStyle.fontFamily}"
1239+ style="
1240+ position: absolute;
1241+ left: 3px;
1242+ top: 50%;
1243+ transform: translateY(-50%) translateX(-{inputScrollLeft}px);
1244+ pointer-events: none;
1245+ white-space: nowrap;
1246+ font-size: {currentTextStyle.fontSize}px;
1247+ color: {currentTextStyle.color || '#000000'};
1248+ {currentTextStyle.bold
1249+ ? currentTextStyle.fontFamily === 'tahoma'
1250+ ? 'font-weight: 200;'
1251+ : 'font-weight: 700;'
1252+ : ''}
1253+ {currentTextStyle.italic ? 'font-style: italic;' : ''}
1254+ {currentTextStyle.underline || currentTextStyle.strikethrough
1255+ ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1256+ : ''}
1257+ "
1258+ aria-hidden="true">{@html formattedInputMessage}</span
1259+ >
1260+ <input
1261+ type="text"
1262+ bind:value={currentMessage}
1263+ maxlength={MAX_MESSAGE_LENGTH}
1264+ class="styled-input mention-overlay-input retro-font-{currentTextStyle.fontFamily}"
1265+ style="width: 100%; background: transparent; font-size: {currentTextStyle.fontSize}px; {currentTextStyle.bold
1266+ ? currentTextStyle.fontFamily === 'tahoma'
1267+ ? 'font-weight: 200;'
1268+ : 'font-weight: 700;'
1269+ : ''} {currentTextStyle.italic
1270+ ? 'font-style: italic;'
1271+ : ''} {currentTextStyle.underline || currentTextStyle.strikethrough
1272+ ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1273+ : ''} caret-color: {currentTextStyle.color || 'black'};"
1274+ onkeydown={handleInputKeydown}
1275+ onkeyup={handleInputKeyup}
1276+ oninput={handleInputChange}
1277+ onscroll={handleInputScroll}
1278+ onfocus={handleInputFocus}
1279+ onclick={handleInputClick}
1280+ bind:this={messageInput}
1281+ placeholder={cooldownEndTime
1282+ ? `Patientez ${cooldownProgress.toFixed(1)}s...`
1283+ : 'Écrivez un message...'}
1284+ disabled={!currentUser || Boolean(cooldownEndTime)}
1285+ />
1286+ </div>
7781287 {/if}
7791288 <LoadingButton
7801289 onclick={handleSubmit}
@@ -788,6 +1297,48 @@
7881297 </span>
7891298 {/if}
7901299 </div>
1300+ {#if mentionOpen}
1301+ <div
1302+ class="sunken-panel mention-picker"
1303+ class:mention-picker-portal={!mentionListInside}
1304+ role="listbox"
1305+ aria-label="Suggestions de mentions"
1306+ aria-activedescendant={mentionActiveId}
1307+ tabindex="-1"
1308+ style={`z-index: ${mentionListInside ? 10 : 3000}; ${mentionListStyle}`}
1309+ onmousedown={(event) => event.preventDefault()}
1310+ bind:this={mentionListContainer}
1311+ aria-live="polite"
1312+ >
1313+ {#if mentionSuggestions.length > 0}
1314+ {#each mentionSuggestions as user, index (user.id)}
1315+ <div
1316+ id={getMentionListItemId(user.id)}
1317+ role="option"
1318+ class="mention-option"
1319+ class:selected={index === mentionSelectedIndex}
1320+ aria-selected={index === mentionSelectedIndex}
1321+ tabindex="-1"
1322+ onmousedown={(event) => handleMentionMouseDown(event, user)}
1323+ >
1324+ <span class="mention-name">{user.nickname}</span>
1325+ <span class="mention-status {user.status}">{user.status}</span>
1326+ </div>
1327+ {/each}
1328+ {:else}
1329+ <div
1330+ class="mention-empty"
1331+ role="option"
1332+ aria-disabled="true"
1333+ aria-selected="false"
1334+ tabindex="-1"
1335+ >
1336+ Aucun utilisateur
1337+ </div>
1338+ {/if}
1339+ </div>
1340+ {/if}
1341+
7911342 {#if cooldownEndTime}
7921343 <div
7931344 class="cooldown-progress"
@@ -936,6 +1487,42 @@
9361487 color: #666;
9371488 }
9381489
1490+ .message-content :global(.mention-token) {
1491+ color: #1e2a72;
1492+ font-weight: bold;
1493+ }
1494+
1495+ /* Input mention highlighting */
1496+ .mention-input-wrapper,
1497+ .gradient-input-wrapper {
1498+ flex: 1;
1499+ position: relative;
1500+ background: white;
1501+ overflow: hidden;
1502+ }
1503+
1504+ .mention-text-overlay,
1505+ .gradient-text-overlay {
1506+ position: absolute;
1507+ left: 3px;
1508+ top: 50%;
1509+ pointer-events: none;
1510+ white-space: nowrap;
1511+ }
1512+
1513+ .mention-text-overlay :global(.input-mention),
1514+ .gradient-text-overlay :global(.input-mention) {
1515+ color: #1e2a72;
1516+ font-weight: bold;
1517+ -webkit-text-fill-color: #1e2a72;
1518+ }
1519+
1520+ /* Force input text to be transparent when using overlay */
1521+ .mention-overlay-input {
1522+ color: transparent !important;
1523+ -webkit-text-fill-color: transparent !important;
1524+ }
1525+
9391526 .section-header {
9401527 margin: 0 0 0.25rem 0;
9411528 font-weight: bold;
@@ -1000,8 +1587,7 @@
10001587 z-index: 2;
10011588 }
10021589
1003- .input-container input:disabled,
1004- .input-container {
1590+ .input-container input:disabled {
10051591 opacity: 0.7;
10061592 background-color: rgba(128, 128, 128, 0.1);
10071593 cursor: not-allowed;
@@ -1160,10 +1746,6 @@
11601746 font-size: 0.875rem;
11611747 }
11621748
1163- .error-banner small {
1164- color: #666;
1165- }
1166-
11671749 .rate-limit-warning {
11681750 background: #fff3e0;
11691751 color: #e65100;
@@ -1198,6 +1780,77 @@
11981780 }
11991781 }
12001782
1783+ .mention-picker {
1784+ position: absolute;
1785+ padding: 0.35rem;
1786+ max-height: 220px;
1787+ overflow-y: auto;
1788+ background: #fffef5;
1789+ border: 2px inset #dfdfdf;
1790+ box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.15);
1791+ font-family: 'Pixelated MS Sans Serif', Tahoma, sans-serif;
1792+ font-size: 0.85rem;
1793+ z-index: 10;
1794+ min-width: 220px;
1795+ }
1796+
1797+ .mention-picker-portal {
1798+ position: fixed;
1799+ }
1800+
1801+ .mention-option {
1802+ display: flex;
1803+ align-items: center;
1804+ justify-content: space-between;
1805+ gap: 0.5rem;
1806+ padding: 0.25rem 0.4rem;
1807+ border: 1px solid transparent;
1808+ cursor: pointer;
1809+ }
1810+
1811+ .mention-option.selected {
1812+ background: #dbe8ff;
1813+ border-color: #2d31a6;
1814+ }
1815+
1816+ .mention-name {
1817+ font-weight: bold;
1818+ color: #1e2a72;
1819+ }
1820+
1821+ .mention-status {
1822+ font-size: 0.7rem;
1823+ text-transform: uppercase;
1824+ letter-spacing: 0.05em;
1825+ color: #5b5b5b;
1826+ }
1827+
1828+ .mention-status.online {
1829+ color: #1f7a1f;
1830+ }
1831+
1832+ .mention-status.away,
1833+ .mention-status.busy {
1834+ color: #b06a00;
1835+ }
1836+
1837+ .mention-status.offline {
1838+ color: #8a8a8a;
1839+ }
1840+
1841+ .mention-empty {
1842+ padding: 0.35rem 0.5rem;
1843+ color: #777;
1844+ font-style: italic;
1845+ }
1846+
1847+ .message.mention-highlight {
1848+ background: rgba(255, 237, 186, 0.45);
1849+ border-radius: 4px;
1850+ padding: 0.2rem 0.35rem;
1851+ box-shadow: inset 0 0 0 1px rgba(178, 136, 0, 0.2);
1852+ }
1853+
12011854 .cooldown-progress {
12021855 position: absolute;
12031856 bottom: 0;
@@ -4,7 +4,7 @@
4 import { api } from '../../convex/_generated/api';4 import { api } from '../../convex/_generated/api';
5 import type { Id } from '../../convex/_generated/dataModel';5 import type { Id } from '../../convex/_generated/dataModel';
6 import type { Message, EnrichedMessage, SafeUser } from '../types/chat';6 import type { Message, EnrichedMessage, SafeUser } from '../types/chat';
7- import { onMount } from 'svelte';7+ import { onMount, tick } from 'svelte';
8 import { browser } from '$app/environment';8 import { browser } from '$app/environment';
9 import { draggable } from '$lib/actions/draggable';9 import { draggable } from '$lib/actions/draggable';
10 import { resizable } from '$lib/actions/resizable';10 import { resizable } from '$lib/actions/resizable';
@@ -146,11 +146,41 @@
146 146
147 let currentMessage = $state('');147 let currentMessage = $state('');
148 let inputScrollLeft = $state(0);148 let inputScrollLeft = $state(0);
149- let currentTextStyle = $state<TextStyle>({149+ // Initialize text style with prop value (intentionally capturing initial value only)
150- ...DEFAULT_TEXT_STYLE,150+ let currentTextStyle = $state<TextStyle>(
151- ...initialTextStyle,151+ (() => {
152- color: initialTextStyle.color || '#000000'152+ const style = { ...initialTextStyle };
153- });153+ return {
154+ ...DEFAULT_TEXT_STYLE,
155+ ...style,
156+ color: style.color || '#000000'
157+ };
158+ })()
159+ );
160+
161+ // ============ MENTION STATE ============
162+
163+ const USERNAME_PATTERN = /^[a-zA-Z0-9_-]{1,32}$/;
164+ const MAX_MENTION_SUGGESTIONS = 6;
165+ let messageInput = $state<HTMLInputElement | null>(null);
166+ let mentionOpen = $state(false);
167+ let mentionQuery = $state('');
168+ let mentionAnchorIndex = $state<number | null>(null);
169+ let mentionSelectedIndex = $state(0);
170+ let mentionObservedIds = new Set<string>();
171+ let mentionMessageIds = $state<Set<string>>(new Set());
172+ let mentionUnreadCount = $state(0);
173+ let mentionDismissKey = $state<string | null>(null);
174+ let mentionObserver: IntersectionObserver | null = null;
175+
176+ let baseTitle = $state('');
177+ const mentionListBoxId = `mentions-${Math.random().toString(36).slice(2, 8)}`;
178+ let mentionActiveId = $state<string | null>(null);
179+ let chatArea = $state<HTMLDivElement | null>(null);
180+ let mentionListContainer = $state<HTMLDivElement | null>(null);
181+ let mentionListStyle = $state('');
182+ let mentionListInside = $state(true);
183+ let mentionMeasureContext: CanvasRenderingContext2D | null = null;
154 184
155 // ============ RATE LIMITING STATE ============185 // ============ RATE LIMITING STATE ============
156 186
@@ -198,6 +228,46 @@
198 const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline'));228 const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline'));
199 const usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline'));229 const usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline'));
200 230
231+ const mentionableUsers = $derived(
232+ onlineUsers.filter((user) => !currentUser || user.id !== currentUser.id)
233+ );
234+
235+ const mentionSuggestions = $derived.by<SafeUser[]>(() => {
236+ if (!mentionOpen) return [];
237+ const query = mentionQuery.trim().toLowerCase();
238+ const candidates = query
239+ ? mentionableUsers.filter((user) => user.nickname.toLowerCase().startsWith(query))
240+ : mentionableUsers;
241+ return candidates.slice(0, MAX_MENTION_SUGGESTIONS);
242+ });
243+
244+ const mentionListMaxHeight = MAX_MENTION_SUGGESTIONS * 32 + 12;
245+
246+ // Format input message with mention highlighting (for overlay display)
247+ const formattedInputMessage = $derived.by(() => {
248+ if (!currentMessage) return '';
249+ // Escape HTML entities first
250+ const escaped = currentMessage
251+ .replace(/&/g, '&amp;')
252+ .replace(/</g, '&lt;')
253+ .replace(/>/g, '&gt;');
254+ // Apply mention styling
255+ return escaped.replace(
256+ /(^|\s)@([a-zA-Z0-9_-]{1,32})(?=$|\s|[^a-zA-Z0-9_-])/g,
257+ '$1<span class="input-mention">@$2</span>'
258+ );
259+ });
260+
261+ // mentionUnreadCount is managed in state updates
262+ const hasMentionHighlight = $derived.by<Record<string, boolean>>(() => {
263+ const highlights: Record<string, boolean> = {};
264+ if (!currentUser) return highlights;
265+ for (const message of visibleMessages) {
266+ highlights[message.id] = shouldHighlightMention(message);
267+ }
268+ return highlights;
269+ });
270+
201 // ============ EFFECTS ============271 // ============ EFFECTS ============
202 272
203 // Auto-scroll to bottom when new messages arrive273 // Auto-scroll to bottom when new messages arrive
@@ -222,6 +292,173 @@
222 }292 }
223 });293 });
224 294
295+ $effect(() => {
296+ if (!browser) return;
297+ if (!baseTitle) baseTitle = document.title;
298+ const totalMentions = chatState.getMentionTotal();
299+ if (totalMentions > 0) {
300+ document.title = `(${totalMentions}) ${baseTitle}`;
301+ } else {
302+ document.title = baseTitle;
303+ }
304+ });
305+
306+ $effect(() => {
307+ chatState.setRoomMentionCount(roomId ?? null, mentionUnreadCount);
308+ });
309+
310+ $effect(() => {
311+ if (!browser || !currentUser) {
312+ mentionObservedIds = new Set();
313+ mentionMessageIds = new Set();
314+ if (mentionUnreadCount !== 0) {
315+ mentionUnreadCount = 0;
316+ }
317+ // mention observer reset
318+ if (mentionObserver) {
319+ mentionObserver.disconnect();
320+ mentionObserver = null;
321+ }
322+ return;
323+ }
324+ const mentionIds = new Set<string>();
325+ for (const message of visibleMessages) {
326+ if (shouldHighlightMention(message)) {
327+ mentionIds.add(message.id);
328+ }
329+ }
330+ const nextMentionIds = mentionIds;
331+ if (!setsEqual(nextMentionIds, mentionMessageIds)) {
332+ mentionMessageIds = nextMentionIds;
333+ }
334+ const nextUnreadCount = Array.from(nextMentionIds).filter(
335+ (messageId) => !mentionObservedIds.has(messageId)
336+ ).length;
337+ // mentionObservedIds is a local Set to avoid effect loops
338+ if (mentionUnreadCount !== nextUnreadCount) {
339+ mentionUnreadCount = nextUnreadCount;
340+ }
341+ });
342+
343+ $effect(() => {
344+ const currentMentionIds = mentionMessageIds;
345+ if (!browser || !chatArea || !currentUser) return;
346+ if (mentionObserver) {
347+ mentionObserver.disconnect();
348+ }
349+ mentionObserver = new IntersectionObserver(
350+ (entries) => {
351+ const updatedObserved = new Set(mentionObservedIds);
352+ let changed = false;
353+ const currentObserved = mentionObservedIds;
354+ const currentUnread = mentionUnreadCount;
355+ for (const entry of entries) {
356+ const target = entry.target as HTMLElement;
357+ const messageId = target.dataset.messageId;
358+ if (!messageId || !currentMentionIds.has(messageId)) continue;
359+ if (entry.isIntersecting && !updatedObserved.has(messageId)) {
360+ updatedObserved.add(messageId);
361+ changed = true;
362+ }
363+ }
364+ if (changed && !setsEqual(updatedObserved, currentObserved)) {
365+ mentionObservedIds = updatedObserved;
366+ const nextUnread = Math.max(0, currentMentionIds.size - updatedObserved.size);
367+ if (currentUnread !== nextUnread) {
368+ mentionUnreadCount = nextUnread;
369+ }
370+ }
371+ },
372+ { root: chatArea, threshold: 0.6 }
373+ );
374+ const nodes = Array.from(chatArea.querySelectorAll<HTMLElement>('[data-message-id]'));
375+ for (const node of nodes) {
376+ const messageId = node.dataset.messageId;
377+ if (messageId && currentMentionIds.has(messageId)) {
378+ mentionObserver.observe(node);
379+ }
380+ }
381+ // only observe mention messages
382+ // mention observer reset
383+ return () => {
384+ mentionObserver?.disconnect();
385+ };
386+ });
387+
388+ $effect(() => {
389+ if (!mentionOpen || mentionSuggestions.length === 0) {
390+ if (mentionActiveId !== null) {
391+ mentionActiveId = null;
392+ }
393+ return;
394+ }
395+ const currentActiveId = mentionActiveId;
396+ let selectedIndex = mentionSelectedIndex;
397+ if (selectedIndex >= mentionSuggestions.length) {
398+ selectedIndex = 0;
399+ if (mentionSelectedIndex !== 0) {
400+ mentionSelectedIndex = 0;
401+ }
402+ }
403+ const active = mentionSuggestions[selectedIndex];
404+ const nextActiveId = active ? getMentionListItemId(active.id) : null;
405+ if (currentActiveId !== nextActiveId) {
406+ mentionActiveId = nextActiveId;
407+ }
408+ });
409+
410+ $effect(() => {
411+ if (!mentionOpen || !messageInput) {
412+ if (mentionListStyle) mentionListStyle = '';
413+ return;
414+ }
415+ const currentListStyle = mentionListStyle;
416+ const currentInside = mentionListInside;
417+ const inputRect = messageInput.getBoundingClientRect();
418+ const containerRect = messageInput.closest('.chat-container')?.getBoundingClientRect();
419+ // Use actual height: single row for empty state, otherwise based on suggestion count
420+ const listHeight = mentionSuggestions.length
421+ ? Math.min(mentionSuggestions.length, MAX_MENTION_SUGGESTIONS) * 32 + 12
422+ : 44; // Single "Aucun utilisateur" row height
423+ const spaceBelow = containerRect
424+ ? containerRect.bottom - inputRect.bottom
425+ : window.innerHeight - inputRect.bottom;
426+ const spaceAbove = containerRect ? inputRect.top - containerRect.top : inputRect.top;
427+ const showAbove = spaceBelow < listHeight && spaceAbove > listHeight;
428+ const maxNameWidth = mentionSuggestions.reduce((max, user) => {
429+ const width = measureMentionText(user.nickname, true, 13.6);
430+ return Math.max(max, width);
431+ }, 0);
432+ // Status uses 0.7rem (11.2px) font size and uppercase (add ~10% for letter-spacing)
433+ const statusWidth = measureMentionText('OFFLINE', false, 11.2) * 1.1;
434+ const paddingWidth = 68;
435+ const listWidth = Math.max(220, Math.ceil(maxNameWidth + statusWidth + paddingWidth));
436+ if (containerRect) {
437+ if (!currentInside) mentionListInside = true;
438+ const top = showAbove
439+ ? inputRect.top - containerRect.top - listHeight - 6
440+ : inputRect.bottom - containerRect.top + 6;
441+ const left = inputRect.left - containerRect.left;
442+ const nextStyle = `top: ${Math.max(8, top)}px; left: ${Math.max(8, left)}px; width: ${listWidth}px;`;
443+ if (currentListStyle !== nextStyle) {
444+ mentionListStyle = nextStyle;
445+ }
446+ } else {
447+ if (currentInside) mentionListInside = false;
448+ const top = showAbove ? inputRect.top - listHeight - 6 : inputRect.bottom + 6;
449+ const nextStyle = `top: ${Math.max(8, top)}px; left: ${Math.max(8, inputRect.left)}px; width: ${listWidth}px;`;
450+ if (currentListStyle !== nextStyle) {
451+ mentionListStyle = nextStyle;
452+ }
453+ }
454+ if (mentionSuggestions.length === 0 && mentionActiveId !== null) {
455+ mentionActiveId = null;
456+ }
457+ if (mentionSelectedIndex >= mentionSuggestions.length && mentionSuggestions.length > 0) {
458+ mentionSelectedIndex = 0;
459+ }
460+ });
461+
225 // ============ HELPERS ============462 // ============ HELPERS ============
226 463
227 function resolveUser(senderId: string): SafeUser {464 function resolveUser(senderId: string): SafeUser {
@@ -253,6 +490,156 @@
253 490
254 const debouncedSaveWindowState = debounce(saveWindowState, 300);491 const debouncedSaveWindowState = debounce(saveWindowState, 300);
255 492
493+ function isWordStart(value: string, index: number) {
494+ if (index <= 0) return true;
495+ return /\s/.test(value[index - 1]);
496+ }
497+
498+ function setsEqual(a: Set<string>, b: Set<string>) {
499+ if (a.size !== b.size) return false;
500+ for (const value of a) {
501+ if (!b.has(value)) return false;
502+ }
503+ return true;
504+ }
505+
506+ function measureMentionText(text: string, bold: boolean = true, fontSizePx: number = 13.6) {
507+ if (!mentionMeasureContext) {
508+ const canvas = document.createElement('canvas');
509+ mentionMeasureContext = canvas.getContext('2d');
510+ }
511+ if (!mentionMeasureContext) return 0;
512+ mentionMeasureContext.font = `${bold ? 'bold ' : ''}${fontSizePx}px 'Pixelated MS Sans Serif', Tahoma, sans-serif`;
513+ return mentionMeasureContext.measureText(text).width;
514+ }
515+
516+ function findMentionContext(value: string, cursorIndex: number) {
517+ const beforeCursor = value.slice(0, cursorIndex);
518+ const atIndex = beforeCursor.lastIndexOf('@');
519+ if (atIndex === -1) return null;
520+ if (!isWordStart(value, atIndex)) return null;
521+ const query = value.slice(atIndex + 1, cursorIndex);
522+ if (query.includes(' ') || query.includes('\n') || query.includes('\t')) return null;
523+ if (query.length > 32) return null;
524+ if (query && !USERNAME_PATTERN.test(query)) return null;
525+ return { atIndex, query };
526+ }
527+
528+ function updateMentionState(input: HTMLInputElement) {
529+ if (!currentUser) {
530+ mentionOpen = false;
531+ return;
532+ }
533+ const cursorIndex = input.selectionStart ?? input.value.length;
534+ const context = findMentionContext(input.value, cursorIndex);
535+ if (!context) {
536+ closeMentionPicker();
537+ return;
538+ }
539+ const mentionKey = `${context.atIndex}:${context.query}`;
540+ if (mentionDismissKey === mentionKey) {
541+ return;
542+ }
543+ const wasOpen = mentionOpen;
544+ const previousQuery = mentionQuery;
545+ const sameAnchor = mentionAnchorIndex === context.atIndex;
546+ mentionOpen = true;
547+ mentionQuery = context.query;
548+ mentionAnchorIndex = context.atIndex;
549+ mentionDismissKey = null;
550+ if (!wasOpen || !sameAnchor || previousQuery !== context.query) {
551+ mentionSelectedIndex = 0;
552+ }
553+ }
554+
555+ function registerMentionNode(node: HTMLElement) {
556+ if (!node) return;
557+ const messageId = node.dataset.messageId;
558+ if (!messageId) return;
559+ if (mentionObserver) {
560+ mentionObserver.observe(node);
561+ return;
562+ }
563+ // observer will scan after setup
564+ }
565+
566+ function closeMentionPicker() {
567+ mentionOpen = false;
568+ mentionQuery = '';
569+ mentionAnchorIndex = null;
570+ mentionSelectedIndex = 0;
571+ mentionActiveId = null;
572+ mentionDismissKey = null;
573+ if (messageInput) {
574+ messageInput.focus();
575+ }
576+ }
577+
578+ function clampMentionIndex(nextIndex: number) {
579+ if (mentionSuggestions.length === 0) return 0;
580+ const maxIndex = mentionSuggestions.length - 1;
581+ if (nextIndex < 0) return maxIndex;
582+ if (nextIndex > maxIndex) return 0;
583+ return nextIndex;
584+ }
585+
586+ function applyMentionSelection(user: SafeUser) {
587+ if (!messageInput || mentionAnchorIndex === null) return;
588+ const selectionStart = messageInput.selectionStart ?? currentMessage.length;
589+ const before = currentMessage.slice(0, mentionAnchorIndex);
590+ const after = currentMessage.slice(selectionStart);
591+ const mentionText = `@${user.nickname} `;
592+ currentMessage = `${before}${mentionText}${after}`;
593+ void tick().then(() => {
594+ if (!messageInput) return;
595+ const cursor = before.length + mentionText.length;
596+ messageInput.focus();
597+ messageInput.setSelectionRange(cursor, cursor);
598+ });
599+ closeMentionPicker();
600+ }
601+
602+ function handleMentionKeydown(event: KeyboardEvent) {
603+ if (!mentionOpen || mentionSuggestions.length === 0) return;
604+ if (event.key === 'ArrowDown') {
605+ event.preventDefault();
606+ mentionSelectedIndex = clampMentionIndex(mentionSelectedIndex + 1);
607+ void tick().then(() => scrollMentionOptionIntoView());
608+ return;
609+ }
610+ if (event.key === 'ArrowUp') {
611+ event.preventDefault();
612+ mentionSelectedIndex = clampMentionIndex(mentionSelectedIndex - 1);
613+ void tick().then(() => scrollMentionOptionIntoView());
614+ return;
615+ }
616+ if (event.key === 'Enter' || event.key === 'Tab') {
617+ event.preventDefault();
618+ const choice = mentionSuggestions[mentionSelectedIndex];
619+ if (choice) applyMentionSelection(choice);
620+ return;
621+ }
622+ if (event.key === 'Escape') {
623+ event.preventDefault();
624+ if (mentionOpen && mentionAnchorIndex !== null) {
625+ mentionDismissKey = `${mentionAnchorIndex}:${mentionQuery}`;
626+ }
627+ closeMentionPicker();
628+ }
629+ }
630+
631+ function getMentionListItemId(userId: string) {
632+ return `${mentionListBoxId}-${userId}`;
633+ }
634+
635+ function scrollMentionOptionIntoView() {
636+ if (!mentionActiveId) return;
637+ const option =
638+ document.getElementById(mentionActiveId) ??
639+ mentionListContainer?.querySelector(`[id='${mentionActiveId}']`);
640+ option?.scrollIntoView({ block: 'nearest' });
641+ }
642+
256 function updateCooldownProgress() {643 function updateCooldownProgress() {
257 if (!cooldownEndTime) return;644 if (!cooldownEndTime) return;
258 const now = Date.now();645 const now = Date.now();
@@ -282,6 +669,14 @@
282 showChatRoom = false;669 showChatRoom = false;
283 }670 }
284 671
672+ function shouldHighlightMention(message: EnrichedMessage) {
673+ if (!currentUser) return false;
674+ if (message.senderId === currentUser.id) return false;
675+ const escapedNickname = currentUser.nickname.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
676+ const mentionRegex = new RegExp(`(?:^|\\s)@(${escapedNickname})(?=$|\\s|[^a-zA-Z0-9_-])`, 'i');
677+ return mentionRegex.test(message.content);
678+ }
679+
285 function handleInputChange(e: Event) {680 function handleInputChange(e: Event) {
286 const input = e.target as HTMLInputElement;681 const input = e.target as HTMLInputElement;
287 if (input.value.length > MAX_MESSAGE_LENGTH) {682 if (input.value.length > MAX_MESSAGE_LENGTH) {
@@ -289,12 +684,51 @@
289 currentMessage = input.value;684 currentMessage = input.value;
290 }685 }
291 inputScrollLeft = input.scrollLeft;686 inputScrollLeft = input.scrollLeft;
687+ updateMentionState(input);
688+ if (!mentionOpen && mentionQuery) {
689+ mentionQuery = '';
690+ }
292 }691 }
293 692
294 function handleInputScroll(e: Event) {693 function handleInputScroll(e: Event) {
295 inputScrollLeft = (e.target as HTMLInputElement).scrollLeft;694 inputScrollLeft = (e.target as HTMLInputElement).scrollLeft;
296 }695 }
297 696
697+ function handleInputFocus() {
698+ if (!messageInput) return;
699+ updateMentionState(messageInput);
700+ }
701+
702+ function handleInputClick() {
703+ if (!messageInput) return;
704+ updateMentionState(messageInput);
705+ }
706+
707+ function handleInputKeydown(event: KeyboardEvent) {
708+ if (!messageInput) return;
709+ if (event.key === 'Escape') {
710+ event.preventDefault();
711+ if (mentionOpen && mentionAnchorIndex !== null) {
712+ mentionDismissKey = `${mentionAnchorIndex}:${mentionQuery}`;
713+ }
714+ closeMentionPicker();
715+ return;
716+ }
717+ if (mentionOpen) {
718+ handleMentionKeydown(event);
719+ return;
720+ }
721+ if (event.key === 'Enter') {
722+ event.preventDefault();
723+ void handleSubmit();
724+ }
725+ }
726+
727+ function handleInputKeyup() {
728+ if (!messageInput) return;
729+ updateMentionState(messageInput);
730+ }
731+
298 async function handleSubmit() {732 async function handleSubmit() {
299 if (!currentMessage.trim() || !currentUser || cooldownEndTime) return;733 if (!currentMessage.trim() || !currentUser || cooldownEndTime) return;
300 734
@@ -439,6 +873,11 @@
439 showAuth = false;873 showAuth = false;
440 }874 }
441 875
876+ function handleMentionMouseDown(event: MouseEvent, user: SafeUser) {
877+ event.preventDefault();
878+ applyMentionSelection(user);
879+ }
880+
442 // ============ LIFECYCLE ============881 // ============ LIFECYCLE ============
443 882
444 onMount(() => {883 onMount(() => {
@@ -520,6 +959,8 @@
520 return () => {959 return () => {
521 window.removeEventListener('resize', handleResize);960 window.removeEventListener('resize', handleResize);
522 if (cooldownInterval) clearInterval(cooldownInterval);961 if (cooldownInterval) clearInterval(cooldownInterval);
962+ // Clean up canvas context used for text measurement
963+ mentionMeasureContext = null;
523 };964 };
524 });965 });
525 966
@@ -537,6 +978,14 @@
537 isCentered978 isCentered
538 });979 });
539 });980 });
981+
982+ // Scroll selected mention option into view when selection changes
983+ $effect(() => {
984+ if (!mentionOpen || mentionSuggestions.length === 0) return;
985+ // Track mentionSelectedIndex to trigger scroll on keyboard navigation
986+ const _index = mentionSelectedIndex;
987+ void tick().then(() => scrollMentionOptionIntoView());
988+ });
540 </script>989 </script>
541 990
542 {#if showChatRoom}991 {#if showChatRoom}
@@ -567,7 +1016,8 @@
567 class="title-bar-text"1016 class="title-bar-text"
568 style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: 'MS Sans Serif', 'Pixelated MS Sans Serif', sans-serif;"1017 style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: 'MS Sans Serif', 'Pixelated MS Sans Serif', sans-serif;"
569 >1018 >
570- Pdr Aim {#if currentUser}1019+ Pdr Aim {#if mentionUnreadCount > 0}({mentionUnreadCount})
1020+ {/if}{#if currentUser}
571 - {currentUser.nickname}{:else}1021 - {currentUser.nickname}{:else}
572 - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if}1022 - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if}
573 {#if connectionError && !isMinimized}1023 {#if connectionError && !isMinimized}
@@ -640,6 +1090,7 @@
640 class="sunken-panel chat-area"1090 class="sunken-panel chat-area"
641 style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"1091 style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"
642 onscroll={(e) => handleScroll(e)}1092 onscroll={(e) => handleScroll(e)}
1093+ bind:this={chatArea}
643 >1094 >
644 {#if isInitialLoading}1095 {#if isInitialLoading}
645 <div class="initial-loading">1096 <div class="initial-loading">
@@ -659,7 +1110,18 @@
659 </div>1110 </div>
660 {/if}1111 {/if}
661 {#each visibleMessages as message (message.id)}1112 {#each visibleMessages as message (message.id)}
662- <div class="message {message.type} text">1113+ <div
1114+ class="message {message.type} text"
1115+ class:mention-highlight={hasMentionHighlight[message.id]}
1116+ data-message-id={message.id}
1117+ data-mention={hasMentionHighlight[message.id]
1118+ ? `Mention de @${currentUser?.nickname ?? ''}`
1119+ : undefined}
1120+ aria-label={hasMentionHighlight[message.id]
1121+ ? `Message mentionnant ${currentUser?.nickname ?? ''}`
1122+ : undefined}
1123+ use:registerMentionNode
1124+ >
663 {#if message.type === 'emote'}1125 {#if message.type === 'emote'}
664 <span class="emote-text">1126 <span class="emote-text">
665 <Tooltip1127 <Tooltip
@@ -737,7 +1199,7 @@
737 -webkit-text-fill-color: transparent;1199 -webkit-text-fill-color: transparent;
738 background-clip: text;1200 background-clip: text;
739 "1201 "
740- aria-hidden="true">{currentMessage}</span1202+ aria-hidden="true">{@html formattedInputMessage}</span
741 >1203 >
742 <input1204 <input
743 type="text"1205 type="text"
@@ -753,9 +1215,13 @@
753 : ''} {currentTextStyle.underline || currentTextStyle.strikethrough1215 : ''} {currentTextStyle.underline || currentTextStyle.strikethrough
754 ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`1216 ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
755 : ''}"1217 : ''}"
756- onkeydown={(e) => e.key === 'Enter' && handleSubmit()}1218+ onkeydown={handleInputKeydown}
1219+ onkeyup={handleInputKeyup}
757 oninput={handleInputChange}1220 oninput={handleInputChange}
758 onscroll={handleInputScroll}1221 onscroll={handleInputScroll}
1222+ onfocus={handleInputFocus}
1223+ onclick={handleInputClick}
1224+ bind:this={messageInput}
759 placeholder={cooldownEndTime1225 placeholder={cooldownEndTime
760 ? `Patientez ${cooldownProgress.toFixed(1)}s...`1226 ? `Patientez ${cooldownProgress.toFixed(1)}s...`
761 : 'Écrivez un message...'}1227 : 'Écrivez un message...'}
@@ -763,18 +1229,61 @@
763 />1229 />
764 </div>1230 </div>
765 {:else}1231 {:else}
766- <input1232+ <div
767- type="text"1233+ class="mention-input-wrapper"
768- bind:value={currentMessage}1234+ style="flex: 1; position: relative; background: white; overflow: hidden;"
769- maxlength={MAX_MESSAGE_LENGTH}1235+ >
770- class="styled-input retro-font-{currentTextStyle.fontFamily}"1236+ <!-- Text overlay with mention highlighting -->
771- style="flex: 1; {generateInputCSSStyle(currentTextStyle)}"1237+ <span
772- onkeydown={(e) => e.key === 'Enter' && handleSubmit()}1238+ class="mention-text-overlay retro-font-{currentTextStyle.fontFamily}"
773- placeholder={cooldownEndTime1239+ style="
774- ? `Patientez ${cooldownProgress.toFixed(1)}s...`1240+ position: absolute;
775- : 'Écrivez un message...'}1241+ left: 3px;
776- disabled={!currentUser || Boolean(cooldownEndTime)}1242+ top: 50%;
777- />1243+ transform: translateY(-50%) translateX(-{inputScrollLeft}px);
1244+ pointer-events: none;
1245+ white-space: nowrap;
1246+ font-size: {currentTextStyle.fontSize}px;
1247+ color: {currentTextStyle.color || '#000000'};
1248+ {currentTextStyle.bold
1249+ ? currentTextStyle.fontFamily === 'tahoma'
1250+ ? 'font-weight: 200;'
1251+ : 'font-weight: 700;'
1252+ : ''}
1253+ {currentTextStyle.italic ? 'font-style: italic;' : ''}
1254+ {currentTextStyle.underline || currentTextStyle.strikethrough
1255+ ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1256+ : ''}
1257+ "
1258+ aria-hidden="true">{@html formattedInputMessage}</span
1259+ >
1260+ <input
1261+ type="text"
1262+ bind:value={currentMessage}
1263+ maxlength={MAX_MESSAGE_LENGTH}
1264+ class="styled-input mention-overlay-input retro-font-{currentTextStyle.fontFamily}"
1265+ style="width: 100%; background: transparent; font-size: {currentTextStyle.fontSize}px; {currentTextStyle.bold
1266+ ? currentTextStyle.fontFamily === 'tahoma'
1267+ ? 'font-weight: 200;'
1268+ : 'font-weight: 700;'
1269+ : ''} {currentTextStyle.italic
1270+ ? 'font-style: italic;'
1271+ : ''} {currentTextStyle.underline || currentTextStyle.strikethrough
1272+ ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1273+ : ''} caret-color: {currentTextStyle.color || 'black'};"
1274+ onkeydown={handleInputKeydown}
1275+ onkeyup={handleInputKeyup}
1276+ oninput={handleInputChange}
1277+ onscroll={handleInputScroll}
1278+ onfocus={handleInputFocus}
1279+ onclick={handleInputClick}
1280+ bind:this={messageInput}
1281+ placeholder={cooldownEndTime
1282+ ? `Patientez ${cooldownProgress.toFixed(1)}s...`
1283+ : 'Écrivez un message...'}
1284+ disabled={!currentUser || Boolean(cooldownEndTime)}
1285+ />
1286+ </div>
778 {/if}1287 {/if}
779 <LoadingButton1288 <LoadingButton
780 onclick={handleSubmit}1289 onclick={handleSubmit}
@@ -788,6 +1297,48 @@
788 </span>1297 </span>
789 {/if}1298 {/if}
790 </div>1299 </div>
1300+ {#if mentionOpen}
1301+ <div
1302+ class="sunken-panel mention-picker"
1303+ class:mention-picker-portal={!mentionListInside}
1304+ role="listbox"
1305+ aria-label="Suggestions de mentions"
1306+ aria-activedescendant={mentionActiveId}
1307+ tabindex="-1"
1308+ style={`z-index: ${mentionListInside ? 10 : 3000}; ${mentionListStyle}`}
1309+ onmousedown={(event) => event.preventDefault()}
1310+ bind:this={mentionListContainer}
1311+ aria-live="polite"
1312+ >
1313+ {#if mentionSuggestions.length > 0}
1314+ {#each mentionSuggestions as user, index (user.id)}
1315+ <div
1316+ id={getMentionListItemId(user.id)}
1317+ role="option"
1318+ class="mention-option"
1319+ class:selected={index === mentionSelectedIndex}
1320+ aria-selected={index === mentionSelectedIndex}
1321+ tabindex="-1"
1322+ onmousedown={(event) => handleMentionMouseDown(event, user)}
1323+ >
1324+ <span class="mention-name">{user.nickname}</span>
1325+ <span class="mention-status {user.status}">{user.status}</span>
1326+ </div>
1327+ {/each}
1328+ {:else}
1329+ <div
1330+ class="mention-empty"
1331+ role="option"
1332+ aria-disabled="true"
1333+ aria-selected="false"
1334+ tabindex="-1"
1335+ >
1336+ Aucun utilisateur
1337+ </div>
1338+ {/if}
1339+ </div>
1340+ {/if}
1341+
791 {#if cooldownEndTime}1342 {#if cooldownEndTime}
792 <div1343 <div
793 class="cooldown-progress"1344 class="cooldown-progress"
@@ -936,6 +1487,42 @@
936 color: #666;1487 color: #666;
937 }1488 }
938 1489
1490+ .message-content :global(.mention-token) {
1491+ color: #1e2a72;
1492+ font-weight: bold;
1493+ }
1494+
1495+ /* Input mention highlighting */
1496+ .mention-input-wrapper,
1497+ .gradient-input-wrapper {
1498+ flex: 1;
1499+ position: relative;
1500+ background: white;
1501+ overflow: hidden;
1502+ }
1503+
1504+ .mention-text-overlay,
1505+ .gradient-text-overlay {
1506+ position: absolute;
1507+ left: 3px;
1508+ top: 50%;
1509+ pointer-events: none;
1510+ white-space: nowrap;
1511+ }
1512+
1513+ .mention-text-overlay :global(.input-mention),
1514+ .gradient-text-overlay :global(.input-mention) {
1515+ color: #1e2a72;
1516+ font-weight: bold;
1517+ -webkit-text-fill-color: #1e2a72;
1518+ }
1519+
1520+ /* Force input text to be transparent when using overlay */
1521+ .mention-overlay-input {
1522+ color: transparent !important;
1523+ -webkit-text-fill-color: transparent !important;
1524+ }
1525+
939 .section-header {1526 .section-header {
940 margin: 0 0 0.25rem 0;1527 margin: 0 0 0.25rem 0;
941 font-weight: bold;1528 font-weight: bold;
@@ -1000,8 +1587,7 @@
1000 z-index: 2;1587 z-index: 2;
1001 }1588 }
1002 1589
1003- .input-container input:disabled,1590+ .input-container input:disabled {
1004- .input-container {
1005 opacity: 0.7;1591 opacity: 0.7;
1006 background-color: rgba(128, 128, 128, 0.1);1592 background-color: rgba(128, 128, 128, 0.1);
1007 cursor: not-allowed;1593 cursor: not-allowed;
@@ -1160,10 +1746,6 @@
1160 font-size: 0.875rem;1746 font-size: 0.875rem;
1161 }1747 }
1162 1748
1163- .error-banner small {
1164- color: #666;
1165- }
1166-
1167 .rate-limit-warning {1749 .rate-limit-warning {
1168 background: #fff3e0;1750 background: #fff3e0;
1169 color: #e65100;1751 color: #e65100;
@@ -1198,6 +1780,77 @@
1198 }1780 }
1199 }1781 }
1200 1782
1783+ .mention-picker {
1784+ position: absolute;
1785+ padding: 0.35rem;
1786+ max-height: 220px;
1787+ overflow-y: auto;
1788+ background: #fffef5;
1789+ border: 2px inset #dfdfdf;
1790+ box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.15);
1791+ font-family: 'Pixelated MS Sans Serif', Tahoma, sans-serif;
1792+ font-size: 0.85rem;
1793+ z-index: 10;
1794+ min-width: 220px;
1795+ }
1796+
1797+ .mention-picker-portal {
1798+ position: fixed;
1799+ }
1800+
1801+ .mention-option {
1802+ display: flex;
1803+ align-items: center;
1804+ justify-content: space-between;
1805+ gap: 0.5rem;
1806+ padding: 0.25rem 0.4rem;
1807+ border: 1px solid transparent;
1808+ cursor: pointer;
1809+ }
1810+
1811+ .mention-option.selected {
1812+ background: #dbe8ff;
1813+ border-color: #2d31a6;
1814+ }
1815+
1816+ .mention-name {
1817+ font-weight: bold;
1818+ color: #1e2a72;
1819+ }
1820+
1821+ .mention-status {
1822+ font-size: 0.7rem;
1823+ text-transform: uppercase;
1824+ letter-spacing: 0.05em;
1825+ color: #5b5b5b;
1826+ }
1827+
1828+ .mention-status.online {
1829+ color: #1f7a1f;
1830+ }
1831+
1832+ .mention-status.away,
1833+ .mention-status.busy {
1834+ color: #b06a00;
1835+ }
1836+
1837+ .mention-status.offline {
1838+ color: #8a8a8a;
1839+ }
1840+
1841+ .mention-empty {
1842+ padding: 0.35rem 0.5rem;
1843+ color: #777;
1844+ font-style: italic;
1845+ }
1846+
1847+ .message.mention-highlight {
1848+ background: rgba(255, 237, 186, 0.45);
1849+ border-radius: 4px;
1850+ padding: 0.2rem 0.35rem;
1851+ box-shadow: inset 0 0 0 1px rgba(178, 136, 0, 0.2);
1852+ }
1853+
1201 .cooldown-progress {1854 .cooldown-progress {
1202 position: absolute;1855 position: absolute;
1203 bottom: 0;1856 bottom: 0;
M src/lib/components/formatted-message.svelte +16 -1
@@ -15,10 +15,19 @@
1515 let isProcessing = $state(false);
1616 let hasError = $state(false);
1717
18+ // Apply mention highlighting to text
19+ function applyMentionHighlighting(text: string): string {
20+ return text.replace(
21+ /(^|\s)@([a-zA-Z0-9_-]{1,32})(?=$|\s|[^a-zA-Z0-9_-])/g,
22+ '$1<span class="mention-token">@$2</span>'
23+ );
24+ }
25+
1826 // Process message formatting
1927 async function processMessage() {
2028 if (!allowFormatting || !message.hasFormatting) {
21- formattedContent = escapeHtml(message.content);
29+ // Still apply mention highlighting to plain text messages
30+ formattedContent = applyMentionHighlighting(escapeHtml(message.content));
2231 return;
2332 }
2433
@@ -234,6 +243,12 @@
234243 color: inherit;
235244 }
236245
246+ /* Mention token styling */
247+ :global(.mention-token) {
248+ color: #1e2a72;
249+ font-weight: bold;
250+ }
251+
237252 /* Ensure accessibility for screen readers */
238253 :global(.formatted-message[aria-label]) {
239254 position: relative;
@@ -15,10 +15,19 @@
15 let isProcessing = $state(false);15 let isProcessing = $state(false);
16 let hasError = $state(false);16 let hasError = $state(false);
17 17
18+ // Apply mention highlighting to text
19+ function applyMentionHighlighting(text: string): string {
20+ return text.replace(
21+ /(^|\s)@([a-zA-Z0-9_-]{1,32})(?=$|\s|[^a-zA-Z0-9_-])/g,
22+ '$1<span class="mention-token">@$2</span>'
23+ );
24+ }
25+
18 // Process message formatting26 // Process message formatting
19 async function processMessage() {27 async function processMessage() {
20 if (!allowFormatting || !message.hasFormatting) {28 if (!allowFormatting || !message.hasFormatting) {
21- formattedContent = escapeHtml(message.content);29+ // Still apply mention highlighting to plain text messages
30+ formattedContent = applyMentionHighlighting(escapeHtml(message.content));
22 return;31 return;
23 }32 }
24 33
@@ -234,6 +243,12 @@
234 color: inherit;243 color: inherit;
235 }244 }
236 245
246+ /* Mention token styling */
247+ :global(.mention-token) {
248+ color: #1e2a72;
249+ font-weight: bold;
250+ }
251+
237 /* Ensure accessibility for screen readers */252 /* Ensure accessibility for screen readers */
238 :global(.formatted-message[aria-label]) {253 :global(.formatted-message[aria-label]) {
239 position: relative;254 position: relative;
M src/lib/components/text-formatting-toolbar.svelte +8 -3
@@ -87,7 +87,7 @@
8787 title="Font Family"
8888 >
8989 {#each Object.entries(RETRO_FONTS) as [key, font] (key)}
90- <option value={key} style="font-family: {font.stack};">{font.name}</option>
90+ <option value={key}>{font.shortName}</option>
9191 {/each}
9292 </select>
9393 {/if}
@@ -182,17 +182,22 @@
182182 gap: 1px;
183183 }
184184
185- /* Fix font dropdown text alignment */
185+ /* Fix font dropdown text alignment and overflow */
186186 select {
187187 height: 20px;
188188 line-height: 1;
189189 vertical-align: middle;
190- padding: 1px 4px;
190+ padding: 1px 21px 1px 4px;
191191 font-size: 11px;
192+ max-width: 120px;
193+ overflow: hidden;
194+ text-overflow: ellipsis;
195+ white-space: nowrap;
192196 }
193197
194198 select option {
195199 padding: 2px 4px;
196200 line-height: normal;
201+ font-family: inherit;
197202 }
198203 </style>
@@ -87,7 +87,7 @@
87 title="Font Family"87 title="Font Family"
88 >88 >
89 {#each Object.entries(RETRO_FONTS) as [key, font] (key)}89 {#each Object.entries(RETRO_FONTS) as [key, font] (key)}
90- <option value={key} style="font-family: {font.stack};">{font.name}</option>90+ <option value={key}>{font.shortName}</option>
91 {/each}91 {/each}
92 </select>92 </select>
93 {/if}93 {/if}
@@ -182,17 +182,22 @@
182 gap: 1px;182 gap: 1px;
183 }183 }
184 184
185- /* Fix font dropdown text alignment */185+ /* Fix font dropdown text alignment and overflow */
186 select {186 select {
187 height: 20px;187 height: 20px;
188 line-height: 1;188 line-height: 1;
189 vertical-align: middle;189 vertical-align: middle;
190- padding: 1px 4px;190+ padding: 1px 21px 1px 4px;
191 font-size: 11px;191 font-size: 11px;
192+ max-width: 120px;
193+ overflow: hidden;
194+ text-overflow: ellipsis;
195+ white-space: nowrap;
192 }196 }
193 197
194 select option {198 select option {
195 padding: 2px 4px;199 padding: 2px 4px;
196 line-height: normal;200 line-height: normal;
201+ font-family: inherit;
197 }202 }
198 </style>203 </style>
M src/lib/states/chat.svelte.ts +29 -0
@@ -16,9 +16,19 @@ class ChatState {
1616 // Connection state for UI feedback
1717 private _connectionStatus = $state<'connected' | 'disconnected' | 'reconnecting'>('disconnected');
1818
19+ // Mention notifications per room
20+ private _roomMentionCounts = $state<Record<string, number>>({});
21+
1922 // Derived public state
2023 public connectionStatus = $derived(this._connectionStatus);
2124 public isConnected = $derived(this._connectionStatus === 'connected');
25+ public mentionTotal = $derived(
26+ Object.values(this._roomMentionCounts).reduce((sum, count) => sum + count, 0)
27+ );
28+
29+ getMentionTotal() {
30+ return Object.values(this._roomMentionCounts).reduce((sum, count) => sum + count, 0);
31+ }
2232
2333 /**
2434 * Get the current user
@@ -34,6 +44,7 @@ class ChatState {
3444 if (!user) {
3545 this._currentUser = null;
3646 this._connectionStatus = 'disconnected';
47+ this._roomMentionCounts = {};
3748 return;
3849 }
3950
@@ -62,6 +73,24 @@ class ChatState {
6273 setConnectionStatus(status: 'connected' | 'disconnected' | 'reconnecting') {
6374 this._connectionStatus = status;
6475 }
76+
77+ /**
78+ * Update mention count for a room
79+ */
80+ setRoomMentionCount(roomId: string | null, count: number) {
81+ if (!roomId) return;
82+ const next = Math.max(0, count);
83+ if (this._roomMentionCounts[roomId] === next) return;
84+ this._roomMentionCounts = { ...this._roomMentionCounts, [roomId]: next };
85+ }
86+
87+ /**
88+ * Get mention count for a room
89+ */
90+ getRoomMentionCount(roomId: string | null) {
91+ if (!roomId) return 0;
92+ return this._roomMentionCounts[roomId] ?? 0;
93+ }
6594 }
6695
6796 export const chatState = new ChatState();
@@ -16,9 +16,19 @@ class ChatState {
16 // Connection state for UI feedback16 // Connection state for UI feedback
17 private _connectionStatus = $state<'connected' | 'disconnected' | 'reconnecting'>('disconnected');17 private _connectionStatus = $state<'connected' | 'disconnected' | 'reconnecting'>('disconnected');
18 18
19+ // Mention notifications per room
20+ private _roomMentionCounts = $state<Record<string, number>>({});
21+
19 // Derived public state22 // Derived public state
20 public connectionStatus = $derived(this._connectionStatus);23 public connectionStatus = $derived(this._connectionStatus);
21 public isConnected = $derived(this._connectionStatus === 'connected');24 public isConnected = $derived(this._connectionStatus === 'connected');
25+ public mentionTotal = $derived(
26+ Object.values(this._roomMentionCounts).reduce((sum, count) => sum + count, 0)
27+ );
28+
29+ getMentionTotal() {
30+ return Object.values(this._roomMentionCounts).reduce((sum, count) => sum + count, 0);
31+ }
22 32
23 /**33 /**
24 * Get the current user34 * Get the current user
@@ -34,6 +44,7 @@ class ChatState {
34 if (!user) {44 if (!user) {
35 this._currentUser = null;45 this._currentUser = null;
36 this._connectionStatus = 'disconnected';46 this._connectionStatus = 'disconnected';
47+ this._roomMentionCounts = {};
37 return;48 return;
38 }49 }
39 50
@@ -62,6 +73,24 @@ class ChatState {
62 setConnectionStatus(status: 'connected' | 'disconnected' | 'reconnecting') {73 setConnectionStatus(status: 'connected' | 'disconnected' | 'reconnecting') {
63 this._connectionStatus = status;74 this._connectionStatus = status;
64 }75 }
76+
77+ /**
78+ * Update mention count for a room
79+ */
80+ setRoomMentionCount(roomId: string | null, count: number) {
81+ if (!roomId) return;
82+ const next = Math.max(0, count);
83+ if (this._roomMentionCounts[roomId] === next) return;
84+ this._roomMentionCounts = { ...this._roomMentionCounts, [roomId]: next };
85+ }
86+
87+ /**
88+ * Get mention count for a room
89+ */
90+ getRoomMentionCount(roomId: string | null) {
91+ if (!roomId) return 0;
92+ return this._roomMentionCounts[roomId] ?? 0;
93+ }
65 }94 }
66 95
67 export const chatState = new ChatState();96 export const chatState = new ChatState();
M src/lib/types/text-formatting.ts +24 -4
@@ -6,22 +6,42 @@
66 export const RETRO_FONTS = {
77 pixelated: {
88 name: 'Pixelated MS Sans Serif',
9+ shortName: 'MS Sans Serif',
910 stack: '"Pixelated MS Sans Serif", "Tahoma", Geneva, sans-serif'
1011 },
11- tahoma: { name: 'Tahoma', stack: 'Tahoma, "Pixelated MS Sans Serif", Geneva, sans-serif' },
12- verdana: { name: 'Verdana', stack: 'Verdana, "Pixelated MS Sans Serif", Geneva, sans-serif' },
12+ tahoma: {
13+ name: 'Tahoma',
14+ shortName: 'Tahoma',
15+ stack: 'Tahoma, "Pixelated MS Sans Serif", Geneva, sans-serif'
16+ },
17+ verdana: {
18+ name: 'Verdana',
19+ shortName: 'Verdana',
20+ stack: 'Verdana, "Pixelated MS Sans Serif", Geneva, sans-serif'
21+ },
1322 comicSans: {
1423 name: 'Comic Sans MS',
24+ shortName: 'Comic Sans',
1525 stack: '"Comic Sans MS", "Pixelated MS Sans Serif", cursive, sans-serif'
1626 },
17- timesNewRoman: { name: 'Times New Roman', stack: '"Times New Roman", Times, serif' },
18- arial: { name: 'Arial', stack: 'Arial, "Pixelated MS Sans Serif", Helvetica, sans-serif' },
27+ timesNewRoman: {
28+ name: 'Times New Roman',
29+ shortName: 'Times',
30+ stack: '"Times New Roman", Times, serif'
31+ },
32+ arial: {
33+ name: 'Arial',
34+ shortName: 'Arial',
35+ stack: 'Arial, "Pixelated MS Sans Serif", Helvetica, sans-serif'
36+ },
1937 courierNew: {
2038 name: 'Courier New',
39+ shortName: 'Courier',
2140 stack: '"Courier New", "Perfect DOS VGA 437 Win", Courier, monospace'
2241 },
2342 dos: {
2443 name: 'Perfect DOS VGA 437',
44+ shortName: 'DOS VGA',
2545 stack: '"Perfect DOS VGA 437 Win", "Courier New", Courier, monospace'
2646 }
2747 } as const;
@@ -6,22 +6,42 @@
6 export const RETRO_FONTS = {6 export const RETRO_FONTS = {
7 pixelated: {7 pixelated: {
8 name: 'Pixelated MS Sans Serif',8 name: 'Pixelated MS Sans Serif',
9+ shortName: 'MS Sans Serif',
9 stack: '"Pixelated MS Sans Serif", "Tahoma", Geneva, sans-serif'10 stack: '"Pixelated MS Sans Serif", "Tahoma", Geneva, sans-serif'
10 },11 },
11- tahoma: { name: 'Tahoma', stack: 'Tahoma, "Pixelated MS Sans Serif", Geneva, sans-serif' },12+ tahoma: {
12- verdana: { name: 'Verdana', stack: 'Verdana, "Pixelated MS Sans Serif", Geneva, sans-serif' },13+ name: 'Tahoma',
14+ shortName: 'Tahoma',
15+ stack: 'Tahoma, "Pixelated MS Sans Serif", Geneva, sans-serif'
16+ },
17+ verdana: {
18+ name: 'Verdana',
19+ shortName: 'Verdana',
20+ stack: 'Verdana, "Pixelated MS Sans Serif", Geneva, sans-serif'
21+ },
13 comicSans: {22 comicSans: {
14 name: 'Comic Sans MS',23 name: 'Comic Sans MS',
24+ shortName: 'Comic Sans',
15 stack: '"Comic Sans MS", "Pixelated MS Sans Serif", cursive, sans-serif'25 stack: '"Comic Sans MS", "Pixelated MS Sans Serif", cursive, sans-serif'
16 },26 },
17- timesNewRoman: { name: 'Times New Roman', stack: '"Times New Roman", Times, serif' },27+ timesNewRoman: {
18- arial: { name: 'Arial', stack: 'Arial, "Pixelated MS Sans Serif", Helvetica, sans-serif' },28+ name: 'Times New Roman',
29+ shortName: 'Times',
30+ stack: '"Times New Roman", Times, serif'
31+ },
32+ arial: {
33+ name: 'Arial',
34+ shortName: 'Arial',
35+ stack: 'Arial, "Pixelated MS Sans Serif", Helvetica, sans-serif'
36+ },
19 courierNew: {37 courierNew: {
20 name: 'Courier New',38 name: 'Courier New',
39+ shortName: 'Courier',
21 stack: '"Courier New", "Perfect DOS VGA 437 Win", Courier, monospace'40 stack: '"Courier New", "Perfect DOS VGA 437 Win", Courier, monospace'
22 },41 },
23 dos: {42 dos: {
24 name: 'Perfect DOS VGA 437',43 name: 'Perfect DOS VGA 437',
44+ shortName: 'DOS VGA',
25 stack: '"Perfect DOS VGA 437 Win", "Courier New", Courier, monospace'45 stack: '"Perfect DOS VGA 437 Win", "Courier New", Courier, monospace'
26 }46 }
27 } as const;47 } as const;
M src/lib/utils/text-formatter.ts +9 -0
@@ -125,6 +125,9 @@ function preprocessFormatting(text: string): string {
125125 });
126126 });
127127
128+ // Note: Mention highlighting is applied after sanitization in formatText
129+ // to ensure it's not stripped by the sanitizer
130+
128131 return processedText;
129132 }
130133
@@ -185,6 +188,12 @@ export async function formatText(
185188 return escapeHtml(text);
186189 }
187190
191+ // Apply mention highlighting after sanitization
192+ formattedHtml = formattedHtml.replace(
193+ /(^|[\s>])@([a-zA-Z0-9_-]{1,32})(?=$|[\s<]|[^a-zA-Z0-9_-])/g,
194+ '$1<span class="mention-token">@$2</span>'
195+ );
196+
188197 return formattedHtml;
189198 } catch (error) {
190199 console.error('Text formatting error:', error);
@@ -125,6 +125,9 @@ function preprocessFormatting(text: string): string {
125 });125 });
126 });126 });
127 127
128+ // Note: Mention highlighting is applied after sanitization in formatText
129+ // to ensure it's not stripped by the sanitizer
130+
128 return processedText;131 return processedText;
129 }132 }
130 133
@@ -185,6 +188,12 @@ export async function formatText(
185 return escapeHtml(text);188 return escapeHtml(text);
186 }189 }
187 190
191+ // Apply mention highlighting after sanitization
192+ formattedHtml = formattedHtml.replace(
193+ /(^|[\s>])@([a-zA-Z0-9_-]{1,32})(?=$|[\s<]|[^a-zA-Z0-9_-])/g,
194+ '$1<span class="mention-token">@$2</span>'
195+ );
196+
188 return formattedHtml;197 return formattedHtml;
189 } catch (error) {198 } catch (error) {
190 console.error('Text formatting error:', error);199 console.error('Text formatting error:', error);

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