feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
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.
This commit is contained in:
213
packages/shared/src/components/channel/MessageContent.tsx
Normal file
213
packages/shared/src/components/channel/MessageContent.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
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')}</>;
|
||||
}
|
||||
Reference in New Issue
Block a user