/**
* Mention Utilities
*
* Consolidated constants, regex patterns, and pure functions for @mention handling.
* Used by chat-room.svelte, text-formatter.ts, and formatted-message.svelte.
*/
// ============ CONSTANTS ============
export const MENTION_USERNAME_MAX_LENGTH = 32;
export const USERNAME_PATTERN = /^[a-zA-Z0-9_-]{1,32}$/;
export const MAX_MENTION_SUGGESTIONS = 6;
export const MENTION_PICKER = {
ITEM_HEIGHT: 32,
PADDING: 12,
EMPTY_STATE_HEIGHT: 44,
HORIZONTAL_PADDING: 68,
MIN_WIDTH: 220,
OFFSET_GAP: 6,
EDGE_MARGIN: 8
} as const;
// ============ REGEX PATTERNS ============
/**
* Regex for matching @mentions in plain text.
* Captures: $1 = prefix (space or start), $2 = username
*/
export const MENTION_REGEX_PLAIN = /(^|\s)@([a-zA-Z0-9_-]{1,32})(?=$|\s|[^a-zA-Z0-9_-])/g;
/**
* Regex for matching @mentions in HTML context (after sanitization).
* Handles cases where mentions appear after HTML tags like
or
.
* Captures: $1 = prefix (space, start, or >), $2 = username
*/
export const MENTION_REGEX_HTML = /(^|[\s>])@([a-zA-Z0-9_-]{1,32})(?=$|[\s<]|[^a-zA-Z0-9_-])/g;
// ============ PURE UTILITY FUNCTIONS ============
/**
* Checks if the given index is at the start of a word.
* A word starts at position 0 or after whitespace.
*/
export function isWordStart(value: string, index: number): boolean {
if (index <= 0) return true;
return /\s/.test(value[index - 1]);
}
/**
* Finds mention context at the cursor position.
* Returns the @ index and the partial query string, or null if no valid mention context.
*/
export function findMentionContext(
value: string,
cursorIndex: number
): { atIndex: number; query: string } | null {
const beforeCursor = value.slice(0, cursorIndex);
const atIndex = beforeCursor.lastIndexOf('@');
if (atIndex === -1) return null;
if (!isWordStart(value, atIndex)) return null;
const query = value.slice(atIndex + 1, cursorIndex);
// Query cannot contain whitespace
if (query.includes(' ') || query.includes('\n') || query.includes('\t')) return null;
// Query cannot exceed max length
if (query.length > MENTION_USERNAME_MAX_LENGTH) return null;
// If there's a query, it must match the username pattern
// Empty query is allowed (shows all users when just typing @)
if (query && !USERNAME_PATTERN.test(query)) return null;
return { atIndex, query };
}
/**
* Highlights @mentions in plain text by wrapping them in spans.
* Text must be HTML-escaped before calling this function.
*/
export function highlightMentionsPlain(escapedText: string): string {
return escapedText.replace(MENTION_REGEX_PLAIN, '$1@$2');
}
/**
* Highlights @mentions in HTML content (post-sanitization).
* Used in text-formatter.ts after rehype processing.
*/
export function highlightMentionsHtml(html: string): string {
return html.replace(MENTION_REGEX_HTML, '$1@$2');
}
/**
* Highlights @mentions for input field display (with different class).
*/
export function highlightMentionsInput(escapedText: string): string {
return escapedText.replace(MENTION_REGEX_PLAIN, '$1@$2');
}
/**
* Checks if content contains a mention of the given username.
* Case-insensitive by default.
*/
export function containsMention(
content: string,
username: string,
caseSensitive: boolean = false
): boolean {
const escapedUsername = username.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
const flags = caseSensitive ? '' : 'i';
const mentionRegex = new RegExp(`(?:^|\\s)@(${escapedUsername})(?=$|\\s|[^a-zA-Z0-9_-])`, flags);
return mentionRegex.test(content);
}
/**
* Calculates the height of the mention picker based on suggestion count.
*/
export function calculatePickerHeight(suggestionCount: number): number {
if (suggestionCount === 0) {
return MENTION_PICKER.EMPTY_STATE_HEIGHT;
}
return (
Math.min(suggestionCount, MAX_MENTION_SUGGESTIONS) * MENTION_PICKER.ITEM_HEIGHT +
MENTION_PICKER.PADDING
);
}
/**
* Calculates the width of the mention picker based on content.
* @param maxNameWidth - Maximum width of username text in pixels
* @param statusWidth - Width of status text in pixels
*/
export function calculatePickerWidth(maxNameWidth: number, statusWidth: number): number {
return Math.max(
MENTION_PICKER.MIN_WIDTH,
Math.ceil(maxNameWidth + statusWidth + MENTION_PICKER.HORIZONTAL_PADDING)
);
}