Files
DiscordClone/packages/shared/src/components/channel/MessageContent.tsx
Bryan1029384756 b7a4cf4ce8
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
- Implemented Button component with various props for customization.
- Created Modal component with header, content, and footer subcomponents.
- Added Spinner component for loading indicators.
- Developed Toast component for displaying notifications.
- Introduced Tooltip component for contextual hints with keyboard shortcuts.
- Added corresponding CSS modules for styling each component.
- Updated index file to export new components.
- Configured TypeScript settings for the UI package.
2026-04-14 09:02:14 -05:00

214 lines
5.5 KiB
TypeScript

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<string, string>,
): 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<string, string>,
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(<span key={`${keyPrefix}t${cursor}`}>{text.slice(cursor)}</span>);
break;
}
if (tok.index > cursor) {
parts.push(<span key={`${keyPrefix}t${cursor}`}>{text.slice(cursor, tok.index)}</span>);
}
if (tok.type === 'emoji') {
parts.push(
<img
key={`${keyPrefix}e${tok.index}`}
src={getTwemojiUrl(tok.text)}
alt={tok.text}
className={styles.emoji}
draggable={false}
/>,
);
} else if (tok.type === 'customEmoji') {
parts.push(
<img
key={`${keyPrefix}c${tok.index}`}
src={tok.url}
alt={`:${tok.text}:`}
title={`:${tok.text}:`}
className={`${styles.customEmoji} ${styles.emoji}`}
draggable={false}
/>,
);
} else if (tok.type === 'url') {
parts.push(
<a
key={`${keyPrefix}u${tok.index}`}
className={styles.link}
href={tok.url}
target="_blank"
rel="noopener noreferrer"
>
{tok.text}
</a>,
);
} else {
parts.push(
<span key={`${keyPrefix}m${tok.index}`} className={styles.mention}>
{tok.text}
</span>,
);
}
cursor = tok.index + tok.length;
}
if (parts.length === 0) {
parts.push(<span key={`${keyPrefix}t0`}>{text}</span>);
}
// Silence unused escapeRegex in case linter complains.
void escapeRegex;
return parts;
}
export function MessageContent({
content,
members = [],
customEmojis = [],
}: MessageContentProps) {
const map = new Map<string, string>();
for (const e of customEmojis) map.set(e.name.toLowerCase(), e.url);
return <>{renderContent(content, members, map, 'mc')}</>;
}