223 lines
8.8 KiB
TypeScript
223 lines
8.8 KiB
TypeScript
import { Anchor, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core";
|
|
import React from "react";
|
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
|
import dark from 'react-syntax-highlighter/dist/esm/styles/prism/one-dark';
|
|
import light from 'react-syntax-highlighter/dist/esm/styles/prism/one-light';
|
|
import { UserMention } from "../UserMention/UserMention";
|
|
import { Emoji } from "../Emoji/Emoji";
|
|
import { GroupInviteMessage } from "../GroupInviteMessage/GroupInviteMessage";
|
|
import { ALLOWED_DOMAINS_ZONES } from "@/app/constants";
|
|
|
|
interface TextParserProps {
|
|
text: string;
|
|
/**
|
|
* If true, the parsed entities will be rendered without hydration (static rendering).
|
|
*/
|
|
noHydrate?: boolean;
|
|
/**
|
|
* If the text (excluding emojis) is smaller than this value, render emojis in oversize (40px).
|
|
*/
|
|
oversizeIfTextSmallerThan?: number;
|
|
/**
|
|
* Limits the number of parsed entities (like links, mentions, emojis, etc.) in the text.
|
|
* If the limit is reached, the remaining text will be rendered as plain text.
|
|
*/
|
|
performanceEntityLimit?: number;
|
|
/**
|
|
* Flags to enable other effects
|
|
*/
|
|
__reserved_1?: boolean;
|
|
__reserved_2?: boolean;
|
|
__reserved_3?: boolean;
|
|
}
|
|
|
|
interface FormatRule {
|
|
pattern: RegExp[];
|
|
render: (match: string) => React.ReactNode;
|
|
flush: (match: string) => React.ReactNode;
|
|
}
|
|
|
|
export function TextParser(props: TextParserProps) {
|
|
const computedTheme = useComputedColorScheme();
|
|
const theme = useMantineTheme();
|
|
let entityCount = 0;
|
|
|
|
const formatRules : FormatRule[] = [
|
|
{
|
|
pattern: [
|
|
/(https?:\/\/[^\s]+)/g,
|
|
/\b(?:https?:\/\/)?(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(?:\/[\w\-\.\/\?&#=%]*)?/g
|
|
],
|
|
render: (match: string) => {
|
|
let domain = match.replace(/https?:\/\//, '').split('/')[0];
|
|
let domainZone = domain.split('.').pop() || '';
|
|
domainZone = domainZone.split('/')[0];
|
|
if(!ALLOWED_DOMAINS_ZONES.includes(domainZone)) {
|
|
return <>{match}</>;
|
|
}
|
|
return <Anchor style={{
|
|
userSelect: 'auto',
|
|
color: props.__reserved_2 ? theme.colors.blue[2] : undefined
|
|
}} fz={14} href={match.startsWith('http') ? match : 'https://' + match} target="_blank" rel="noopener noreferrer">{match}</Anchor>;
|
|
},
|
|
flush: (match: string) => {
|
|
return <>{match}</>;
|
|
}
|
|
},
|
|
{
|
|
pattern: [/\*\*(.+?)\*\*/],
|
|
render: (match: string) => {
|
|
const boldText = match.replace(/^\*\*(.+)\*\*$/, '$1');
|
|
return <b style={{
|
|
userSelect: 'auto'
|
|
}}>{boldText}</b>;
|
|
},
|
|
flush: (match: string) => {
|
|
const boldText = match.replace(/^\*\*(.+)\*\*$/, '$1');
|
|
return <>{boldText}</>;
|
|
}
|
|
},
|
|
{
|
|
// language```code```
|
|
pattern: [/([a-zA-Z0-9]+)```([\s\S]*?)```/],
|
|
render: (match: string) => {
|
|
const langMatch = match.match(/^([a-zA-Z0-9]+)```([\s\S]*?)```$/);
|
|
const language = langMatch ? langMatch[1] : "plaintext";
|
|
const codeContent = langMatch ? langMatch[2] : match;
|
|
return (
|
|
<SyntaxHighlighter customStyle={{
|
|
borderRadius: '8px',
|
|
padding: '12px',
|
|
fontSize: '14px',
|
|
margin: '8px 0',
|
|
overflowX: 'scroll',
|
|
maxWidth: '40vw'
|
|
}} showLineNumbers wrapLongLines lineProps={{
|
|
style: {
|
|
//flexWrap: 'wrap'
|
|
}
|
|
}} wrapLines language={language} style={
|
|
computedTheme === 'dark' ? dark : light
|
|
}>
|
|
{codeContent.trim()}
|
|
</SyntaxHighlighter>
|
|
);
|
|
},
|
|
flush: (match: string) => {
|
|
const langMatch = match.match(/^([a-zA-Z0-9]+)```([\s\S]*?)```$/);
|
|
const codeContent = langMatch ? langMatch[2] : match;
|
|
return <>{codeContent}</>;
|
|
}
|
|
},
|
|
{
|
|
// @username
|
|
pattern: [/@([a-zA-Z0-9_]+)/],
|
|
render: (match: string) => {
|
|
return <UserMention color={props.__reserved_2 ? theme.colors.blue[2] : undefined} key={match} username={match}></UserMention>;
|
|
},
|
|
flush: (match: string) => {
|
|
return <>{match}</>;
|
|
}
|
|
},
|
|
{
|
|
// :emoji_code:
|
|
pattern: [/:emoji_([a-zA-Z0-9_-]+):/],
|
|
render: (match: string) => {
|
|
const emojiCode = match.slice(1, -1);
|
|
let textWithoutEmojis = props.text.replace(/:emoji_[a-zA-Z0-9_-]+:/g, '');
|
|
if(textWithoutEmojis.length <= (props.oversizeIfTextSmallerThan ?? 0)) {
|
|
return <Emoji size={40} unified={emojiCode.replace("emoji_", "")}></Emoji>;
|
|
}
|
|
return <Emoji unified={emojiCode.replace("emoji_", "")}></Emoji>;
|
|
},
|
|
flush: (match: string) => {
|
|
const emojiCode = match.slice(1, -1);
|
|
return <Emoji unified={emojiCode.replace("emoji_", "")}></Emoji>;
|
|
}
|
|
},
|
|
{
|
|
// $a=Attachment text
|
|
pattern: [/^\$a=(.+)$/],
|
|
render: (match: string) => {
|
|
const attachmentText = match.replace(/^\$a=(.+)$/, '$1');
|
|
return <>{attachmentText}</>;
|
|
},
|
|
flush: (match: string) => {
|
|
const attachmentText = match.replace(/^\$a=(.+)$/, '$1');
|
|
return <Text display={'inline-block'} c={props.__reserved_1 ? '#FFF' : 'blue'}>{attachmentText}</Text>;
|
|
}
|
|
},
|
|
{
|
|
//#group:stringbase64
|
|
pattern: [/^#group:([A-Za-z0-9+/=:]+)$/],
|
|
render: (match: string) => {
|
|
const groupString = match.replace(/^#group:([A-Za-z0-9+/=]+)$/, '$1');
|
|
return <GroupInviteMessage groupInviteCode={groupString}></GroupInviteMessage>;
|
|
},
|
|
flush: () => {
|
|
return <Text c={props.__reserved_1 ? '#FFF' : 'blue'}>Group Invite Code</Text>;
|
|
}
|
|
}
|
|
];
|
|
|
|
function parseText(text: string): React.ReactNode[] {
|
|
let result: React.ReactNode[] = [];
|
|
let remainingText = text;
|
|
let index = 0;
|
|
|
|
while (remainingText.length > 0) {
|
|
let earliestMatch: {start: number, end: number, rule: FormatRule, match: string} | null = null;
|
|
|
|
for (const rule of formatRules) {
|
|
for (const pattern of rule.pattern) {
|
|
pattern.lastIndex = 0; // Reset regex state for global patterns
|
|
const match = pattern.exec(remainingText);
|
|
if (match && (earliestMatch === null || match.index < earliestMatch.start)) {
|
|
earliestMatch = {
|
|
start: match.index,
|
|
end: match.index + match[0].length,
|
|
rule,
|
|
match: match[0]
|
|
};
|
|
}
|
|
}
|
|
}
|
|
if (earliestMatch) {
|
|
// Performance limit check
|
|
if (props.performanceEntityLimit !== undefined && entityCount >= props.performanceEntityLimit) {
|
|
result.push(
|
|
<React.Fragment key={index++}>
|
|
{remainingText}
|
|
</React.Fragment>
|
|
);
|
|
break;
|
|
}
|
|
entityCount += 1;
|
|
|
|
if (earliestMatch.start > 0) {
|
|
result.push(
|
|
<React.Fragment key={index++}>
|
|
{remainingText.slice(0, earliestMatch.start)}
|
|
</React.Fragment>
|
|
);
|
|
}
|
|
result.push(
|
|
<React.Fragment key={index++}>
|
|
{props.noHydrate ? earliestMatch.rule.flush(earliestMatch.match) : earliestMatch.rule.render(earliestMatch.match)}
|
|
</React.Fragment>
|
|
);
|
|
remainingText = remainingText.slice(earliestMatch.end);
|
|
} else {
|
|
result.push(
|
|
<React.Fragment key={index++}>
|
|
{remainingText}
|
|
</React.Fragment>
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
return <>{parseText(props.text)}</>;
|
|
} |