All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
- 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.
214 lines
5.5 KiB
TypeScript
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')}</>;
|
|
}
|