diff --git a/package-lock.json b/package-lock.json index 207f41d36..6aaf0d0f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ }, "devDependencies": { "@tanstack/eslint-plugin-query": "^5.17.22", + "@types/dompurify": "^3.0.5", "@types/react": "^18.0.37", "@types/react-dom": "^18.0.11", "@types/styled-components": "^5.1.26", @@ -1592,6 +1593,15 @@ "integrity": "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==", "dev": true }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/eslint": { "version": "8.40.2", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.40.2.tgz", @@ -1706,6 +1716,12 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true + }, "node_modules/@types/unist": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz", diff --git a/package.json b/package.json index f5c538331..6f4e36ae7 100644 --- a/package.json +++ b/package.json @@ -31,18 +31,19 @@ "dependencies": { "@sendbird/chat": "^4.10.1", "@sendbird/uikit-react": "^3.13.1", + "@tanstack/react-query": "^5.17.19", "dompurify": "^3.0.4", "polished": "^2.3.1", "react-code-blocks": "^0.1.0", "react-popper-tooltip": "^4.4.2", - "styled-components": "^5.3.11", - "@tanstack/react-query": "^5.17.19" + "styled-components": "^5.3.11" }, "devDependencies": { + "@tanstack/eslint-plugin-query": "^5.17.22", + "@types/dompurify": "^3.0.5", "@types/react": "^18.0.37", "@types/react-dom": "^18.0.11", "@types/styled-components": "^5.1.26", - "@tanstack/eslint-plugin-query": "^5.17.22", "@typescript-eslint/eslint-plugin": "^5.60.1", "@typescript-eslint/parser": "^5.60.1", "@vitejs/plugin-react": "^4.0.0", @@ -61,8 +62,8 @@ "tslint-config-prettier": "^1.18.0", "typescript": "5.0.2", "vite": "^4.3.9", - "vite-plugin-svgr": "^3.2.0", - "vite-plugin-dts": "^3.7.0" + "vite-plugin-dts": "^3.7.0", + "vite-plugin-svgr": "^3.2.0" }, "peerDependencies": { "react": "^16.8.6 || ^17.0.0 || ^18.0.0", diff --git a/packages/url-webdemo/src/const.ts b/packages/url-webdemo/src/const.ts index 86efeac32..b8ea807ba 100644 --- a/packages/url-webdemo/src/const.ts +++ b/packages/url-webdemo/src/const.ts @@ -29,6 +29,9 @@ interface CustomRefreshComponent { style: React.CSSProperties; } +type MatchString = string; +type ReplaceString = string; + interface ChatBottomContent { text: string; backgroundColor?: string; @@ -42,7 +45,7 @@ export interface DemoConstant { suggestedMessageContent: SuggestedMessageContent; createGroupChannelParams: CreateGroupChannelParams; botNickName: string; - replacementTextList: string[][]; + replacementTextList: [MatchString, ReplaceString][]; customRefreshComponent?: Partial; betaMark: boolean; customBetaMarkText?: string; diff --git a/src/components/AdminMessage.tsx b/src/components/AdminMessage.tsx index 1ef20c192..3e3ffd542 100644 --- a/src/components/AdminMessage.tsx +++ b/src/components/AdminMessage.tsx @@ -1,4 +1,4 @@ -import { UserMessage } from '@sendbird/chat/message'; +import { AdminMessage as ChatAdminMessage } from '@sendbird/chat/message'; import styled from 'styled-components'; const Root = styled.div` @@ -38,7 +38,7 @@ const TextComponent = styled.div` `; type Props = { - message: UserMessage; + message: ChatAdminMessage; }; export default function AdminMessage(props: Props) { diff --git a/src/components/CustomMessage.tsx b/src/components/CustomMessage.tsx index 6d487ca18..24e15b729 100644 --- a/src/components/CustomMessage.tsx +++ b/src/components/CustomMessage.tsx @@ -18,7 +18,6 @@ import { isNotLocalMessageCustomType, MessageTextParser, replaceTextExtractsMultiple, - replaceUrl, Token, } from '../utils'; import { isFormMessage } from '../utils/messages'; @@ -123,11 +122,14 @@ export default function CustomMessage(props: Props) { const tokens: Token[] = MessageTextParser((message as UserMessage).message); tokens.forEach((token: Token) => { if (token.type === 'String') { - token.value = replaceUrl(token.value); + // Redact text to replacementTextList token.value = replaceTextExtractsMultiple( token.value, replacementTextList ); + + // Convert url string to component --> handled by ParsedBotMessageBody > RegexText + // token.value = replaceUrl(token.value); } }); diff --git a/src/components/ParsedBotMessageBody.tsx b/src/components/ParsedBotMessageBody.tsx index 25039cd2a..184dcd4ca 100644 --- a/src/components/ParsedBotMessageBody.tsx +++ b/src/components/ParsedBotMessageBody.tsx @@ -1,12 +1,11 @@ -import { lazy, Suspense } from 'react'; -// eslint-disable-next-line import/no-unresolved -import { EveryMessage } from 'SendbirdUIKitGlobal'; +import { UserMessage } from '@sendbird/chat/message'; +import { lazy, Suspense, ReactNode } from 'react'; import styled from 'styled-components'; import BotMessageBottom from './BotMessageBottom'; import SourceContainer, { Source } from './SourceContainer'; import { useConstantState } from '../context/ConstantContext'; -import { Token, TokenType } from '../utils'; +import { replaceWithRegex, Token, TokenType } from '../utils'; const LazyCodeBlock = lazy(() => import('./CodeBlock').then(({ CodeBlock }) => ({ default: CodeBlock })) @@ -35,7 +34,7 @@ const MultipleTokenTypeContainer = styled.div` `; type Props = { - message: EveryMessage; + message: UserMessage; tokens: Token[]; }; @@ -53,9 +52,7 @@ export default function ParsedBotMessageBody(props: Props) { const { enableSourceMessage } = useConstantState(); const data: MetaData = JSON.parse(message.data === '' ? '{}' : message.data); const sources: Source[] = Array.isArray(data['metadatas']) - ? data['metadatas']?.filter( - (source: Source) => source.source_type !== 'file' - ) + ? data['metadatas']?.filter((source) => source.source_type !== 'file') : []; // console.log('## sources: ', sources); @@ -65,12 +62,54 @@ export default function ParsedBotMessageBody(props: Props) { {tokens.map((token: Token, i) => { if (token.type === TokenType.string) { return ( - + patterns={[ + { + regex: markdownBoldRegex, + replacer({ match, groups, index }) { + return {groups[1]}; + }, + }, + { + regex: markdownUrlRegex, + replacer({ match, groups, index }) { + return ( + + {groups[1]} + + ); + }, + }, + { + regex: urlRegex, + replacer({ match, index }) { + return ( + + {match} + + ); + }, + }, + ]} + > + {token.value} + ); } + return ( }> @@ -90,3 +129,46 @@ export default function ParsedBotMessageBody(props: Props) { } return {message.message}; } + +const urlRegex = + /(?:https?:\/\/|www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.(xn--)?[a-z]{2,20}\b([-a-zA-Z0-9@:%_+[\],.~#?&/=]*[-a-zA-Z0-9@:%_+~#?&/=])*/g; +const markdownUrlRegex = /\[(.*?)\]\((.*?)\)/g; +const markdownBoldRegex = /\*\*(.*?)\*\*/g; + +interface RegexTextPattern { + regex: RegExp; + replacer(params: { + match: string; + groups: string[]; + index: number; + }): string | ReactNode; +} +const RegexText = ({ + children, + patterns, +}: { + children: string; + patterns: RegexTextPattern[]; +}) => { + if (patterns.length === 0 || typeof children !== 'string') { + return <>{children}; + } + + const convertedNodes: Array = [children]; + patterns.forEach(({ regex, replacer }) => { + const node = convertedNodes.concat(); + let offset = 0; + node.forEach((text, index) => { + if (typeof text === 'string' && text) { + const children = replaceWithRegex(text, regex, replacer); + + if (children.length > 1) { + convertedNodes.splice(index + offset, 1, ...children); + offset += children.length - 1; + } + } + }); + }); + + return {convertedNodes}; +}; diff --git a/src/components/SourceContainer.tsx b/src/components/SourceContainer.tsx index 7a7c23858..f06331b3a 100644 --- a/src/components/SourceContainer.tsx +++ b/src/components/SourceContainer.tsx @@ -56,6 +56,7 @@ export interface Source { title: string; description: string; language: string; + source_type?: string; } type Props = { diff --git a/src/const.ts b/src/const.ts index bb5518603..c4e19babc 100644 --- a/src/const.ts +++ b/src/const.ts @@ -102,6 +102,8 @@ type FirstMessageItem = { data: MessageData; message: string; }; +type MatchString = string; +type ReplaceString = string; export interface Constant { botNickName: string; @@ -113,7 +115,7 @@ export interface Constant { createGroupChannelParams: CreateGroupChannelParams; chatBottomContent: ChatBottomContent; messageBottomContent: MessageBottomContent; - replacementTextList: string[][]; + replacementTextList: [MatchString, ReplaceString][]; instantConnect: boolean; customRefreshComponent: CustomRefreshComponent; customUserAgentParam: Record; diff --git a/src/utils/index.ts b/src/utils/index.ts index 0453f7c0c..8f25f6083 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -154,7 +154,7 @@ export function isNotLocalMessageCustomType(customType: string | undefined) { export function replaceTextExtractsMultiple( input: string, - replacements: Array<[string, string]> + replacements: [string, string][], ): string { let result = input; for (let i = 0; i < replacements.length; i++) { @@ -173,14 +173,6 @@ export function replaceTextExtracts( return input.replace(regex, replaceText); } -export function replaceUrl(input: string): string { - const urlRegex = - /(?:https?:\/\/|www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.(xn--)?[a-z]{2,20}\b([-a-zA-Z0-9@:%_+[\],.~#?&/=]*[-a-zA-Z0-9@:%_+~#?&/=])*/g; - return input.replace(urlRegex, function (url) { - return `${url}`; - }); -} - export function isSpecialMessage( message: string, specialMessageList: string[] @@ -219,3 +211,29 @@ export function hideChatBottomBanner(sdk: SendbirdChat): boolean { return false; } + +export const replaceWithRegex = ( + text: string, + regex: RegExp, + replacer: (params: { match: string; groups: string[]; index: number; }) => T, +) => { + const matches = [...text.matchAll(regex)]; + const founds = matches.map((value) => { + const text = value[0]; + const start = value.index ?? 0; + const end = start + text.length; + return { text, start, end, groups: value, matchIndex: value.index }; + }); + + const items: Array = [text]; + let cursor = 0; + founds.forEach(({ text, start, end, groups }, index) => { + const restText = items.pop() as string; + const head = restText.slice(0, start - cursor); + const mid = replacer({ match: text, groups, index }); + const tail = restText.slice(end - cursor); + items.push(head, mid, tail); + cursor = end; + }); + return items; +};