detached baa04b0

Merge pull request #79 from kitpdr/feat/mention-shortcut

feat(chat): add @mention autocomplete with highlighting and unread tracking
commit
baa04b0f838c7a535da58ae8f5101ada46803a5f
author
KiT · · 2026-01-19 13:49 UTC
committer
GitHub · · 2026-01-19 13:49 UTC
merge of
6aaf2c1 + 23f928a · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
10 files · +1229 -45
M src/lib/components/aim-login.svelte +10 -4
@@ -15,9 +15,15 @@
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 = $bindable<'signin' | 'signup'>('signin')
22+ } = $props<{
23+ showAuth?: boolean;
24+ onLoginSuccess?: () => void;
25+ activeTab?: 'signin' | 'signup';
26+ }>();
2127
2228 // Sign In state
2329 let siUsername = $state('');
@@ -229,7 +235,7 @@
229235 }
230236 }
231237
232- function switchTab(tab: string) {
238+ function switchTab(tab: 'signin' | 'signup') {
233239 activeTab = tab;
234240 error = '';
235241 signupError = '';
@@ -15,9 +15,15 @@
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 = $bindable<'signin' | 'signup'>('signin')
22+ } = $props<{
23+ showAuth?: boolean;
24+ onLoginSuccess?: () => void;
25+ activeTab?: 'signin' | 'signup';
26+ }>();
21 27
22 // Sign In state28 // Sign In state
23 let siUsername = $state('');29 let siUsername = $state('');
@@ -229,7 +235,7 @@
229 }235 }
230 }236 }
231 237
232- function switchTab(tab: string) {238+ function switchTab(tab: 'signin' | 'signup') {
233 activeTab = tab;239 activeTab = tab;
234 error = '';240 error = '';
235 signupError = '';241 signupError = '';
M src/lib/components/chat-room.svelte +580 -33
@@ -4,7 +4,8 @@
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';
8+ import { SvelteSet } from 'svelte/reactivity';
89 import { browser } from '$app/environment';
910 import { draggable } from '$lib/actions/draggable';
1011 import { resizable } from '$lib/actions/resizable';
@@ -16,11 +17,17 @@
1617 import AimLogin from './aim-login.svelte';
1718 import FormattedMessage from './formatted-message.svelte';
1819 import TextFormattingToolbar from './text-formatting-toolbar.svelte';
20+ import MentionPicker from './mention-picker.svelte';
1921 import {
20- DEFAULT_TEXT_STYLE,
21- type TextStyle,
22- generateInputCSSStyle
23- } from '../types/text-formatting';
22+ MAX_MENTION_SUGGESTIONS,
23+ MENTION_PICKER,
24+ findMentionContext,
25+ highlightMentionsInput,
26+ containsMention,
27+ calculatePickerHeight,
28+ calculatePickerWidth
29+ } from '../utils/mention';
30+ import { DEFAULT_TEXT_STYLE, type TextStyle } from '../types/text-formatting';
2431 import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format';
2532 import { loadWindowState, saveWindowState, debounce } from '$lib/utils/chat-window-state';
2633 import { MAX_MESSAGE_LENGTH } from '$lib/validation/message';
@@ -146,12 +153,34 @@
146153
147154 let currentMessage = $state('');
148155 let inputScrollLeft = $state(0);
156+ // Initialize text style with prop value (intentionally capturing initial value only)
149157 let currentTextStyle = $state<TextStyle>({
150158 ...DEFAULT_TEXT_STYLE,
151159 ...initialTextStyle,
152160 color: initialTextStyle.color || '#000000'
153161 });
154162
163+ // ============ MENTION STATE ============
164+
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 = $state<Set<string>>(new Set());
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 mentionListStyle = $state('');
181+ let mentionListInside = $state(true);
182+ let mentionMeasureContext: CanvasRenderingContext2D | null = null;
183+
155184 // ============ RATE LIMITING STATE ============
156185
157186 let cooldownEndTime = $state<number | null>(null);
@@ -198,19 +227,55 @@
198227 const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline'));
199228 const usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline'));
200229
230+ const mentionableUsers = $derived(
231+ onlineUsers.filter((user) => !currentUser || user.id !== currentUser.id)
232+ );
233+
234+ // Filter users for mention autocomplete
235+ // Empty query (just "@") shows all users to let user browse the list
236+ const mentionSuggestions = $derived.by<SafeUser[]>(() => {
237+ if (!mentionOpen) return [];
238+ const query = mentionQuery.trim().toLowerCase();
239+ const candidates = query
240+ ? mentionableUsers.filter((user) => user.nickname.toLowerCase().startsWith(query))
241+ : mentionableUsers;
242+ return candidates.slice(0, MAX_MENTION_SUGGESTIONS);
243+ });
244+
245+ // Format input message with mention highlighting (for overlay display)
246+ const formattedInputMessage = $derived.by(() => {
247+ if (!currentMessage) return '';
248+ // Escape HTML entities first
249+ const escaped = currentMessage
250+ .replace(/&/g, '&amp;')
251+ .replace(/</g, '&lt;')
252+ .replace(/>/g, '&gt;');
253+ // Apply mention styling using shared utility
254+ return highlightMentionsInput(escaped);
255+ });
256+
257+ // mentionUnreadCount is managed in state updates
258+ const hasMentionHighlight = $derived.by<Record<string, boolean>>(() => {
259+ const highlights: Record<string, boolean> = {};
260+ if (!currentUser) return highlights;
261+ for (const message of visibleMessages) {
262+ highlights[message.id] = shouldHighlightMention(message);
263+ }
264+ return highlights;
265+ });
266+
201267 // ============ EFFECTS ============
202268
203269 // Auto-scroll to bottom when new messages arrive
204270 $effect(() => {
205271 if (messages.length > lastMessageCount && messages.length > 0) {
206272 lastMessageCount = messages.length;
207- if (browser) {
208- const chatArea = document.querySelector('.chat-area');
209- if (chatArea) {
210- setTimeout(() => {
273+ if (browser && chatArea) {
274+ tick().then(() => {
275+ if (chatArea) {
211276 chatArea.scrollTop = chatArea.scrollHeight;
212- }, 0);
213- }
277+ }
278+ });
214279 }
215280 }
216281 });
@@ -222,6 +287,173 @@
222287 }
223288 });
224289
290+ $effect(() => {
291+ if (!browser) return;
292+ if (!baseTitle) baseTitle = document.title;
293+ const totalMentions = chatState.getMentionTotal();
294+ if (totalMentions > 0) {
295+ document.title = `(${totalMentions}) ${baseTitle}`;
296+ } else {
297+ document.title = baseTitle;
298+ }
299+ });
300+
301+ $effect(() => {
302+ chatState.setRoomMentionCount(roomId ?? null, mentionUnreadCount);
303+ });
304+
305+ $effect(() => {
306+ if (!browser || !currentUser) {
307+ mentionObservedIds = new SvelteSet();
308+ mentionMessageIds = new SvelteSet();
309+ if (mentionUnreadCount !== 0) {
310+ mentionUnreadCount = 0;
311+ }
312+ // mention observer reset
313+ if (mentionObserver) {
314+ mentionObserver.disconnect();
315+ mentionObserver = null;
316+ }
317+ return;
318+ }
319+ const mentionIds = new SvelteSet<string>();
320+ for (const message of visibleMessages) {
321+ if (shouldHighlightMention(message)) {
322+ mentionIds.add(message.id);
323+ }
324+ }
325+ const nextMentionIds = mentionIds;
326+ if (!setsEqual(nextMentionIds, mentionMessageIds)) {
327+ mentionMessageIds = nextMentionIds;
328+ }
329+ const nextUnreadCount = Array.from(nextMentionIds).filter(
330+ (messageId) => !mentionObservedIds.has(messageId)
331+ ).length;
332+ // mentionObservedIds is a local Set to avoid effect loops
333+ if (mentionUnreadCount !== nextUnreadCount) {
334+ mentionUnreadCount = nextUnreadCount;
335+ }
336+ });
337+
338+ $effect(() => {
339+ const currentMentionIds = mentionMessageIds;
340+ if (!browser || !chatArea || !currentUser) return;
341+ if (mentionObserver) {
342+ mentionObserver.disconnect();
343+ }
344+ mentionObserver = new IntersectionObserver(
345+ (entries) => {
346+ const updatedObserved = new SvelteSet(mentionObservedIds);
347+ let changed = false;
348+ const currentObserved = mentionObservedIds;
349+ const currentUnread = mentionUnreadCount;
350+ for (const entry of entries) {
351+ const target = entry.target as HTMLElement;
352+ const messageId = target.dataset.messageId;
353+ if (!messageId || !currentMentionIds.has(messageId)) continue;
354+ if (entry.isIntersecting && !updatedObserved.has(messageId)) {
355+ updatedObserved.add(messageId);
356+ changed = true;
357+ }
358+ }
359+ if (changed && !setsEqual(updatedObserved, currentObserved)) {
360+ mentionObservedIds = updatedObserved;
361+ const nextUnread = Math.max(0, currentMentionIds.size - updatedObserved.size);
362+ if (currentUnread !== nextUnread) {
363+ mentionUnreadCount = nextUnread;
364+ }
365+ }
366+ },
367+ { root: chatArea, threshold: 0.6 }
368+ );
369+ const nodes = Array.from(chatArea.querySelectorAll<HTMLElement>('[data-message-id]'));
370+ for (const node of nodes) {
371+ const messageId = node.dataset.messageId;
372+ if (messageId && currentMentionIds.has(messageId)) {
373+ mentionObserver.observe(node);
374+ }
375+ }
376+ // only observe mention messages
377+ // mention observer reset
378+ return () => {
379+ mentionObserver?.disconnect();
380+ };
381+ });
382+
383+ $effect(() => {
384+ if (!mentionOpen || mentionSuggestions.length === 0) {
385+ if (mentionActiveId !== null) {
386+ mentionActiveId = null;
387+ }
388+ return;
389+ }
390+ const currentActiveId = mentionActiveId;
391+ let selectedIndex = mentionSelectedIndex;
392+ if (selectedIndex >= mentionSuggestions.length) {
393+ selectedIndex = 0;
394+ if (mentionSelectedIndex !== 0) {
395+ mentionSelectedIndex = 0;
396+ }
397+ }
398+ const active = mentionSuggestions[selectedIndex];
399+ const nextActiveId = active ? getMentionListItemId(active.id) : null;
400+ if (currentActiveId !== nextActiveId) {
401+ mentionActiveId = nextActiveId;
402+ }
403+ });
404+
405+ $effect(() => {
406+ if (!mentionOpen || !messageInput) {
407+ if (mentionListStyle) mentionListStyle = '';
408+ return;
409+ }
410+ const currentListStyle = mentionListStyle;
411+ const currentInside = mentionListInside;
412+ const inputRect = messageInput.getBoundingClientRect();
413+ const containerRect = messageInput.closest('.chat-container')?.getBoundingClientRect();
414+ // Use shared utility for height calculation
415+ const listHeight = calculatePickerHeight(mentionSuggestions.length);
416+ const spaceBelow = containerRect
417+ ? containerRect.bottom - inputRect.bottom
418+ : window.innerHeight - inputRect.bottom;
419+ const spaceAbove = containerRect ? inputRect.top - containerRect.top : inputRect.top;
420+ const showAbove = spaceBelow < listHeight && spaceAbove > listHeight;
421+ const maxNameWidth = mentionSuggestions.reduce((max, user) => {
422+ const width = measureMentionText(user.nickname, true, 13.6);
423+ return Math.max(max, width);
424+ }, 0);
425+ // Status uses 0.7rem (11.2px) font size and uppercase (add ~10% for letter-spacing)
426+ const statusWidth = measureMentionText('OFFLINE', false, 11.2) * 1.1;
427+ // Use shared utility for width calculation
428+ const listWidth = calculatePickerWidth(maxNameWidth, statusWidth);
429+ if (containerRect) {
430+ if (!currentInside) mentionListInside = true;
431+ const top = showAbove
432+ ? inputRect.top - containerRect.top - listHeight - MENTION_PICKER.OFFSET_GAP
433+ : inputRect.bottom - containerRect.top + MENTION_PICKER.OFFSET_GAP;
434+ const left = inputRect.left - containerRect.left;
435+ const nextStyle = `top: ${Math.max(MENTION_PICKER.EDGE_MARGIN, top)}px; left: ${Math.max(MENTION_PICKER.EDGE_MARGIN, left)}px; width: ${listWidth}px;`;
436+ if (currentListStyle !== nextStyle) {
437+ mentionListStyle = nextStyle;
438+ }
439+ } else {
440+ if (currentInside) mentionListInside = false;
441+ const top = showAbove
442+ ? inputRect.top - listHeight - MENTION_PICKER.OFFSET_GAP
443+ : inputRect.bottom + MENTION_PICKER.OFFSET_GAP;
444+ const nextStyle = `top: ${Math.max(MENTION_PICKER.EDGE_MARGIN, top)}px; left: ${Math.max(MENTION_PICKER.EDGE_MARGIN, inputRect.left)}px; width: ${listWidth}px;`;
445+ if (currentListStyle !== nextStyle) {
446+ mentionListStyle = nextStyle;
447+ }
448+ }
449+ if (mentionSuggestions.length === 0 && mentionActiveId !== null) {
450+ mentionActiveId = null;
451+ }
452+ if (mentionSelectedIndex >= mentionSuggestions.length && mentionSuggestions.length > 0) {
453+ mentionSelectedIndex = 0;
454+ }
455+ });
456+
225457 // ============ HELPERS ============
226458
227459 function resolveUser(senderId: string): SafeUser {
@@ -253,6 +485,143 @@
253485
254486 const debouncedSaveWindowState = debounce(saveWindowState, 300);
255487
488+ function setsEqual(a: Set<string>, b: Set<string>) {
489+ if (a.size !== b.size) return false;
490+ for (const value of a) {
491+ if (!b.has(value)) return false;
492+ }
493+ return true;
494+ }
495+
496+ function measureMentionText(text: string, bold: boolean = true, fontSizePx: number = 13.6) {
497+ if (!mentionMeasureContext) {
498+ const canvas = document.createElement('canvas');
499+ mentionMeasureContext = canvas.getContext('2d');
500+ }
501+ if (!mentionMeasureContext) return 0;
502+ mentionMeasureContext.font = `${bold ? 'bold ' : ''}${fontSizePx}px 'Pixelated MS Sans Serif', Tahoma, sans-serif`;
503+ return mentionMeasureContext.measureText(text).width;
504+ }
505+
506+ function updateMentionState(input: HTMLInputElement) {
507+ if (!currentUser) {
508+ mentionOpen = false;
509+ return;
510+ }
511+ const cursorIndex = input.selectionStart ?? input.value.length;
512+ const context = findMentionContext(input.value, cursorIndex);
513+ if (!context) {
514+ closeMentionPicker();
515+ return;
516+ }
517+ const mentionKey = `${context.atIndex}:${context.query}`;
518+ if (mentionDismissKey === mentionKey) {
519+ return;
520+ }
521+ const wasOpen = mentionOpen;
522+ const previousQuery = mentionQuery;
523+ const sameAnchor = mentionAnchorIndex === context.atIndex;
524+ mentionOpen = true;
525+ mentionQuery = context.query;
526+ mentionAnchorIndex = context.atIndex;
527+ mentionDismissKey = null;
528+ if (!wasOpen || !sameAnchor || previousQuery !== context.query) {
529+ mentionSelectedIndex = 0;
530+ }
531+ }
532+
533+ function registerMentionNode(node: HTMLElement) {
534+ if (!node) return;
535+ const messageId = node.dataset.messageId;
536+ if (!messageId) return;
537+ if (mentionObserver) {
538+ mentionObserver.observe(node);
539+ }
540+ // Return destroy method to properly cleanup when node is removed
541+ return {
542+ destroy() {
543+ if (mentionObserver) {
544+ mentionObserver.unobserve(node);
545+ }
546+ }
547+ };
548+ }
549+
550+ function closeMentionPicker() {
551+ mentionOpen = false;
552+ mentionQuery = '';
553+ mentionAnchorIndex = null;
554+ mentionSelectedIndex = 0;
555+ mentionActiveId = null;
556+ mentionDismissKey = null;
557+ if (messageInput) {
558+ messageInput.focus();
559+ }
560+ }
561+
562+ function clampMentionIndex(nextIndex: number) {
563+ if (mentionSuggestions.length === 0) return 0;
564+ const maxIndex = mentionSuggestions.length - 1;
565+ if (nextIndex < 0) return maxIndex;
566+ if (nextIndex > maxIndex) return 0;
567+ return nextIndex;
568+ }
569+
570+ function applyMentionSelection(user: SafeUser) {
571+ if (!messageInput || mentionAnchorIndex === null) return;
572+ const selectionStart = messageInput.selectionStart ?? currentMessage.length;
573+ const before = currentMessage.slice(0, mentionAnchorIndex);
574+ const after = currentMessage.slice(selectionStart);
575+ const mentionText = `@${user.nickname} `;
576+ currentMessage = `${before}${mentionText}${after}`;
577+ void tick().then(() => {
578+ if (!messageInput) return;
579+ const cursor = before.length + mentionText.length;
580+ messageInput.focus();
581+ messageInput.setSelectionRange(cursor, cursor);
582+ });
583+ closeMentionPicker();
584+ }
585+
586+ function handleMentionKeydown(event: KeyboardEvent) {
587+ if (!mentionOpen || mentionSuggestions.length === 0) return;
588+ if (event.key === 'ArrowDown') {
589+ event.preventDefault();
590+ mentionSelectedIndex = clampMentionIndex(mentionSelectedIndex + 1);
591+ void tick().then(() => scrollMentionOptionIntoView());
592+ return;
593+ }
594+ if (event.key === 'ArrowUp') {
595+ event.preventDefault();
596+ mentionSelectedIndex = clampMentionIndex(mentionSelectedIndex - 1);
597+ void tick().then(() => scrollMentionOptionIntoView());
598+ return;
599+ }
600+ if (event.key === 'Enter' || event.key === 'Tab') {
601+ event.preventDefault();
602+ const choice = mentionSuggestions[mentionSelectedIndex];
603+ if (choice) applyMentionSelection(choice);
604+ return;
605+ }
606+ if (event.key === 'Escape') {
607+ event.preventDefault();
608+ if (mentionOpen && mentionAnchorIndex !== null) {
609+ mentionDismissKey = `${mentionAnchorIndex}:${mentionQuery}`;
610+ }
611+ closeMentionPicker();
612+ }
613+ }
614+
615+ function getMentionListItemId(userId: string) {
616+ return `${mentionListBoxId}-${userId}`;
617+ }
618+
619+ function scrollMentionOptionIntoView() {
620+ if (!mentionActiveId) return;
621+ const option = document.getElementById(mentionActiveId);
622+ option?.scrollIntoView({ block: 'nearest' });
623+ }
624+
256625 function updateCooldownProgress() {
257626 if (!cooldownEndTime) return;
258627 const now = Date.now();
@@ -282,6 +651,12 @@
282651 showChatRoom = false;
283652 }
284653
654+ function shouldHighlightMention(message: EnrichedMessage) {
655+ if (!currentUser) return false;
656+ if (message.senderId === currentUser.id) return false;
657+ return containsMention(message.content, currentUser.nickname);
658+ }
659+
285660 function handleInputChange(e: Event) {
286661 const input = e.target as HTMLInputElement;
287662 if (input.value.length > MAX_MESSAGE_LENGTH) {
@@ -289,12 +664,51 @@
289664 currentMessage = input.value;
290665 }
291666 inputScrollLeft = input.scrollLeft;
667+ updateMentionState(input);
668+ if (!mentionOpen && mentionQuery) {
669+ mentionQuery = '';
670+ }
292671 }
293672
294673 function handleInputScroll(e: Event) {
295674 inputScrollLeft = (e.target as HTMLInputElement).scrollLeft;
296675 }
297676
677+ function handleInputFocus() {
678+ if (!messageInput) return;
679+ updateMentionState(messageInput);
680+ }
681+
682+ function handleInputClick() {
683+ if (!messageInput) return;
684+ updateMentionState(messageInput);
685+ }
686+
687+ function handleInputKeydown(event: KeyboardEvent) {
688+ if (!messageInput) return;
689+ if (event.key === 'Escape') {
690+ event.preventDefault();
691+ if (mentionOpen && mentionAnchorIndex !== null) {
692+ mentionDismissKey = `${mentionAnchorIndex}:${mentionQuery}`;
693+ }
694+ closeMentionPicker();
695+ return;
696+ }
697+ if (mentionOpen) {
698+ handleMentionKeydown(event);
699+ return;
700+ }
701+ if (event.key === 'Enter') {
702+ event.preventDefault();
703+ void handleSubmit();
704+ }
705+ }
706+
707+ function handleInputKeyup() {
708+ if (!messageInput) return;
709+ updateMentionState(messageInput);
710+ }
711+
298712 async function handleSubmit() {
299713 if (!currentMessage.trim() || !currentUser || cooldownEndTime) return;
300714
@@ -520,6 +934,8 @@
520934 return () => {
521935 window.removeEventListener('resize', handleResize);
522936 if (cooldownInterval) clearInterval(cooldownInterval);
937+ // Clean up canvas context used for text measurement
938+ mentionMeasureContext = null;
523939 };
524940 });
525941
@@ -537,6 +953,14 @@
537953 isCentered
538954 });
539955 });
956+
957+ // Scroll selected mention option into view when selection changes
958+ $effect(() => {
959+ if (!mentionOpen || mentionSuggestions.length === 0) return;
960+ // Track mentionSelectedIndex to trigger scroll on keyboard navigation
961+ void mentionSelectedIndex;
962+ void tick().then(() => scrollMentionOptionIntoView());
963+ });
540964 </script>
541965
542966 {#if showChatRoom}
@@ -567,7 +991,8 @@
567991 class="title-bar-text"
568992 style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: 'MS Sans Serif', 'Pixelated MS Sans Serif', sans-serif;"
569993 >
570- Pdr Aim {#if currentUser}
994+ Pdr Aim {#if mentionUnreadCount > 0}({mentionUnreadCount})
995+ {/if}{#if currentUser}
571996 - {currentUser.nickname}{:else}
572997 - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if}
573998 {#if connectionError && !isMinimized}
@@ -640,6 +1065,7 @@
6401065 class="sunken-panel chat-area"
6411066 style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"
6421067 onscroll={(e) => handleScroll(e)}
1068+ bind:this={chatArea}
6431069 >
6441070 {#if isInitialLoading}
6451071 <div class="initial-loading">
@@ -659,7 +1085,18 @@
6591085 </div>
6601086 {/if}
6611087 {#each visibleMessages as message (message.id)}
662- <div class="message {message.type} text">
1088+ <div
1089+ class="message {message.type} text"
1090+ class:mention-highlight={hasMentionHighlight[message.id]}
1091+ data-message-id={message.id}
1092+ data-mention={hasMentionHighlight[message.id]
1093+ ? `Mention de @${currentUser?.nickname ?? ''}`
1094+ : undefined}
1095+ aria-label={hasMentionHighlight[message.id]
1096+ ? `Message mentionnant ${currentUser?.nickname ?? ''}`
1097+ : undefined}
1098+ use:registerMentionNode
1099+ >
6631100 {#if message.type === 'emote'}
6641101 <span class="emote-text">
6651102 <Tooltip
@@ -737,8 +1174,11 @@
7371174 -webkit-text-fill-color: transparent;
7381175 background-clip: text;
7391176 "
740- aria-hidden="true">{currentMessage}</span
1177+ aria-hidden="true"
7411178 >
1179+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedInputMessage is sanitized by highlightMentionsInput -->
1180+ {@html formattedInputMessage}
1181+ </span>
7421182 <input
7431183 type="text"
7441184 bind:value={currentMessage}
@@ -753,28 +1193,86 @@
7531193 : ''} {currentTextStyle.underline || currentTextStyle.strikethrough
7541194 ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
7551195 : ''}"
756- onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
1196+ onkeydown={handleInputKeydown}
1197+ onkeyup={handleInputKeyup}
7571198 oninput={handleInputChange}
7581199 onscroll={handleInputScroll}
1200+ onfocus={handleInputFocus}
1201+ onclick={handleInputClick}
1202+ bind:this={messageInput}
7591203 placeholder={cooldownEndTime
7601204 ? `Patientez ${cooldownProgress.toFixed(1)}s...`
7611205 : 'Écrivez un message...'}
7621206 disabled={!currentUser || Boolean(cooldownEndTime)}
1207+ role="combobox"
1208+ aria-autocomplete="list"
1209+ aria-controls={mentionOpen ? mentionListBoxId : undefined}
1210+ aria-expanded={mentionOpen}
7631211 />
7641212 </div>
7651213 {: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- />
1214+ <div
1215+ class="mention-input-wrapper"
1216+ style="flex: 1; position: relative; background: white; overflow: hidden;"
1217+ >
1218+ <!-- Text overlay with mention highlighting -->
1219+ <span
1220+ class="mention-text-overlay retro-font-{currentTextStyle.fontFamily}"
1221+ style="
1222+ position: absolute;
1223+ left: 3px;
1224+ top: 50%;
1225+ transform: translateY(-50%) translateX(-{inputScrollLeft}px);
1226+ pointer-events: none;
1227+ white-space: nowrap;
1228+ font-size: {currentTextStyle.fontSize}px;
1229+ color: {currentTextStyle.color || '#000000'};
1230+ {currentTextStyle.bold
1231+ ? currentTextStyle.fontFamily === 'tahoma'
1232+ ? 'font-weight: 200;'
1233+ : 'font-weight: 700;'
1234+ : ''}
1235+ {currentTextStyle.italic ? 'font-style: italic;' : ''}
1236+ {currentTextStyle.underline || currentTextStyle.strikethrough
1237+ ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1238+ : ''}
1239+ "
1240+ aria-hidden="true"
1241+ >
1242+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedInputMessage is sanitized by highlightMentionsInput -->
1243+ {@html formattedInputMessage}
1244+ </span>
1245+ <input
1246+ type="text"
1247+ bind:value={currentMessage}
1248+ maxlength={MAX_MESSAGE_LENGTH}
1249+ class="styled-input mention-overlay-input retro-font-{currentTextStyle.fontFamily}"
1250+ style="width: 100%; background: transparent; color: transparent; font-size: {currentTextStyle.fontSize}px; {currentTextStyle.bold
1251+ ? currentTextStyle.fontFamily === 'tahoma'
1252+ ? 'font-weight: 200;'
1253+ : 'font-weight: 700;'
1254+ : ''} {currentTextStyle.italic
1255+ ? 'font-style: italic;'
1256+ : ''} {currentTextStyle.underline || currentTextStyle.strikethrough
1257+ ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1258+ : ''} caret-color: {currentTextStyle.color || 'black'};"
1259+ onkeydown={handleInputKeydown}
1260+ onkeyup={handleInputKeyup}
1261+ oninput={handleInputChange}
1262+ onscroll={handleInputScroll}
1263+ onfocus={handleInputFocus}
1264+ onclick={handleInputClick}
1265+ bind:this={messageInput}
1266+ placeholder={cooldownEndTime
1267+ ? `Patientez ${cooldownProgress.toFixed(1)}s...`
1268+ : 'Écrivez un message...'}
1269+ disabled={!currentUser || Boolean(cooldownEndTime)}
1270+ role="combobox"
1271+ aria-autocomplete="list"
1272+ aria-controls={mentionOpen ? mentionListBoxId : undefined}
1273+ aria-expanded={mentionOpen}
1274+ />
1275+ </div>
7781276 {/if}
7791277 <LoadingButton
7801278 onclick={handleSubmit}
@@ -788,6 +1286,18 @@
7881286 </span>
7891287 {/if}
7901288 </div>
1289+ {#if mentionOpen}
1290+ <MentionPicker
1291+ suggestions={mentionSuggestions}
1292+ selectedIndex={mentionSelectedIndex}
1293+ activeId={mentionActiveId}
1294+ listBoxId={mentionListBoxId}
1295+ listStyle={mentionListStyle}
1296+ isPortal={!mentionListInside}
1297+ onSelect={applyMentionSelection}
1298+ />
1299+ {/if}
1300+
7911301 {#if cooldownEndTime}
7921302 <div
7931303 class="cooldown-progress"
@@ -936,6 +1446,41 @@
9361446 color: #666;
9371447 }
9381448
1449+ .message-content :global(.mention-token) {
1450+ color: #1e2a72;
1451+ font-weight: bold;
1452+ }
1453+
1454+ /* Input mention highlighting */
1455+ .mention-input-wrapper,
1456+ .gradient-input-wrapper {
1457+ flex: 1;
1458+ position: relative;
1459+ background: white;
1460+ overflow: hidden;
1461+ }
1462+
1463+ .mention-text-overlay,
1464+ .gradient-text-overlay {
1465+ position: absolute;
1466+ left: 3px;
1467+ top: 50%;
1468+ pointer-events: none;
1469+ white-space: nowrap;
1470+ }
1471+
1472+ .mention-text-overlay :global(.input-mention),
1473+ .gradient-text-overlay :global(.input-mention) {
1474+ color: #1e2a72;
1475+ -webkit-text-fill-color: #1e2a72;
1476+ }
1477+
1478+ /* Force input text to be transparent when using overlay */
1479+ .mention-overlay-input {
1480+ color: transparent !important;
1481+ -webkit-text-fill-color: transparent !important;
1482+ }
1483+
9391484 .section-header {
9401485 margin: 0 0 0.25rem 0;
9411486 font-weight: bold;
@@ -1000,8 +1545,7 @@
10001545 z-index: 2;
10011546 }
10021547
1003- .input-container input:disabled,
1004- .input-container {
1548+ .input-container input:disabled {
10051549 opacity: 0.7;
10061550 background-color: rgba(128, 128, 128, 0.1);
10071551 cursor: not-allowed;
@@ -1160,10 +1704,6 @@
11601704 font-size: 0.875rem;
11611705 }
11621706
1163- .error-banner small {
1164- color: #666;
1165- }
1166-
11671707 .rate-limit-warning {
11681708 background: #fff3e0;
11691709 color: #e65100;
@@ -1198,6 +1738,13 @@
11981738 }
11991739 }
12001740
1741+ .message.mention-highlight {
1742+ background: rgba(255, 237, 186, 0.45);
1743+ border-radius: 4px;
1744+ padding: 0.2rem 0.35rem;
1745+ box-shadow: inset 0 0 0 1px rgba(178, 136, 0, 0.2);
1746+ }
1747+
12011748 .cooldown-progress {
12021749 position: absolute;
12031750 bottom: 0;
@@ -4,7 +4,8 @@
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 { SvelteSet } from 'svelte/reactivity';
8 import { browser } from '$app/environment';9 import { browser } from '$app/environment';
9 import { draggable } from '$lib/actions/draggable';10 import { draggable } from '$lib/actions/draggable';
10 import { resizable } from '$lib/actions/resizable';11 import { resizable } from '$lib/actions/resizable';
@@ -16,11 +17,17 @@
16 import AimLogin from './aim-login.svelte';17 import AimLogin from './aim-login.svelte';
17 import FormattedMessage from './formatted-message.svelte';18 import FormattedMessage from './formatted-message.svelte';
18 import TextFormattingToolbar from './text-formatting-toolbar.svelte';19 import TextFormattingToolbar from './text-formatting-toolbar.svelte';
20+ import MentionPicker from './mention-picker.svelte';
19 import {21 import {
20- DEFAULT_TEXT_STYLE,22+ MAX_MENTION_SUGGESTIONS,
21- type TextStyle,23+ MENTION_PICKER,
22- generateInputCSSStyle24+ findMentionContext,
23- } from '../types/text-formatting';25+ highlightMentionsInput,
26+ containsMention,
27+ calculatePickerHeight,
28+ calculatePickerWidth
29+ } from '../utils/mention';
30+ import { DEFAULT_TEXT_STYLE, type TextStyle } from '../types/text-formatting';
24 import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format';31 import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format';
25 import { loadWindowState, saveWindowState, debounce } from '$lib/utils/chat-window-state';32 import { loadWindowState, saveWindowState, debounce } from '$lib/utils/chat-window-state';
26 import { MAX_MESSAGE_LENGTH } from '$lib/validation/message';33 import { MAX_MESSAGE_LENGTH } from '$lib/validation/message';
@@ -146,12 +153,34 @@
146 153
147 let currentMessage = $state('');154 let currentMessage = $state('');
148 let inputScrollLeft = $state(0);155 let inputScrollLeft = $state(0);
156+ // Initialize text style with prop value (intentionally capturing initial value only)
149 let currentTextStyle = $state<TextStyle>({157 let currentTextStyle = $state<TextStyle>({
150 ...DEFAULT_TEXT_STYLE,158 ...DEFAULT_TEXT_STYLE,
151 ...initialTextStyle,159 ...initialTextStyle,
152 color: initialTextStyle.color || '#000000'160 color: initialTextStyle.color || '#000000'
153 });161 });
154 162
163+ // ============ MENTION STATE ============
164+
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 = $state<Set<string>>(new Set());
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 mentionListStyle = $state('');
181+ let mentionListInside = $state(true);
182+ let mentionMeasureContext: CanvasRenderingContext2D | null = null;
183+
155 // ============ RATE LIMITING STATE ============184 // ============ RATE LIMITING STATE ============
156 185
157 let cooldownEndTime = $state<number | null>(null);186 let cooldownEndTime = $state<number | null>(null);
@@ -198,19 +227,55 @@
198 const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline'));227 const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline'));
199 const usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline'));228 const usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline'));
200 229
230+ const mentionableUsers = $derived(
231+ onlineUsers.filter((user) => !currentUser || user.id !== currentUser.id)
232+ );
233+
234+ // Filter users for mention autocomplete
235+ // Empty query (just "@") shows all users to let user browse the list
236+ const mentionSuggestions = $derived.by<SafeUser[]>(() => {
237+ if (!mentionOpen) return [];
238+ const query = mentionQuery.trim().toLowerCase();
239+ const candidates = query
240+ ? mentionableUsers.filter((user) => user.nickname.toLowerCase().startsWith(query))
241+ : mentionableUsers;
242+ return candidates.slice(0, MAX_MENTION_SUGGESTIONS);
243+ });
244+
245+ // Format input message with mention highlighting (for overlay display)
246+ const formattedInputMessage = $derived.by(() => {
247+ if (!currentMessage) return '';
248+ // Escape HTML entities first
249+ const escaped = currentMessage
250+ .replace(/&/g, '&amp;')
251+ .replace(/</g, '&lt;')
252+ .replace(/>/g, '&gt;');
253+ // Apply mention styling using shared utility
254+ return highlightMentionsInput(escaped);
255+ });
256+
257+ // mentionUnreadCount is managed in state updates
258+ const hasMentionHighlight = $derived.by<Record<string, boolean>>(() => {
259+ const highlights: Record<string, boolean> = {};
260+ if (!currentUser) return highlights;
261+ for (const message of visibleMessages) {
262+ highlights[message.id] = shouldHighlightMention(message);
263+ }
264+ return highlights;
265+ });
266+
201 // ============ EFFECTS ============267 // ============ EFFECTS ============
202 268
203 // Auto-scroll to bottom when new messages arrive269 // Auto-scroll to bottom when new messages arrive
204 $effect(() => {270 $effect(() => {
205 if (messages.length > lastMessageCount && messages.length > 0) {271 if (messages.length > lastMessageCount && messages.length > 0) {
206 lastMessageCount = messages.length;272 lastMessageCount = messages.length;
207- if (browser) {273+ if (browser && chatArea) {
208- const chatArea = document.querySelector('.chat-area');274+ tick().then(() => {
209- if (chatArea) {275+ if (chatArea) {
210- setTimeout(() => {
211 chatArea.scrollTop = chatArea.scrollHeight;276 chatArea.scrollTop = chatArea.scrollHeight;
212- }, 0);277+ }
213- }278+ });
214 }279 }
215 }280 }
216 });281 });
@@ -222,6 +287,173 @@
222 }287 }
223 });288 });
224 289
290+ $effect(() => {
291+ if (!browser) return;
292+ if (!baseTitle) baseTitle = document.title;
293+ const totalMentions = chatState.getMentionTotal();
294+ if (totalMentions > 0) {
295+ document.title = `(${totalMentions}) ${baseTitle}`;
296+ } else {
297+ document.title = baseTitle;
298+ }
299+ });
300+
301+ $effect(() => {
302+ chatState.setRoomMentionCount(roomId ?? null, mentionUnreadCount);
303+ });
304+
305+ $effect(() => {
306+ if (!browser || !currentUser) {
307+ mentionObservedIds = new SvelteSet();
308+ mentionMessageIds = new SvelteSet();
309+ if (mentionUnreadCount !== 0) {
310+ mentionUnreadCount = 0;
311+ }
312+ // mention observer reset
313+ if (mentionObserver) {
314+ mentionObserver.disconnect();
315+ mentionObserver = null;
316+ }
317+ return;
318+ }
319+ const mentionIds = new SvelteSet<string>();
320+ for (const message of visibleMessages) {
321+ if (shouldHighlightMention(message)) {
322+ mentionIds.add(message.id);
323+ }
324+ }
325+ const nextMentionIds = mentionIds;
326+ if (!setsEqual(nextMentionIds, mentionMessageIds)) {
327+ mentionMessageIds = nextMentionIds;
328+ }
329+ const nextUnreadCount = Array.from(nextMentionIds).filter(
330+ (messageId) => !mentionObservedIds.has(messageId)
331+ ).length;
332+ // mentionObservedIds is a local Set to avoid effect loops
333+ if (mentionUnreadCount !== nextUnreadCount) {
334+ mentionUnreadCount = nextUnreadCount;
335+ }
336+ });
337+
338+ $effect(() => {
339+ const currentMentionIds = mentionMessageIds;
340+ if (!browser || !chatArea || !currentUser) return;
341+ if (mentionObserver) {
342+ mentionObserver.disconnect();
343+ }
344+ mentionObserver = new IntersectionObserver(
345+ (entries) => {
346+ const updatedObserved = new SvelteSet(mentionObservedIds);
347+ let changed = false;
348+ const currentObserved = mentionObservedIds;
349+ const currentUnread = mentionUnreadCount;
350+ for (const entry of entries) {
351+ const target = entry.target as HTMLElement;
352+ const messageId = target.dataset.messageId;
353+ if (!messageId || !currentMentionIds.has(messageId)) continue;
354+ if (entry.isIntersecting && !updatedObserved.has(messageId)) {
355+ updatedObserved.add(messageId);
356+ changed = true;
357+ }
358+ }
359+ if (changed && !setsEqual(updatedObserved, currentObserved)) {
360+ mentionObservedIds = updatedObserved;
361+ const nextUnread = Math.max(0, currentMentionIds.size - updatedObserved.size);
362+ if (currentUnread !== nextUnread) {
363+ mentionUnreadCount = nextUnread;
364+ }
365+ }
366+ },
367+ { root: chatArea, threshold: 0.6 }
368+ );
369+ const nodes = Array.from(chatArea.querySelectorAll<HTMLElement>('[data-message-id]'));
370+ for (const node of nodes) {
371+ const messageId = node.dataset.messageId;
372+ if (messageId && currentMentionIds.has(messageId)) {
373+ mentionObserver.observe(node);
374+ }
375+ }
376+ // only observe mention messages
377+ // mention observer reset
378+ return () => {
379+ mentionObserver?.disconnect();
380+ };
381+ });
382+
383+ $effect(() => {
384+ if (!mentionOpen || mentionSuggestions.length === 0) {
385+ if (mentionActiveId !== null) {
386+ mentionActiveId = null;
387+ }
388+ return;
389+ }
390+ const currentActiveId = mentionActiveId;
391+ let selectedIndex = mentionSelectedIndex;
392+ if (selectedIndex >= mentionSuggestions.length) {
393+ selectedIndex = 0;
394+ if (mentionSelectedIndex !== 0) {
395+ mentionSelectedIndex = 0;
396+ }
397+ }
398+ const active = mentionSuggestions[selectedIndex];
399+ const nextActiveId = active ? getMentionListItemId(active.id) : null;
400+ if (currentActiveId !== nextActiveId) {
401+ mentionActiveId = nextActiveId;
402+ }
403+ });
404+
405+ $effect(() => {
406+ if (!mentionOpen || !messageInput) {
407+ if (mentionListStyle) mentionListStyle = '';
408+ return;
409+ }
410+ const currentListStyle = mentionListStyle;
411+ const currentInside = mentionListInside;
412+ const inputRect = messageInput.getBoundingClientRect();
413+ const containerRect = messageInput.closest('.chat-container')?.getBoundingClientRect();
414+ // Use shared utility for height calculation
415+ const listHeight = calculatePickerHeight(mentionSuggestions.length);
416+ const spaceBelow = containerRect
417+ ? containerRect.bottom - inputRect.bottom
418+ : window.innerHeight - inputRect.bottom;
419+ const spaceAbove = containerRect ? inputRect.top - containerRect.top : inputRect.top;
420+ const showAbove = spaceBelow < listHeight && spaceAbove > listHeight;
421+ const maxNameWidth = mentionSuggestions.reduce((max, user) => {
422+ const width = measureMentionText(user.nickname, true, 13.6);
423+ return Math.max(max, width);
424+ }, 0);
425+ // Status uses 0.7rem (11.2px) font size and uppercase (add ~10% for letter-spacing)
426+ const statusWidth = measureMentionText('OFFLINE', false, 11.2) * 1.1;
427+ // Use shared utility for width calculation
428+ const listWidth = calculatePickerWidth(maxNameWidth, statusWidth);
429+ if (containerRect) {
430+ if (!currentInside) mentionListInside = true;
431+ const top = showAbove
432+ ? inputRect.top - containerRect.top - listHeight - MENTION_PICKER.OFFSET_GAP
433+ : inputRect.bottom - containerRect.top + MENTION_PICKER.OFFSET_GAP;
434+ const left = inputRect.left - containerRect.left;
435+ const nextStyle = `top: ${Math.max(MENTION_PICKER.EDGE_MARGIN, top)}px; left: ${Math.max(MENTION_PICKER.EDGE_MARGIN, left)}px; width: ${listWidth}px;`;
436+ if (currentListStyle !== nextStyle) {
437+ mentionListStyle = nextStyle;
438+ }
439+ } else {
440+ if (currentInside) mentionListInside = false;
441+ const top = showAbove
442+ ? inputRect.top - listHeight - MENTION_PICKER.OFFSET_GAP
443+ : inputRect.bottom + MENTION_PICKER.OFFSET_GAP;
444+ const nextStyle = `top: ${Math.max(MENTION_PICKER.EDGE_MARGIN, top)}px; left: ${Math.max(MENTION_PICKER.EDGE_MARGIN, inputRect.left)}px; width: ${listWidth}px;`;
445+ if (currentListStyle !== nextStyle) {
446+ mentionListStyle = nextStyle;
447+ }
448+ }
449+ if (mentionSuggestions.length === 0 && mentionActiveId !== null) {
450+ mentionActiveId = null;
451+ }
452+ if (mentionSelectedIndex >= mentionSuggestions.length && mentionSuggestions.length > 0) {
453+ mentionSelectedIndex = 0;
454+ }
455+ });
456+
225 // ============ HELPERS ============457 // ============ HELPERS ============
226 458
227 function resolveUser(senderId: string): SafeUser {459 function resolveUser(senderId: string): SafeUser {
@@ -253,6 +485,143 @@
253 485
254 const debouncedSaveWindowState = debounce(saveWindowState, 300);486 const debouncedSaveWindowState = debounce(saveWindowState, 300);
255 487
488+ function setsEqual(a: Set<string>, b: Set<string>) {
489+ if (a.size !== b.size) return false;
490+ for (const value of a) {
491+ if (!b.has(value)) return false;
492+ }
493+ return true;
494+ }
495+
496+ function measureMentionText(text: string, bold: boolean = true, fontSizePx: number = 13.6) {
497+ if (!mentionMeasureContext) {
498+ const canvas = document.createElement('canvas');
499+ mentionMeasureContext = canvas.getContext('2d');
500+ }
501+ if (!mentionMeasureContext) return 0;
502+ mentionMeasureContext.font = `${bold ? 'bold ' : ''}${fontSizePx}px 'Pixelated MS Sans Serif', Tahoma, sans-serif`;
503+ return mentionMeasureContext.measureText(text).width;
504+ }
505+
506+ function updateMentionState(input: HTMLInputElement) {
507+ if (!currentUser) {
508+ mentionOpen = false;
509+ return;
510+ }
511+ const cursorIndex = input.selectionStart ?? input.value.length;
512+ const context = findMentionContext(input.value, cursorIndex);
513+ if (!context) {
514+ closeMentionPicker();
515+ return;
516+ }
517+ const mentionKey = `${context.atIndex}:${context.query}`;
518+ if (mentionDismissKey === mentionKey) {
519+ return;
520+ }
521+ const wasOpen = mentionOpen;
522+ const previousQuery = mentionQuery;
523+ const sameAnchor = mentionAnchorIndex === context.atIndex;
524+ mentionOpen = true;
525+ mentionQuery = context.query;
526+ mentionAnchorIndex = context.atIndex;
527+ mentionDismissKey = null;
528+ if (!wasOpen || !sameAnchor || previousQuery !== context.query) {
529+ mentionSelectedIndex = 0;
530+ }
531+ }
532+
533+ function registerMentionNode(node: HTMLElement) {
534+ if (!node) return;
535+ const messageId = node.dataset.messageId;
536+ if (!messageId) return;
537+ if (mentionObserver) {
538+ mentionObserver.observe(node);
539+ }
540+ // Return destroy method to properly cleanup when node is removed
541+ return {
542+ destroy() {
543+ if (mentionObserver) {
544+ mentionObserver.unobserve(node);
545+ }
546+ }
547+ };
548+ }
549+
550+ function closeMentionPicker() {
551+ mentionOpen = false;
552+ mentionQuery = '';
553+ mentionAnchorIndex = null;
554+ mentionSelectedIndex = 0;
555+ mentionActiveId = null;
556+ mentionDismissKey = null;
557+ if (messageInput) {
558+ messageInput.focus();
559+ }
560+ }
561+
562+ function clampMentionIndex(nextIndex: number) {
563+ if (mentionSuggestions.length === 0) return 0;
564+ const maxIndex = mentionSuggestions.length - 1;
565+ if (nextIndex < 0) return maxIndex;
566+ if (nextIndex > maxIndex) return 0;
567+ return nextIndex;
568+ }
569+
570+ function applyMentionSelection(user: SafeUser) {
571+ if (!messageInput || mentionAnchorIndex === null) return;
572+ const selectionStart = messageInput.selectionStart ?? currentMessage.length;
573+ const before = currentMessage.slice(0, mentionAnchorIndex);
574+ const after = currentMessage.slice(selectionStart);
575+ const mentionText = `@${user.nickname} `;
576+ currentMessage = `${before}${mentionText}${after}`;
577+ void tick().then(() => {
578+ if (!messageInput) return;
579+ const cursor = before.length + mentionText.length;
580+ messageInput.focus();
581+ messageInput.setSelectionRange(cursor, cursor);
582+ });
583+ closeMentionPicker();
584+ }
585+
586+ function handleMentionKeydown(event: KeyboardEvent) {
587+ if (!mentionOpen || mentionSuggestions.length === 0) return;
588+ if (event.key === 'ArrowDown') {
589+ event.preventDefault();
590+ mentionSelectedIndex = clampMentionIndex(mentionSelectedIndex + 1);
591+ void tick().then(() => scrollMentionOptionIntoView());
592+ return;
593+ }
594+ if (event.key === 'ArrowUp') {
595+ event.preventDefault();
596+ mentionSelectedIndex = clampMentionIndex(mentionSelectedIndex - 1);
597+ void tick().then(() => scrollMentionOptionIntoView());
598+ return;
599+ }
600+ if (event.key === 'Enter' || event.key === 'Tab') {
601+ event.preventDefault();
602+ const choice = mentionSuggestions[mentionSelectedIndex];
603+ if (choice) applyMentionSelection(choice);
604+ return;
605+ }
606+ if (event.key === 'Escape') {
607+ event.preventDefault();
608+ if (mentionOpen && mentionAnchorIndex !== null) {
609+ mentionDismissKey = `${mentionAnchorIndex}:${mentionQuery}`;
610+ }
611+ closeMentionPicker();
612+ }
613+ }
614+
615+ function getMentionListItemId(userId: string) {
616+ return `${mentionListBoxId}-${userId}`;
617+ }
618+
619+ function scrollMentionOptionIntoView() {
620+ if (!mentionActiveId) return;
621+ const option = document.getElementById(mentionActiveId);
622+ option?.scrollIntoView({ block: 'nearest' });
623+ }
624+
256 function updateCooldownProgress() {625 function updateCooldownProgress() {
257 if (!cooldownEndTime) return;626 if (!cooldownEndTime) return;
258 const now = Date.now();627 const now = Date.now();
@@ -282,6 +651,12 @@
282 showChatRoom = false;651 showChatRoom = false;
283 }652 }
284 653
654+ function shouldHighlightMention(message: EnrichedMessage) {
655+ if (!currentUser) return false;
656+ if (message.senderId === currentUser.id) return false;
657+ return containsMention(message.content, currentUser.nickname);
658+ }
659+
285 function handleInputChange(e: Event) {660 function handleInputChange(e: Event) {
286 const input = e.target as HTMLInputElement;661 const input = e.target as HTMLInputElement;
287 if (input.value.length > MAX_MESSAGE_LENGTH) {662 if (input.value.length > MAX_MESSAGE_LENGTH) {
@@ -289,12 +664,51 @@
289 currentMessage = input.value;664 currentMessage = input.value;
290 }665 }
291 inputScrollLeft = input.scrollLeft;666 inputScrollLeft = input.scrollLeft;
667+ updateMentionState(input);
668+ if (!mentionOpen && mentionQuery) {
669+ mentionQuery = '';
670+ }
292 }671 }
293 672
294 function handleInputScroll(e: Event) {673 function handleInputScroll(e: Event) {
295 inputScrollLeft = (e.target as HTMLInputElement).scrollLeft;674 inputScrollLeft = (e.target as HTMLInputElement).scrollLeft;
296 }675 }
297 676
677+ function handleInputFocus() {
678+ if (!messageInput) return;
679+ updateMentionState(messageInput);
680+ }
681+
682+ function handleInputClick() {
683+ if (!messageInput) return;
684+ updateMentionState(messageInput);
685+ }
686+
687+ function handleInputKeydown(event: KeyboardEvent) {
688+ if (!messageInput) return;
689+ if (event.key === 'Escape') {
690+ event.preventDefault();
691+ if (mentionOpen && mentionAnchorIndex !== null) {
692+ mentionDismissKey = `${mentionAnchorIndex}:${mentionQuery}`;
693+ }
694+ closeMentionPicker();
695+ return;
696+ }
697+ if (mentionOpen) {
698+ handleMentionKeydown(event);
699+ return;
700+ }
701+ if (event.key === 'Enter') {
702+ event.preventDefault();
703+ void handleSubmit();
704+ }
705+ }
706+
707+ function handleInputKeyup() {
708+ if (!messageInput) return;
709+ updateMentionState(messageInput);
710+ }
711+
298 async function handleSubmit() {712 async function handleSubmit() {
299 if (!currentMessage.trim() || !currentUser || cooldownEndTime) return;713 if (!currentMessage.trim() || !currentUser || cooldownEndTime) return;
300 714
@@ -520,6 +934,8 @@
520 return () => {934 return () => {
521 window.removeEventListener('resize', handleResize);935 window.removeEventListener('resize', handleResize);
522 if (cooldownInterval) clearInterval(cooldownInterval);936 if (cooldownInterval) clearInterval(cooldownInterval);
937+ // Clean up canvas context used for text measurement
938+ mentionMeasureContext = null;
523 };939 };
524 });940 });
525 941
@@ -537,6 +953,14 @@
537 isCentered953 isCentered
538 });954 });
539 });955 });
956+
957+ // Scroll selected mention option into view when selection changes
958+ $effect(() => {
959+ if (!mentionOpen || mentionSuggestions.length === 0) return;
960+ // Track mentionSelectedIndex to trigger scroll on keyboard navigation
961+ void mentionSelectedIndex;
962+ void tick().then(() => scrollMentionOptionIntoView());
963+ });
540 </script>964 </script>
541 965
542 {#if showChatRoom}966 {#if showChatRoom}
@@ -567,7 +991,8 @@
567 class="title-bar-text"991 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;"992 style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: 'MS Sans Serif', 'Pixelated MS Sans Serif', sans-serif;"
569 >993 >
570- Pdr Aim {#if currentUser}994+ Pdr Aim {#if mentionUnreadCount > 0}({mentionUnreadCount})
995+ {/if}{#if currentUser}
571 - {currentUser.nickname}{:else}996 - {currentUser.nickname}{:else}
572 - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if}997 - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if}
573 {#if connectionError && !isMinimized}998 {#if connectionError && !isMinimized}
@@ -640,6 +1065,7 @@
640 class="sunken-panel chat-area"1065 class="sunken-panel chat-area"
641 style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"1066 style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"
642 onscroll={(e) => handleScroll(e)}1067 onscroll={(e) => handleScroll(e)}
1068+ bind:this={chatArea}
643 >1069 >
644 {#if isInitialLoading}1070 {#if isInitialLoading}
645 <div class="initial-loading">1071 <div class="initial-loading">
@@ -659,7 +1085,18 @@
659 </div>1085 </div>
660 {/if}1086 {/if}
661 {#each visibleMessages as message (message.id)}1087 {#each visibleMessages as message (message.id)}
662- <div class="message {message.type} text">1088+ <div
1089+ class="message {message.type} text"
1090+ class:mention-highlight={hasMentionHighlight[message.id]}
1091+ data-message-id={message.id}
1092+ data-mention={hasMentionHighlight[message.id]
1093+ ? `Mention de @${currentUser?.nickname ?? ''}`
1094+ : undefined}
1095+ aria-label={hasMentionHighlight[message.id]
1096+ ? `Message mentionnant ${currentUser?.nickname ?? ''}`
1097+ : undefined}
1098+ use:registerMentionNode
1099+ >
663 {#if message.type === 'emote'}1100 {#if message.type === 'emote'}
664 <span class="emote-text">1101 <span class="emote-text">
665 <Tooltip1102 <Tooltip
@@ -737,8 +1174,11 @@
737 -webkit-text-fill-color: transparent;1174 -webkit-text-fill-color: transparent;
738 background-clip: text;1175 background-clip: text;
739 "1176 "
740- aria-hidden="true">{currentMessage}</span1177+ aria-hidden="true"
741 >1178 >
1179+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedInputMessage is sanitized by highlightMentionsInput -->
1180+ {@html formattedInputMessage}
1181+ </span>
742 <input1182 <input
743 type="text"1183 type="text"
744 bind:value={currentMessage}1184 bind:value={currentMessage}
@@ -753,28 +1193,86 @@
753 : ''} {currentTextStyle.underline || currentTextStyle.strikethrough1193 : ''} {currentTextStyle.underline || currentTextStyle.strikethrough
754 ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`1194 ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
755 : ''}"1195 : ''}"
756- onkeydown={(e) => e.key === 'Enter' && handleSubmit()}1196+ onkeydown={handleInputKeydown}
1197+ onkeyup={handleInputKeyup}
757 oninput={handleInputChange}1198 oninput={handleInputChange}
758 onscroll={handleInputScroll}1199 onscroll={handleInputScroll}
1200+ onfocus={handleInputFocus}
1201+ onclick={handleInputClick}
1202+ bind:this={messageInput}
759 placeholder={cooldownEndTime1203 placeholder={cooldownEndTime
760 ? `Patientez ${cooldownProgress.toFixed(1)}s...`1204 ? `Patientez ${cooldownProgress.toFixed(1)}s...`
761 : 'Écrivez un message...'}1205 : 'Écrivez un message...'}
762 disabled={!currentUser || Boolean(cooldownEndTime)}1206 disabled={!currentUser || Boolean(cooldownEndTime)}
1207+ role="combobox"
1208+ aria-autocomplete="list"
1209+ aria-controls={mentionOpen ? mentionListBoxId : undefined}
1210+ aria-expanded={mentionOpen}
763 />1211 />
764 </div>1212 </div>
765 {:else}1213 {:else}
766- <input1214+ <div
767- type="text"1215+ class="mention-input-wrapper"
768- bind:value={currentMessage}1216+ style="flex: 1; position: relative; background: white; overflow: hidden;"
769- maxlength={MAX_MESSAGE_LENGTH}1217+ >
770- class="styled-input retro-font-{currentTextStyle.fontFamily}"1218+ <!-- Text overlay with mention highlighting -->
771- style="flex: 1; {generateInputCSSStyle(currentTextStyle)}"1219+ <span
772- onkeydown={(e) => e.key === 'Enter' && handleSubmit()}1220+ class="mention-text-overlay retro-font-{currentTextStyle.fontFamily}"
773- placeholder={cooldownEndTime1221+ style="
774- ? `Patientez ${cooldownProgress.toFixed(1)}s...`1222+ position: absolute;
775- : 'Écrivez un message...'}1223+ left: 3px;
776- disabled={!currentUser || Boolean(cooldownEndTime)}1224+ top: 50%;
777- />1225+ transform: translateY(-50%) translateX(-{inputScrollLeft}px);
1226+ pointer-events: none;
1227+ white-space: nowrap;
1228+ font-size: {currentTextStyle.fontSize}px;
1229+ color: {currentTextStyle.color || '#000000'};
1230+ {currentTextStyle.bold
1231+ ? currentTextStyle.fontFamily === 'tahoma'
1232+ ? 'font-weight: 200;'
1233+ : 'font-weight: 700;'
1234+ : ''}
1235+ {currentTextStyle.italic ? 'font-style: italic;' : ''}
1236+ {currentTextStyle.underline || currentTextStyle.strikethrough
1237+ ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1238+ : ''}
1239+ "
1240+ aria-hidden="true"
1241+ >
1242+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedInputMessage is sanitized by highlightMentionsInput -->
1243+ {@html formattedInputMessage}
1244+ </span>
1245+ <input
1246+ type="text"
1247+ bind:value={currentMessage}
1248+ maxlength={MAX_MESSAGE_LENGTH}
1249+ class="styled-input mention-overlay-input retro-font-{currentTextStyle.fontFamily}"
1250+ style="width: 100%; background: transparent; color: transparent; font-size: {currentTextStyle.fontSize}px; {currentTextStyle.bold
1251+ ? currentTextStyle.fontFamily === 'tahoma'
1252+ ? 'font-weight: 200;'
1253+ : 'font-weight: 700;'
1254+ : ''} {currentTextStyle.italic
1255+ ? 'font-style: italic;'
1256+ : ''} {currentTextStyle.underline || currentTextStyle.strikethrough
1257+ ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1258+ : ''} caret-color: {currentTextStyle.color || 'black'};"
1259+ onkeydown={handleInputKeydown}
1260+ onkeyup={handleInputKeyup}
1261+ oninput={handleInputChange}
1262+ onscroll={handleInputScroll}
1263+ onfocus={handleInputFocus}
1264+ onclick={handleInputClick}
1265+ bind:this={messageInput}
1266+ placeholder={cooldownEndTime
1267+ ? `Patientez ${cooldownProgress.toFixed(1)}s...`
1268+ : 'Écrivez un message...'}
1269+ disabled={!currentUser || Boolean(cooldownEndTime)}
1270+ role="combobox"
1271+ aria-autocomplete="list"
1272+ aria-controls={mentionOpen ? mentionListBoxId : undefined}
1273+ aria-expanded={mentionOpen}
1274+ />
1275+ </div>
778 {/if}1276 {/if}
779 <LoadingButton1277 <LoadingButton
780 onclick={handleSubmit}1278 onclick={handleSubmit}
@@ -788,6 +1286,18 @@
788 </span>1286 </span>
789 {/if}1287 {/if}
790 </div>1288 </div>
1289+ {#if mentionOpen}
1290+ <MentionPicker
1291+ suggestions={mentionSuggestions}
1292+ selectedIndex={mentionSelectedIndex}
1293+ activeId={mentionActiveId}
1294+ listBoxId={mentionListBoxId}
1295+ listStyle={mentionListStyle}
1296+ isPortal={!mentionListInside}
1297+ onSelect={applyMentionSelection}
1298+ />
1299+ {/if}
1300+
791 {#if cooldownEndTime}1301 {#if cooldownEndTime}
792 <div1302 <div
793 class="cooldown-progress"1303 class="cooldown-progress"
@@ -936,6 +1446,41 @@
936 color: #666;1446 color: #666;
937 }1447 }
938 1448
1449+ .message-content :global(.mention-token) {
1450+ color: #1e2a72;
1451+ font-weight: bold;
1452+ }
1453+
1454+ /* Input mention highlighting */
1455+ .mention-input-wrapper,
1456+ .gradient-input-wrapper {
1457+ flex: 1;
1458+ position: relative;
1459+ background: white;
1460+ overflow: hidden;
1461+ }
1462+
1463+ .mention-text-overlay,
1464+ .gradient-text-overlay {
1465+ position: absolute;
1466+ left: 3px;
1467+ top: 50%;
1468+ pointer-events: none;
1469+ white-space: nowrap;
1470+ }
1471+
1472+ .mention-text-overlay :global(.input-mention),
1473+ .gradient-text-overlay :global(.input-mention) {
1474+ color: #1e2a72;
1475+ -webkit-text-fill-color: #1e2a72;
1476+ }
1477+
1478+ /* Force input text to be transparent when using overlay */
1479+ .mention-overlay-input {
1480+ color: transparent !important;
1481+ -webkit-text-fill-color: transparent !important;
1482+ }
1483+
939 .section-header {1484 .section-header {
940 margin: 0 0 0.25rem 0;1485 margin: 0 0 0.25rem 0;
941 font-weight: bold;1486 font-weight: bold;
@@ -1000,8 +1545,7 @@
1000 z-index: 2;1545 z-index: 2;
1001 }1546 }
1002 1547
1003- .input-container input:disabled,1548+ .input-container input:disabled {
1004- .input-container {
1005 opacity: 0.7;1549 opacity: 0.7;
1006 background-color: rgba(128, 128, 128, 0.1);1550 background-color: rgba(128, 128, 128, 0.1);
1007 cursor: not-allowed;1551 cursor: not-allowed;
@@ -1160,10 +1704,6 @@
1160 font-size: 0.875rem;1704 font-size: 0.875rem;
1161 }1705 }
1162 1706
1163- .error-banner small {
1164- color: #666;
1165- }
1166-
1167 .rate-limit-warning {1707 .rate-limit-warning {
1168 background: #fff3e0;1708 background: #fff3e0;
1169 color: #e65100;1709 color: #e65100;
@@ -1198,6 +1738,13 @@
1198 }1738 }
1199 }1739 }
1200 1740
1741+ .message.mention-highlight {
1742+ background: rgba(255, 237, 186, 0.45);
1743+ border-radius: 4px;
1744+ padding: 0.2rem 0.35rem;
1745+ box-shadow: inset 0 0 0 1px rgba(178, 136, 0, 0.2);
1746+ }
1747+
1201 .cooldown-progress {1748 .cooldown-progress {
1202 position: absolute;1749 position: absolute;
1203 bottom: 0;1750 bottom: 0;
M src/lib/components/formatted-message.svelte +9 -1
@@ -3,6 +3,7 @@
33 import type { TextStyle } from '../types/text-formatting';
44 import { formatText, createGradientText, escapeHtml } from '../utils/text-formatter';
55 import { DEFAULT_TEXT_STYLE } from '../types/text-formatting';
6+ import { highlightMentionsPlain } from '../utils/mention';
67
78 // Props
89 let { message, allowFormatting = true } = $props<{
@@ -18,7 +19,8 @@
1819 // Process message formatting
1920 async function processMessage() {
2021 if (!allowFormatting || !message.hasFormatting) {
21- formattedContent = escapeHtml(message.content);
22+ // Still apply mention highlighting to plain text messages
23+ formattedContent = highlightMentionsPlain(escapeHtml(message.content));
2224 return;
2325 }
2426
@@ -234,6 +236,12 @@
234236 color: inherit;
235237 }
236238
239+ /* Mention token styling */
240+ :global(.mention-token) {
241+ color: #1e2a72;
242+ font-weight: bold;
243+ }
244+
237245 /* Ensure accessibility for screen readers */
238246 :global(.formatted-message[aria-label]) {
239247 position: relative;
@@ -3,6 +3,7 @@
3 import type { TextStyle } from '../types/text-formatting';3 import type { TextStyle } from '../types/text-formatting';
4 import { formatText, createGradientText, escapeHtml } from '../utils/text-formatter';4 import { formatText, createGradientText, escapeHtml } from '../utils/text-formatter';
5 import { DEFAULT_TEXT_STYLE } from '../types/text-formatting';5 import { DEFAULT_TEXT_STYLE } from '../types/text-formatting';
6+ import { highlightMentionsPlain } from '../utils/mention';
6 7
7 // Props8 // Props
8 let { message, allowFormatting = true } = $props<{9 let { message, allowFormatting = true } = $props<{
@@ -18,7 +19,8 @@
18 // Process message formatting19 // Process message formatting
19 async function processMessage() {20 async function processMessage() {
20 if (!allowFormatting || !message.hasFormatting) {21 if (!allowFormatting || !message.hasFormatting) {
21- formattedContent = escapeHtml(message.content);22+ // Still apply mention highlighting to plain text messages
23+ formattedContent = highlightMentionsPlain(escapeHtml(message.content));
22 return;24 return;
23 }25 }
24 26
@@ -234,6 +236,12 @@
234 color: inherit;236 color: inherit;
235 }237 }
236 238
239+ /* Mention token styling */
240+ :global(.mention-token) {
241+ color: #1e2a72;
242+ font-weight: bold;
243+ }
244+
237 /* Ensure accessibility for screen readers */245 /* Ensure accessibility for screen readers */
238 :global(.formatted-message[aria-label]) {246 :global(.formatted-message[aria-label]) {
239 position: relative;247 position: relative;
A src/lib/components/mention-picker.svelte +145 -0
@@ -0,0 +1,145 @@
1+<script lang="ts">
2+ import type { SafeUser } from '../types/chat';
3+
4+ // Props
5+ let {
6+ suggestions,
7+ selectedIndex,
8+ activeId,
9+ listBoxId,
10+ listStyle,
11+ isPortal = false,
12+ emptyMessage = 'Aucun utilisateur',
13+ onSelect,
14+ onMouseDown
15+ } = $props<{
16+ suggestions: SafeUser[];
17+ selectedIndex: number;
18+ activeId: string | null;
19+ listBoxId: string;
20+ listStyle: string;
21+ isPortal?: boolean;
22+ emptyMessage?: string;
23+ onSelect: (user: SafeUser) => void;
24+ onMouseDown?: (event: MouseEvent) => void;
25+ }>();
26+
27+ // Generate option ID from user ID
28+ function getOptionId(userId: string): string {
29+ return `${listBoxId}-${userId}`;
30+ }
31+
32+ function handleOptionMouseDown(event: MouseEvent, user: SafeUser) {
33+ event.preventDefault();
34+ onSelect(user);
35+ }
36+
37+ function handleContainerMouseDown(event: MouseEvent) {
38+ event.preventDefault();
39+ onMouseDown?.(event);
40+ }
41+</script>
42+
43+<div
44+ id={listBoxId}
45+ class="mention-picker"
46+ class:mention-picker-portal={isPortal}
47+ role="listbox"
48+ aria-label="Suggestions de mentions"
49+ aria-activedescendant={activeId}
50+ tabindex="-1"
51+ style={`z-index: ${isPortal ? 3000 : 10}; ${listStyle}`}
52+ onmousedown={handleContainerMouseDown}
53+>
54+ {#if suggestions.length > 0}
55+ {#each suggestions as user, index (user.id)}
56+ <div
57+ id={getOptionId(user.id)}
58+ role="option"
59+ class="mention-option"
60+ class:selected={index === selectedIndex}
61+ aria-selected={index === selectedIndex}
62+ tabindex="-1"
63+ onmousedown={(event) => handleOptionMouseDown(event, user)}
64+ >
65+ <span class="mention-name">{user.nickname}</span>
66+ <span class="mention-status {user.status}">{user.status}</span>
67+ </div>
68+ {/each}
69+ {:else}
70+ <div
71+ class="mention-empty"
72+ role="option"
73+ aria-disabled="true"
74+ aria-selected="false"
75+ tabindex="-1"
76+ >
77+ {emptyMessage}
78+ </div>
79+ {/if}
80+</div>
81+
82+<style>
83+ .mention-picker {
84+ position: absolute;
85+ padding: 0.35rem;
86+ max-height: 220px;
87+ overflow-y: auto;
88+ background: #fffef5;
89+ border: 2px inset #dfdfdf;
90+ box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.15);
91+ font-family: 'Pixelated MS Sans Serif', Tahoma, sans-serif;
92+ font-size: 0.85rem;
93+ min-width: 220px;
94+ }
95+
96+ .mention-picker-portal {
97+ position: fixed;
98+ }
99+
100+ .mention-option {
101+ display: flex;
102+ align-items: center;
103+ justify-content: space-between;
104+ gap: 0.5rem;
105+ padding: 0.25rem 0.4rem;
106+ border: 1px solid transparent;
107+ cursor: pointer;
108+ }
109+
110+ .mention-option.selected {
111+ background: #dbe8ff;
112+ border-color: #2d31a6;
113+ }
114+
115+ .mention-name {
116+ font-weight: bold;
117+ color: #1e2a72;
118+ }
119+
120+ .mention-status {
121+ font-size: 0.7rem;
122+ text-transform: uppercase;
123+ letter-spacing: 0.05em;
124+ color: #5b5b5b;
125+ }
126+
127+ .mention-status.online {
128+ color: #1f7a1f;
129+ }
130+
131+ .mention-status.away,
132+ .mention-status.busy {
133+ color: #b06a00;
134+ }
135+
136+ .mention-status.offline {
137+ color: #8a8a8a;
138+ }
139+
140+ .mention-empty {
141+ padding: 0.35rem 0.5rem;
142+ color: #777;
143+ font-style: italic;
144+ }
145+</style>
@@ -0,0 +1,145 @@
1+<script lang="ts">
2+ import type { SafeUser } from '../types/chat';
3+
4+ // Props
5+ let {
6+ suggestions,
7+ selectedIndex,
8+ activeId,
9+ listBoxId,
10+ listStyle,
11+ isPortal = false,
12+ emptyMessage = 'Aucun utilisateur',
13+ onSelect,
14+ onMouseDown
15+ } = $props<{
16+ suggestions: SafeUser[];
17+ selectedIndex: number;
18+ activeId: string | null;
19+ listBoxId: string;
20+ listStyle: string;
21+ isPortal?: boolean;
22+ emptyMessage?: string;
23+ onSelect: (user: SafeUser) => void;
24+ onMouseDown?: (event: MouseEvent) => void;
25+ }>();
26+
27+ // Generate option ID from user ID
28+ function getOptionId(userId: string): string {
29+ return `${listBoxId}-${userId}`;
30+ }
31+
32+ function handleOptionMouseDown(event: MouseEvent, user: SafeUser) {
33+ event.preventDefault();
34+ onSelect(user);
35+ }
36+
37+ function handleContainerMouseDown(event: MouseEvent) {
38+ event.preventDefault();
39+ onMouseDown?.(event);
40+ }
41+</script>
42+
43+<div
44+ id={listBoxId}
45+ class="mention-picker"
46+ class:mention-picker-portal={isPortal}
47+ role="listbox"
48+ aria-label="Suggestions de mentions"
49+ aria-activedescendant={activeId}
50+ tabindex="-1"
51+ style={`z-index: ${isPortal ? 3000 : 10}; ${listStyle}`}
52+ onmousedown={handleContainerMouseDown}
53+>
54+ {#if suggestions.length > 0}
55+ {#each suggestions as user, index (user.id)}
56+ <div
57+ id={getOptionId(user.id)}
58+ role="option"
59+ class="mention-option"
60+ class:selected={index === selectedIndex}
61+ aria-selected={index === selectedIndex}
62+ tabindex="-1"
63+ onmousedown={(event) => handleOptionMouseDown(event, user)}
64+ >
65+ <span class="mention-name">{user.nickname}</span>
66+ <span class="mention-status {user.status}">{user.status}</span>
67+ </div>
68+ {/each}
69+ {:else}
70+ <div
71+ class="mention-empty"
72+ role="option"
73+ aria-disabled="true"
74+ aria-selected="false"
75+ tabindex="-1"
76+ >
77+ {emptyMessage}
78+ </div>
79+ {/if}
80+</div>
81+
82+<style>
83+ .mention-picker {
84+ position: absolute;
85+ padding: 0.35rem;
86+ max-height: 220px;
87+ overflow-y: auto;
88+ background: #fffef5;
89+ border: 2px inset #dfdfdf;
90+ box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.15);
91+ font-family: 'Pixelated MS Sans Serif', Tahoma, sans-serif;
92+ font-size: 0.85rem;
93+ min-width: 220px;
94+ }
95+
96+ .mention-picker-portal {
97+ position: fixed;
98+ }
99+
100+ .mention-option {
101+ display: flex;
102+ align-items: center;
103+ justify-content: space-between;
104+ gap: 0.5rem;
105+ padding: 0.25rem 0.4rem;
106+ border: 1px solid transparent;
107+ cursor: pointer;
108+ }
109+
110+ .mention-option.selected {
111+ background: #dbe8ff;
112+ border-color: #2d31a6;
113+ }
114+
115+ .mention-name {
116+ font-weight: bold;
117+ color: #1e2a72;
118+ }
119+
120+ .mention-status {
121+ font-size: 0.7rem;
122+ text-transform: uppercase;
123+ letter-spacing: 0.05em;
124+ color: #5b5b5b;
125+ }
126+
127+ .mention-status.online {
128+ color: #1f7a1f;
129+ }
130+
131+ .mention-status.away,
132+ .mention-status.busy {
133+ color: #b06a00;
134+ }
135+
136+ .mention-status.offline {
137+ color: #8a8a8a;
138+ }
139+
140+ .mention-empty {
141+ padding: 0.35rem 0.5rem;
142+ color: #777;
143+ font-style: italic;
144+ }
145+</style>
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 this.mentionTotal;
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 this.mentionTotal;
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;
A src/lib/utils/mention.spec.ts +275 -0
@@ -0,0 +1,275 @@
1+import { describe, it, expect, beforeEach } from 'vitest';
2+import {
3+ MENTION_USERNAME_MAX_LENGTH,
4+ USERNAME_PATTERN,
5+ MAX_MENTION_SUGGESTIONS,
6+ MENTION_PICKER,
7+ MENTION_REGEX_PLAIN,
8+ MENTION_REGEX_HTML,
9+ isWordStart,
10+ findMentionContext,
11+ highlightMentionsPlain,
12+ highlightMentionsHtml,
13+ highlightMentionsInput,
14+ containsMention,
15+ calculatePickerHeight,
16+ calculatePickerWidth
17+} from './mention';
18+
19+describe('mention constants', () => {
20+ it('has correct username max length', () => {
21+ expect(MENTION_USERNAME_MAX_LENGTH).toBe(32);
22+ });
23+
24+ it('has correct max suggestions', () => {
25+ expect(MAX_MENTION_SUGGESTIONS).toBe(6);
26+ });
27+
28+ it('has correct picker dimensions', () => {
29+ expect(MENTION_PICKER.ITEM_HEIGHT).toBe(32);
30+ expect(MENTION_PICKER.PADDING).toBe(12);
31+ expect(MENTION_PICKER.EMPTY_STATE_HEIGHT).toBe(44);
32+ expect(MENTION_PICKER.MIN_WIDTH).toBe(220);
33+ });
34+});
35+
36+describe('USERNAME_PATTERN', () => {
37+ it('matches valid usernames', () => {
38+ expect(USERNAME_PATTERN.test('alice')).toBe(true);
39+ expect(USERNAME_PATTERN.test('Bob123')).toBe(true);
40+ expect(USERNAME_PATTERN.test('user_name')).toBe(true);
41+ expect(USERNAME_PATTERN.test('user-name')).toBe(true);
42+ expect(USERNAME_PATTERN.test('A')).toBe(true);
43+ expect(USERNAME_PATTERN.test('a'.repeat(32))).toBe(true);
44+ });
45+
46+ it('rejects invalid usernames', () => {
47+ expect(USERNAME_PATTERN.test('')).toBe(false);
48+ expect(USERNAME_PATTERN.test('a'.repeat(33))).toBe(false);
49+ expect(USERNAME_PATTERN.test('user name')).toBe(false);
50+ expect(USERNAME_PATTERN.test('user.name')).toBe(false);
51+ expect(USERNAME_PATTERN.test('@alice')).toBe(false);
52+ expect(USERNAME_PATTERN.test('alice!')).toBe(false);
53+ });
54+});
55+
56+describe('isWordStart', () => {
57+ it('returns true at position 0', () => {
58+ expect(isWordStart('hello', 0)).toBe(true);
59+ expect(isWordStart('@alice', 0)).toBe(true);
60+ });
61+
62+ it('returns true after whitespace', () => {
63+ expect(isWordStart('hello @alice', 6)).toBe(true);
64+ expect(isWordStart('hi\t@bob', 3)).toBe(true);
65+ expect(isWordStart('hi\n@bob', 3)).toBe(true);
66+ });
67+
68+ it('returns false mid-word', () => {
69+ expect(isWordStart('hello', 2)).toBe(false);
70+ expect(isWordStart('test@alice', 4)).toBe(false);
71+ });
72+});
73+
74+describe('findMentionContext', () => {
75+ it('finds mention at start of input', () => {
76+ const result = findMentionContext('@alice', 6);
77+ expect(result).toEqual({ atIndex: 0, query: 'alice' });
78+ });
79+
80+ it('finds mention after space', () => {
81+ const result = findMentionContext('hello @bob', 10);
82+ expect(result).toEqual({ atIndex: 6, query: 'bob' });
83+ });
84+
85+ it('finds partial mention (typing in progress)', () => {
86+ const result = findMentionContext('hello @al', 9);
87+ expect(result).toEqual({ atIndex: 6, query: 'al' });
88+ });
89+
90+ it('finds empty query (just @)', () => {
91+ const result = findMentionContext('hello @', 7);
92+ expect(result).toEqual({ atIndex: 6, query: '' });
93+ });
94+
95+ it('returns null when no @', () => {
96+ expect(findMentionContext('hello world', 11)).toBeNull();
97+ });
98+
99+ it('returns null when @ is mid-word', () => {
100+ expect(findMentionContext('test@alice', 10)).toBeNull();
101+ });
102+
103+ it('returns null when query contains space', () => {
104+ expect(findMentionContext('@alice bob', 10)).toBeNull();
105+ });
106+
107+ it('returns null when query exceeds max length', () => {
108+ const longName = 'a'.repeat(33);
109+ expect(findMentionContext(`@${longName}`, longName.length + 1)).toBeNull();
110+ });
111+
112+ it('returns null when query has invalid characters', () => {
113+ expect(findMentionContext('@alice!', 7)).toBeNull();
114+ expect(findMentionContext('@alice.bob', 10)).toBeNull();
115+ });
116+
117+ it('handles cursor in middle of mention', () => {
118+ const result = findMentionContext('@alice hello', 4);
119+ expect(result).toEqual({ atIndex: 0, query: 'ali' });
120+ });
121+});
122+
123+describe('MENTION_REGEX_PLAIN', () => {
124+ beforeEach(() => {
125+ // Reset regex lastIndex for global regex
126+ MENTION_REGEX_PLAIN.lastIndex = 0;
127+ });
128+
129+ it('matches mention at start', () => {
130+ const matches = '@alice hello'.match(MENTION_REGEX_PLAIN);
131+ expect(matches).toContain('@alice');
132+ });
133+
134+ it('matches mention after space', () => {
135+ const matches = 'hello @bob there'.match(MENTION_REGEX_PLAIN);
136+ expect(matches).toContain(' @bob');
137+ });
138+
139+ it('matches multiple mentions', () => {
140+ const text = '@alice and @bob are here';
141+ const matches = text.match(MENTION_REGEX_PLAIN);
142+ expect(matches).toHaveLength(2);
143+ });
144+
145+ it('does not match mid-word @', () => {
146+ const matches = 'email@test.com'.match(MENTION_REGEX_PLAIN);
147+ expect(matches).toBeNull();
148+ });
149+});
150+
151+describe('MENTION_REGEX_HTML', () => {
152+ beforeEach(() => {
153+ MENTION_REGEX_HTML.lastIndex = 0;
154+ });
155+
156+ it('matches mention after HTML tag', () => {
157+ const matches = '<p>@alice</p>'.match(MENTION_REGEX_HTML);
158+ expect(matches).toContain('>@alice');
159+ });
160+
161+ it('matches mention at start', () => {
162+ const matches = '@bob said'.match(MENTION_REGEX_HTML);
163+ expect(matches).toContain('@bob');
164+ });
165+});
166+
167+describe('highlightMentionsPlain', () => {
168+ it('wraps mention in span', () => {
169+ const result = highlightMentionsPlain('@alice');
170+ expect(result).toBe('<span class="mention-token">@alice</span>');
171+ });
172+
173+ it('preserves text around mention', () => {
174+ const result = highlightMentionsPlain('hello @bob there');
175+ expect(result).toBe('hello <span class="mention-token">@bob</span> there');
176+ });
177+
178+ it('handles multiple mentions', () => {
179+ const result = highlightMentionsPlain('@alice and @bob');
180+ expect(result).toContain('<span class="mention-token">@alice</span>');
181+ expect(result).toContain('<span class="mention-token">@bob</span>');
182+ });
183+
184+ it('does not highlight mid-word @', () => {
185+ const result = highlightMentionsPlain('email@test.com');
186+ expect(result).toBe('email@test.com');
187+ });
188+});
189+
190+describe('highlightMentionsHtml', () => {
191+ it('wraps mention after HTML tag', () => {
192+ const result = highlightMentionsHtml('<p>@alice</p>');
193+ expect(result).toBe('<p><span class="mention-token">@alice</span></p>');
194+ });
195+
196+ it('handles mention at start', () => {
197+ const result = highlightMentionsHtml('@bob said');
198+ expect(result).toBe('<span class="mention-token">@bob</span> said');
199+ });
200+});
201+
202+describe('highlightMentionsInput', () => {
203+ it('uses input-mention class', () => {
204+ const result = highlightMentionsInput('@alice');
205+ expect(result).toBe('<span class="input-mention">@alice</span>');
206+ });
207+});
208+
209+describe('containsMention', () => {
210+ it('detects mention (case-insensitive by default)', () => {
211+ expect(containsMention('hello @Alice', 'alice')).toBe(true);
212+ expect(containsMention('hello @ALICE', 'alice')).toBe(true);
213+ expect(containsMention('hello @alice', 'ALICE')).toBe(true);
214+ });
215+
216+ it('respects case sensitivity when enabled', () => {
217+ expect(containsMention('hello @Alice', 'alice', true)).toBe(false);
218+ expect(containsMention('hello @alice', 'alice', true)).toBe(true);
219+ });
220+
221+ it('returns false when no mention', () => {
222+ expect(containsMention('hello world', 'alice')).toBe(false);
223+ });
224+
225+ it('returns false for partial match', () => {
226+ expect(containsMention('hello @alicexyz', 'alice')).toBe(false);
227+ });
228+
229+ it('handles special regex characters in username', () => {
230+ expect(containsMention('hello @user-name', 'user-name')).toBe(true);
231+ expect(containsMention('hello @user_name', 'user_name')).toBe(true);
232+ });
233+
234+ it('requires word boundary before @', () => {
235+ expect(containsMention('test@alice', 'alice')).toBe(false);
236+ });
237+});
238+
239+describe('calculatePickerHeight', () => {
240+ it('returns empty state height for 0 suggestions', () => {
241+ expect(calculatePickerHeight(0)).toBe(MENTION_PICKER.EMPTY_STATE_HEIGHT);
242+ });
243+
244+ it('calculates height for single suggestion', () => {
245+ expect(calculatePickerHeight(1)).toBe(MENTION_PICKER.ITEM_HEIGHT + MENTION_PICKER.PADDING);
246+ });
247+
248+ it('calculates height for multiple suggestions', () => {
249+ expect(calculatePickerHeight(3)).toBe(3 * MENTION_PICKER.ITEM_HEIGHT + MENTION_PICKER.PADDING);
250+ });
251+
252+ it('caps at max suggestions', () => {
253+ const maxHeight = MAX_MENTION_SUGGESTIONS * MENTION_PICKER.ITEM_HEIGHT + MENTION_PICKER.PADDING;
254+ expect(calculatePickerHeight(10)).toBe(maxHeight);
255+ expect(calculatePickerHeight(100)).toBe(maxHeight);
256+ });
257+});
258+
259+describe('calculatePickerWidth', () => {
260+ it('returns minimum width for small content', () => {
261+ expect(calculatePickerWidth(50, 30)).toBe(MENTION_PICKER.MIN_WIDTH);
262+ });
263+
264+ it('calculates width for larger content', () => {
265+ const nameWidth = 150;
266+ const statusWidth = 50;
267+ const expected = nameWidth + statusWidth + MENTION_PICKER.HORIZONTAL_PADDING;
268+ expect(calculatePickerWidth(nameWidth, statusWidth)).toBe(expected);
269+ });
270+
271+ it('rounds up to nearest integer', () => {
272+ const result = calculatePickerWidth(150.5, 50.3);
273+ expect(Number.isInteger(result)).toBe(true);
274+ });
275+});
@@ -0,0 +1,275 @@
1+import { describe, it, expect, beforeEach } from 'vitest';
2+import {
3+ MENTION_USERNAME_MAX_LENGTH,
4+ USERNAME_PATTERN,
5+ MAX_MENTION_SUGGESTIONS,
6+ MENTION_PICKER,
7+ MENTION_REGEX_PLAIN,
8+ MENTION_REGEX_HTML,
9+ isWordStart,
10+ findMentionContext,
11+ highlightMentionsPlain,
12+ highlightMentionsHtml,
13+ highlightMentionsInput,
14+ containsMention,
15+ calculatePickerHeight,
16+ calculatePickerWidth
17+} from './mention';
18+
19+describe('mention constants', () => {
20+ it('has correct username max length', () => {
21+ expect(MENTION_USERNAME_MAX_LENGTH).toBe(32);
22+ });
23+
24+ it('has correct max suggestions', () => {
25+ expect(MAX_MENTION_SUGGESTIONS).toBe(6);
26+ });
27+
28+ it('has correct picker dimensions', () => {
29+ expect(MENTION_PICKER.ITEM_HEIGHT).toBe(32);
30+ expect(MENTION_PICKER.PADDING).toBe(12);
31+ expect(MENTION_PICKER.EMPTY_STATE_HEIGHT).toBe(44);
32+ expect(MENTION_PICKER.MIN_WIDTH).toBe(220);
33+ });
34+});
35+
36+describe('USERNAME_PATTERN', () => {
37+ it('matches valid usernames', () => {
38+ expect(USERNAME_PATTERN.test('alice')).toBe(true);
39+ expect(USERNAME_PATTERN.test('Bob123')).toBe(true);
40+ expect(USERNAME_PATTERN.test('user_name')).toBe(true);
41+ expect(USERNAME_PATTERN.test('user-name')).toBe(true);
42+ expect(USERNAME_PATTERN.test('A')).toBe(true);
43+ expect(USERNAME_PATTERN.test('a'.repeat(32))).toBe(true);
44+ });
45+
46+ it('rejects invalid usernames', () => {
47+ expect(USERNAME_PATTERN.test('')).toBe(false);
48+ expect(USERNAME_PATTERN.test('a'.repeat(33))).toBe(false);
49+ expect(USERNAME_PATTERN.test('user name')).toBe(false);
50+ expect(USERNAME_PATTERN.test('user.name')).toBe(false);
51+ expect(USERNAME_PATTERN.test('@alice')).toBe(false);
52+ expect(USERNAME_PATTERN.test('alice!')).toBe(false);
53+ });
54+});
55+
56+describe('isWordStart', () => {
57+ it('returns true at position 0', () => {
58+ expect(isWordStart('hello', 0)).toBe(true);
59+ expect(isWordStart('@alice', 0)).toBe(true);
60+ });
61+
62+ it('returns true after whitespace', () => {
63+ expect(isWordStart('hello @alice', 6)).toBe(true);
64+ expect(isWordStart('hi\t@bob', 3)).toBe(true);
65+ expect(isWordStart('hi\n@bob', 3)).toBe(true);
66+ });
67+
68+ it('returns false mid-word', () => {
69+ expect(isWordStart('hello', 2)).toBe(false);
70+ expect(isWordStart('test@alice', 4)).toBe(false);
71+ });
72+});
73+
74+describe('findMentionContext', () => {
75+ it('finds mention at start of input', () => {
76+ const result = findMentionContext('@alice', 6);
77+ expect(result).toEqual({ atIndex: 0, query: 'alice' });
78+ });
79+
80+ it('finds mention after space', () => {
81+ const result = findMentionContext('hello @bob', 10);
82+ expect(result).toEqual({ atIndex: 6, query: 'bob' });
83+ });
84+
85+ it('finds partial mention (typing in progress)', () => {
86+ const result = findMentionContext('hello @al', 9);
87+ expect(result).toEqual({ atIndex: 6, query: 'al' });
88+ });
89+
90+ it('finds empty query (just @)', () => {
91+ const result = findMentionContext('hello @', 7);
92+ expect(result).toEqual({ atIndex: 6, query: '' });
93+ });
94+
95+ it('returns null when no @', () => {
96+ expect(findMentionContext('hello world', 11)).toBeNull();
97+ });
98+
99+ it('returns null when @ is mid-word', () => {
100+ expect(findMentionContext('test@alice', 10)).toBeNull();
101+ });
102+
103+ it('returns null when query contains space', () => {
104+ expect(findMentionContext('@alice bob', 10)).toBeNull();
105+ });
106+
107+ it('returns null when query exceeds max length', () => {
108+ const longName = 'a'.repeat(33);
109+ expect(findMentionContext(`@${longName}`, longName.length + 1)).toBeNull();
110+ });
111+
112+ it('returns null when query has invalid characters', () => {
113+ expect(findMentionContext('@alice!', 7)).toBeNull();
114+ expect(findMentionContext('@alice.bob', 10)).toBeNull();
115+ });
116+
117+ it('handles cursor in middle of mention', () => {
118+ const result = findMentionContext('@alice hello', 4);
119+ expect(result).toEqual({ atIndex: 0, query: 'ali' });
120+ });
121+});
122+
123+describe('MENTION_REGEX_PLAIN', () => {
124+ beforeEach(() => {
125+ // Reset regex lastIndex for global regex
126+ MENTION_REGEX_PLAIN.lastIndex = 0;
127+ });
128+
129+ it('matches mention at start', () => {
130+ const matches = '@alice hello'.match(MENTION_REGEX_PLAIN);
131+ expect(matches).toContain('@alice');
132+ });
133+
134+ it('matches mention after space', () => {
135+ const matches = 'hello @bob there'.match(MENTION_REGEX_PLAIN);
136+ expect(matches).toContain(' @bob');
137+ });
138+
139+ it('matches multiple mentions', () => {
140+ const text = '@alice and @bob are here';
141+ const matches = text.match(MENTION_REGEX_PLAIN);
142+ expect(matches).toHaveLength(2);
143+ });
144+
145+ it('does not match mid-word @', () => {
146+ const matches = 'email@test.com'.match(MENTION_REGEX_PLAIN);
147+ expect(matches).toBeNull();
148+ });
149+});
150+
151+describe('MENTION_REGEX_HTML', () => {
152+ beforeEach(() => {
153+ MENTION_REGEX_HTML.lastIndex = 0;
154+ });
155+
156+ it('matches mention after HTML tag', () => {
157+ const matches = '<p>@alice</p>'.match(MENTION_REGEX_HTML);
158+ expect(matches).toContain('>@alice');
159+ });
160+
161+ it('matches mention at start', () => {
162+ const matches = '@bob said'.match(MENTION_REGEX_HTML);
163+ expect(matches).toContain('@bob');
164+ });
165+});
166+
167+describe('highlightMentionsPlain', () => {
168+ it('wraps mention in span', () => {
169+ const result = highlightMentionsPlain('@alice');
170+ expect(result).toBe('<span class="mention-token">@alice</span>');
171+ });
172+
173+ it('preserves text around mention', () => {
174+ const result = highlightMentionsPlain('hello @bob there');
175+ expect(result).toBe('hello <span class="mention-token">@bob</span> there');
176+ });
177+
178+ it('handles multiple mentions', () => {
179+ const result = highlightMentionsPlain('@alice and @bob');
180+ expect(result).toContain('<span class="mention-token">@alice</span>');
181+ expect(result).toContain('<span class="mention-token">@bob</span>');
182+ });
183+
184+ it('does not highlight mid-word @', () => {
185+ const result = highlightMentionsPlain('email@test.com');
186+ expect(result).toBe('email@test.com');
187+ });
188+});
189+
190+describe('highlightMentionsHtml', () => {
191+ it('wraps mention after HTML tag', () => {
192+ const result = highlightMentionsHtml('<p>@alice</p>');
193+ expect(result).toBe('<p><span class="mention-token">@alice</span></p>');
194+ });
195+
196+ it('handles mention at start', () => {
197+ const result = highlightMentionsHtml('@bob said');
198+ expect(result).toBe('<span class="mention-token">@bob</span> said');
199+ });
200+});
201+
202+describe('highlightMentionsInput', () => {
203+ it('uses input-mention class', () => {
204+ const result = highlightMentionsInput('@alice');
205+ expect(result).toBe('<span class="input-mention">@alice</span>');
206+ });
207+});
208+
209+describe('containsMention', () => {
210+ it('detects mention (case-insensitive by default)', () => {
211+ expect(containsMention('hello @Alice', 'alice')).toBe(true);
212+ expect(containsMention('hello @ALICE', 'alice')).toBe(true);
213+ expect(containsMention('hello @alice', 'ALICE')).toBe(true);
214+ });
215+
216+ it('respects case sensitivity when enabled', () => {
217+ expect(containsMention('hello @Alice', 'alice', true)).toBe(false);
218+ expect(containsMention('hello @alice', 'alice', true)).toBe(true);
219+ });
220+
221+ it('returns false when no mention', () => {
222+ expect(containsMention('hello world', 'alice')).toBe(false);
223+ });
224+
225+ it('returns false for partial match', () => {
226+ expect(containsMention('hello @alicexyz', 'alice')).toBe(false);
227+ });
228+
229+ it('handles special regex characters in username', () => {
230+ expect(containsMention('hello @user-name', 'user-name')).toBe(true);
231+ expect(containsMention('hello @user_name', 'user_name')).toBe(true);
232+ });
233+
234+ it('requires word boundary before @', () => {
235+ expect(containsMention('test@alice', 'alice')).toBe(false);
236+ });
237+});
238+
239+describe('calculatePickerHeight', () => {
240+ it('returns empty state height for 0 suggestions', () => {
241+ expect(calculatePickerHeight(0)).toBe(MENTION_PICKER.EMPTY_STATE_HEIGHT);
242+ });
243+
244+ it('calculates height for single suggestion', () => {
245+ expect(calculatePickerHeight(1)).toBe(MENTION_PICKER.ITEM_HEIGHT + MENTION_PICKER.PADDING);
246+ });
247+
248+ it('calculates height for multiple suggestions', () => {
249+ expect(calculatePickerHeight(3)).toBe(3 * MENTION_PICKER.ITEM_HEIGHT + MENTION_PICKER.PADDING);
250+ });
251+
252+ it('caps at max suggestions', () => {
253+ const maxHeight = MAX_MENTION_SUGGESTIONS * MENTION_PICKER.ITEM_HEIGHT + MENTION_PICKER.PADDING;
254+ expect(calculatePickerHeight(10)).toBe(maxHeight);
255+ expect(calculatePickerHeight(100)).toBe(maxHeight);
256+ });
257+});
258+
259+describe('calculatePickerWidth', () => {
260+ it('returns minimum width for small content', () => {
261+ expect(calculatePickerWidth(50, 30)).toBe(MENTION_PICKER.MIN_WIDTH);
262+ });
263+
264+ it('calculates width for larger content', () => {
265+ const nameWidth = 150;
266+ const statusWidth = 50;
267+ const expected = nameWidth + statusWidth + MENTION_PICKER.HORIZONTAL_PADDING;
268+ expect(calculatePickerWidth(nameWidth, statusWidth)).toBe(expected);
269+ });
270+
271+ it('rounds up to nearest integer', () => {
272+ const result = calculatePickerWidth(150.5, 50.3);
273+ expect(Number.isInteger(result)).toBe(true);
274+ });
275+});
A src/lib/utils/mention.ts +142 -0
@@ -0,0 +1,142 @@
1+/**
2+ * Mention Utilities
3+ *
4+ * Consolidated constants, regex patterns, and pure functions for @mention handling.
5+ * Used by chat-room.svelte, text-formatter.ts, and formatted-message.svelte.
6+ */
7+
8+// ============ CONSTANTS ============
9+
10+export const MENTION_USERNAME_MAX_LENGTH = 32;
11+
12+export const USERNAME_PATTERN = /^[a-zA-Z0-9_-]{1,32}$/;
13+
14+export const MAX_MENTION_SUGGESTIONS = 6;
15+
16+export const MENTION_PICKER = {
17+ ITEM_HEIGHT: 32,
18+ PADDING: 12,
19+ EMPTY_STATE_HEIGHT: 44,
20+ HORIZONTAL_PADDING: 68,
21+ MIN_WIDTH: 220,
22+ OFFSET_GAP: 6,
23+ EDGE_MARGIN: 8
24+} as const;
25+
26+// ============ REGEX PATTERNS ============
27+
28+/**
29+ * Regex for matching @mentions in plain text.
30+ * Captures: $1 = prefix (space or start), $2 = username
31+ */
32+export const MENTION_REGEX_PLAIN = /(^|\s)@([a-zA-Z0-9_-]{1,32})(?=$|\s|[^a-zA-Z0-9_-])/g;
33+
34+/**
35+ * Regex for matching @mentions in HTML context (after sanitization).
36+ * Handles cases where mentions appear after HTML tags like <br> or </p>.
37+ * Captures: $1 = prefix (space, start, or >), $2 = username
38+ */
39+export const MENTION_REGEX_HTML = /(^|[\s>])@([a-zA-Z0-9_-]{1,32})(?=$|[\s<]|[^a-zA-Z0-9_-])/g;
40+
41+// ============ PURE UTILITY FUNCTIONS ============
42+
43+/**
44+ * Checks if the given index is at the start of a word.
45+ * A word starts at position 0 or after whitespace.
46+ */
47+export function isWordStart(value: string, index: number): boolean {
48+ if (index <= 0) return true;
49+ return /\s/.test(value[index - 1]);
50+}
51+
52+/**
53+ * Finds mention context at the cursor position.
54+ * Returns the @ index and the partial query string, or null if no valid mention context.
55+ */
56+export function findMentionContext(
57+ value: string,
58+ cursorIndex: number
59+): { atIndex: number; query: string } | null {
60+ const beforeCursor = value.slice(0, cursorIndex);
61+ const atIndex = beforeCursor.lastIndexOf('@');
62+
63+ if (atIndex === -1) return null;
64+ if (!isWordStart(value, atIndex)) return null;
65+
66+ const query = value.slice(atIndex + 1, cursorIndex);
67+
68+ // Query cannot contain whitespace
69+ if (query.includes(' ') || query.includes('\n') || query.includes('\t')) return null;
70+
71+ // Query cannot exceed max length
72+ if (query.length > MENTION_USERNAME_MAX_LENGTH) return null;
73+
74+ // If there's a query, it must match the username pattern
75+ // Empty query is allowed (shows all users when just typing @)
76+ if (query && !USERNAME_PATTERN.test(query)) return null;
77+
78+ return { atIndex, query };
79+}
80+
81+/**
82+ * Highlights @mentions in plain text by wrapping them in spans.
83+ * Text must be HTML-escaped before calling this function.
84+ */
85+export function highlightMentionsPlain(escapedText: string): string {
86+ return escapedText.replace(MENTION_REGEX_PLAIN, '$1<span class="mention-token">@$2</span>');
87+}
88+
89+/**
90+ * Highlights @mentions in HTML content (post-sanitization).
91+ * Used in text-formatter.ts after rehype processing.
92+ */
93+export function highlightMentionsHtml(html: string): string {
94+ return html.replace(MENTION_REGEX_HTML, '$1<span class="mention-token">@$2</span>');
95+}
96+
97+/**
98+ * Highlights @mentions for input field display (with different class).
99+ */
100+export function highlightMentionsInput(escapedText: string): string {
101+ return escapedText.replace(MENTION_REGEX_PLAIN, '$1<span class="input-mention">@$2</span>');
102+}
103+
104+/**
105+ * Checks if content contains a mention of the given username.
106+ * Case-insensitive by default.
107+ */
108+export function containsMention(
109+ content: string,
110+ username: string,
111+ caseSensitive: boolean = false
112+): boolean {
113+ const escapedUsername = username.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
114+ const flags = caseSensitive ? '' : 'i';
115+ const mentionRegex = new RegExp(`(?:^|\\s)@(${escapedUsername})(?=$|\\s|[^a-zA-Z0-9_-])`, flags);
116+ return mentionRegex.test(content);
117+}
118+
119+/**
120+ * Calculates the height of the mention picker based on suggestion count.
121+ */
122+export function calculatePickerHeight(suggestionCount: number): number {
123+ if (suggestionCount === 0) {
124+ return MENTION_PICKER.EMPTY_STATE_HEIGHT;
125+ }
126+ return (
127+ Math.min(suggestionCount, MAX_MENTION_SUGGESTIONS) * MENTION_PICKER.ITEM_HEIGHT +
128+ MENTION_PICKER.PADDING
129+ );
130+}
131+
132+/**
133+ * Calculates the width of the mention picker based on content.
134+ * @param maxNameWidth - Maximum width of username text in pixels
135+ * @param statusWidth - Width of status text in pixels
136+ */
137+export function calculatePickerWidth(maxNameWidth: number, statusWidth: number): number {
138+ return Math.max(
139+ MENTION_PICKER.MIN_WIDTH,
140+ Math.ceil(maxNameWidth + statusWidth + MENTION_PICKER.HORIZONTAL_PADDING)
141+ );
142+}
@@ -0,0 +1,142 @@
1+/**
2+ * Mention Utilities
3+ *
4+ * Consolidated constants, regex patterns, and pure functions for @mention handling.
5+ * Used by chat-room.svelte, text-formatter.ts, and formatted-message.svelte.
6+ */
7+
8+// ============ CONSTANTS ============
9+
10+export const MENTION_USERNAME_MAX_LENGTH = 32;
11+
12+export const USERNAME_PATTERN = /^[a-zA-Z0-9_-]{1,32}$/;
13+
14+export const MAX_MENTION_SUGGESTIONS = 6;
15+
16+export const MENTION_PICKER = {
17+ ITEM_HEIGHT: 32,
18+ PADDING: 12,
19+ EMPTY_STATE_HEIGHT: 44,
20+ HORIZONTAL_PADDING: 68,
21+ MIN_WIDTH: 220,
22+ OFFSET_GAP: 6,
23+ EDGE_MARGIN: 8
24+} as const;
25+
26+// ============ REGEX PATTERNS ============
27+
28+/**
29+ * Regex for matching @mentions in plain text.
30+ * Captures: $1 = prefix (space or start), $2 = username
31+ */
32+export const MENTION_REGEX_PLAIN = /(^|\s)@([a-zA-Z0-9_-]{1,32})(?=$|\s|[^a-zA-Z0-9_-])/g;
33+
34+/**
35+ * Regex for matching @mentions in HTML context (after sanitization).
36+ * Handles cases where mentions appear after HTML tags like <br> or </p>.
37+ * Captures: $1 = prefix (space, start, or >), $2 = username
38+ */
39+export const MENTION_REGEX_HTML = /(^|[\s>])@([a-zA-Z0-9_-]{1,32})(?=$|[\s<]|[^a-zA-Z0-9_-])/g;
40+
41+// ============ PURE UTILITY FUNCTIONS ============
42+
43+/**
44+ * Checks if the given index is at the start of a word.
45+ * A word starts at position 0 or after whitespace.
46+ */
47+export function isWordStart(value: string, index: number): boolean {
48+ if (index <= 0) return true;
49+ return /\s/.test(value[index - 1]);
50+}
51+
52+/**
53+ * Finds mention context at the cursor position.
54+ * Returns the @ index and the partial query string, or null if no valid mention context.
55+ */
56+export function findMentionContext(
57+ value: string,
58+ cursorIndex: number
59+): { atIndex: number; query: string } | null {
60+ const beforeCursor = value.slice(0, cursorIndex);
61+ const atIndex = beforeCursor.lastIndexOf('@');
62+
63+ if (atIndex === -1) return null;
64+ if (!isWordStart(value, atIndex)) return null;
65+
66+ const query = value.slice(atIndex + 1, cursorIndex);
67+
68+ // Query cannot contain whitespace
69+ if (query.includes(' ') || query.includes('\n') || query.includes('\t')) return null;
70+
71+ // Query cannot exceed max length
72+ if (query.length > MENTION_USERNAME_MAX_LENGTH) return null;
73+
74+ // If there's a query, it must match the username pattern
75+ // Empty query is allowed (shows all users when just typing @)
76+ if (query && !USERNAME_PATTERN.test(query)) return null;
77+
78+ return { atIndex, query };
79+}
80+
81+/**
82+ * Highlights @mentions in plain text by wrapping them in spans.
83+ * Text must be HTML-escaped before calling this function.
84+ */
85+export function highlightMentionsPlain(escapedText: string): string {
86+ return escapedText.replace(MENTION_REGEX_PLAIN, '$1<span class="mention-token">@$2</span>');
87+}
88+
89+/**
90+ * Highlights @mentions in HTML content (post-sanitization).
91+ * Used in text-formatter.ts after rehype processing.
92+ */
93+export function highlightMentionsHtml(html: string): string {
94+ return html.replace(MENTION_REGEX_HTML, '$1<span class="mention-token">@$2</span>');
95+}
96+
97+/**
98+ * Highlights @mentions for input field display (with different class).
99+ */
100+export function highlightMentionsInput(escapedText: string): string {
101+ return escapedText.replace(MENTION_REGEX_PLAIN, '$1<span class="input-mention">@$2</span>');
102+}
103+
104+/**
105+ * Checks if content contains a mention of the given username.
106+ * Case-insensitive by default.
107+ */
108+export function containsMention(
109+ content: string,
110+ username: string,
111+ caseSensitive: boolean = false
112+): boolean {
113+ const escapedUsername = username.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
114+ const flags = caseSensitive ? '' : 'i';
115+ const mentionRegex = new RegExp(`(?:^|\\s)@(${escapedUsername})(?=$|\\s|[^a-zA-Z0-9_-])`, flags);
116+ return mentionRegex.test(content);
117+}
118+
119+/**
120+ * Calculates the height of the mention picker based on suggestion count.
121+ */
122+export function calculatePickerHeight(suggestionCount: number): number {
123+ if (suggestionCount === 0) {
124+ return MENTION_PICKER.EMPTY_STATE_HEIGHT;
125+ }
126+ return (
127+ Math.min(suggestionCount, MAX_MENTION_SUGGESTIONS) * MENTION_PICKER.ITEM_HEIGHT +
128+ MENTION_PICKER.PADDING
129+ );
130+}
131+
132+/**
133+ * Calculates the width of the mention picker based on content.
134+ * @param maxNameWidth - Maximum width of username text in pixels
135+ * @param statusWidth - Width of status text in pixels
136+ */
137+export function calculatePickerWidth(maxNameWidth: number, statusWidth: number): number {
138+ return Math.max(
139+ MENTION_PICKER.MIN_WIDTH,
140+ Math.ceil(maxNameWidth + statusWidth + MENTION_PICKER.HORIZONTAL_PADDING)
141+ );
142+}
M src/lib/utils/text-formatter.ts +7 -0
@@ -6,6 +6,7 @@ import rehypeStringify from 'rehype-stringify';
66 import type { TextStyle } from '../types/text-formatting';
77 import { validateTextStyle } from '../types/text-formatting';
88 import { isValidBBCodeColor, isValidBBCodeFontSize, isValidBBCodeFont } from '../validation/bbcode';
9+import { highlightMentionsHtml } from './mention';
910
1011 // Define allowed HTML tags and attributes for security
1112 const ALLOWED_SCHEMA = {
@@ -125,6 +126,9 @@ function preprocessFormatting(text: string): string {
125126 });
126127 });
127128
129+ // Note: Mention highlighting is applied after sanitization in formatText
130+ // to ensure it's not stripped by the sanitizer
131+
128132 return processedText;
129133 }
130134
@@ -185,6 +189,9 @@ export async function formatText(
185189 return escapeHtml(text);
186190 }
187191
192+ // Apply mention highlighting after sanitization
193+ formattedHtml = highlightMentionsHtml(formattedHtml);
194+
188195 return formattedHtml;
189196 } catch (error) {
190197 console.error('Text formatting error:', error);
@@ -6,6 +6,7 @@ import rehypeStringify from 'rehype-stringify';
6 import type { TextStyle } from '../types/text-formatting';6 import type { TextStyle } from '../types/text-formatting';
7 import { validateTextStyle } from '../types/text-formatting';7 import { validateTextStyle } from '../types/text-formatting';
8 import { isValidBBCodeColor, isValidBBCodeFontSize, isValidBBCodeFont } from '../validation/bbcode';8 import { isValidBBCodeColor, isValidBBCodeFontSize, isValidBBCodeFont } from '../validation/bbcode';
9+import { highlightMentionsHtml } from './mention';
9 10
10 // Define allowed HTML tags and attributes for security11 // Define allowed HTML tags and attributes for security
11 const ALLOWED_SCHEMA = {12 const ALLOWED_SCHEMA = {
@@ -125,6 +126,9 @@ function preprocessFormatting(text: string): string {
125 });126 });
126 });127 });
127 128
129+ // Note: Mention highlighting is applied after sanitization in formatText
130+ // to ensure it's not stripped by the sanitizer
131+
128 return processedText;132 return processedText;
129 }133 }
130 134
@@ -185,6 +189,9 @@ export async function formatText(
185 return escapeHtml(text);189 return escapeHtml(text);
186 }190 }
187 191
192+ // Apply mention highlighting after sanitization
193+ formattedHtml = highlightMentionsHtml(formattedHtml);
194+
188 return formattedHtml;195 return formattedHtml;
189 } catch (error) {196 } catch (error) {
190 console.error('Text formatting error:', error);197 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