import type { ReactNode } from 'react'; import { getTwemojiUrl } from '../../utils/twemoji'; import styles from './MessageContent.module.css'; interface MentionMember { displayName: string; username: string; userId: string; } export interface CustomEmojiEntry { name: string; url: string; } interface MessageContentProps { content: string; members?: MentionMember[]; customEmojis?: CustomEmojiEntry[]; } const EMOJI_REGEX = /(?:\p{Emoji_Presentation}|\p{Emoji}\uFE0F)(?:\u200D(?:\p{Emoji_Presentation}|\p{Emoji}\uFE0F))*|\p{Regional_Indicator}{2}/gu; const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/gi; const URL_REGEX = /https?:\/\/[^\s<>"']+/gi; // Escape user-supplied strings for safe inclusion in a regex. function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } interface Token { type: 'emoji' | 'mention' | 'customEmoji' | 'url'; index: number; length: number; text: string; // For customEmoji / url: the resolved URL. url?: string; } // Find the earliest token (emoji or mention) in `text` starting at `from`. function findNextToken( text: string, from: number, members: MentionMember[], customEmojiMap: Map, ): Token | null { let best: Token | null = null; // URL — earliest match at or after `from`. Trailing punctuation // is commonly prose, not part of the URL. URL_REGEX.lastIndex = from; const urlMatch = URL_REGEX.exec(text); if (urlMatch) { let matchText = urlMatch[0]; const trimmed = matchText.replace(/[),.;!?]+$/, ''); matchText = trimmed; best = { type: 'url', index: urlMatch.index, length: matchText.length, text: matchText, url: matchText, }; } // Emoji — next match at or after `from`. EMOJI_REGEX.lastIndex = from; const emojiMatch = EMOJI_REGEX.exec(text); if (emojiMatch && (!best || emojiMatch.index < best.index)) { best = { type: 'emoji', index: emojiMatch.index, length: emojiMatch[0].length, text: emojiMatch[0], }; } // Custom emoji `:shortcode:` — match only when we have a registered // emoji with that name. Unknown shortcodes fall through as plain text. if (customEmojiMap.size > 0) { CUSTOM_EMOJI_REGEX.lastIndex = from; let m: RegExpExecArray | null; while ((m = CUSTOM_EMOJI_REGEX.exec(text)) !== null) { const name = m[1].toLowerCase(); const url = customEmojiMap.get(name); if (url) { if (!best || m.index < best.index) { best = { type: 'customEmoji', index: m.index, length: m[0].length, text: name, url, }; } break; } // Unknown shortcode — keep searching past this match. } } // @everyone const everyoneIdx = text.indexOf('@everyone', from); if (everyoneIdx !== -1 && (!best || everyoneIdx < best.index)) { best = { type: 'mention', index: everyoneIdx, length: '@everyone'.length, text: '@everyone' }; } // @{DisplayName} — prefer longest display name match so "@Alice Smith" // beats "@Alice". Sort members by descending display-name length. const sorted = [...members].sort( (a, b) => (b.displayName?.length ?? 0) - (a.displayName?.length ?? 0), ); for (const m of sorted) { const name = m.displayName || m.username; if (!name) continue; const needle = `@${name}`; const idx = text.indexOf(needle, from); if (idx !== -1 && (!best || idx < best.index)) { best = { type: 'mention', index: idx, length: needle.length, text: needle }; } } // Generic @word fallback — single run of word chars after an @. const genericRe = /@[\w]+/g; genericRe.lastIndex = from; const gm = genericRe.exec(text); if (gm && (!best || gm.index < best.index)) { best = { type: 'mention', index: gm.index, length: gm[0].length, text: gm[0] }; } return best; } function renderContent( text: string, members: MentionMember[], customEmojiMap: Map, keyPrefix: string, ): ReactNode[] { const parts: ReactNode[] = []; let cursor = 0; let safety = 0; while (cursor < text.length && safety++ < 10000) { const tok = findNextToken(text, cursor, members, customEmojiMap); if (!tok) { parts.push({text.slice(cursor)}); break; } if (tok.index > cursor) { parts.push({text.slice(cursor, tok.index)}); } if (tok.type === 'emoji') { parts.push( {tok.text}, ); } else if (tok.type === 'customEmoji') { parts.push( {`:${tok.text}:`}, ); } else if (tok.type === 'url') { parts.push( {tok.text} , ); } else { parts.push( {tok.text} , ); } cursor = tok.index + tok.length; } if (parts.length === 0) { parts.push({text}); } // Silence unused escapeRegex in case linter complains. void escapeRegex; return parts; } export function MessageContent({ content, members = [], customEmojis = [], }: MessageContentProps) { const map = new Map(); for (const e of customEmojis) map.set(e.name.toLowerCase(), e.url); return <>{renderContent(content, members, map, 'mc')}; }