UNPKG

@cometchat/chat-uikit-react

Version:

Ready-to-use Chat UI Components for React

512 lines (508 loc) 17.8 kB
import './index.css'; import { CometChatTextFormatter } from './chunk-OZUCO4ZL.js'; import { createContext, useContext } from 'react'; import DOMPurify from 'dompurify'; var defaultValue = { subscribe: () => () => { }, publish: () => { } }; var CometChatEventsContext = createContext(defaultValue); CometChatEventsContext.displayName = "CometChatEventsContext"; function useCometChatEventsContext() { return useContext(CometChatEventsContext); } function usePublishEvent() { const { publish } = useContext(CometChatEventsContext); return publish; } var ALLOWED_TAGS = [ "span", "strong", "em", "b", "i", "u", "s", "code", "pre", "blockquote", "ul", "ol", "li", "a", "br", "p", "h1", "h2", "h3", "h4", "h5", "h6" ]; var ALLOWED_ATTR = [ "class", "data-uid", "data-mention-type", "data-self", "href", "target", "rel", "contenteditable", "style" ]; if (typeof window !== "undefined") { DOMPurify.addHook("afterSanitizeAttributes", (node) => { if (node.tagName === "A") { node.setAttribute("target", "_blank"); node.setAttribute("rel", "noopener noreferrer"); } }); } function sanitizeHtml(html) { if (!html || typeof html !== "string") return ""; if (html.trim() === "") return ""; return DOMPurify.sanitize(html, { ALLOWED_TAGS, ALLOWED_ATTR }); } function escapeUserHtml(text) { if (!text || typeof text !== "string") return ""; const mentionPlaceholders = []; const mentionRegex = /<@(?:uid|all):[^>]*>/g; let escaped = text.replace(mentionRegex, (match) => { const idx = mentionPlaceholders.length; mentionPlaceholders.push(match); return `__COMETCHAT_MENTION_${String(idx)}__`; }); escaped = escaped.replace(/<[^>]*>/g, (match) => { const inner = match.slice(1, -1).trim(); if (/^\/?u$/i.test(inner)) { return match; } return match.replace(/</g, "&lt;").replace(/>/g, "&gt;"); }); escaped = escaped.replace(/&(?!(?:#\d+|#x[\da-fA-F]+|[a-zA-Z]\w*);)/g, "&amp;"); escaped = escaped.replace( /__COMETCHAT_MENTION_(\d+)__/g, (_, idx) => mentionPlaceholders[parseInt(idx, 10)] ?? "" ); return escaped; } function stripInvalidMentionFormats(text) { if (!text || typeof text !== "string") return ""; return text.replace(/<@(?!uid:|all:)[^>]+>/g, ""); } // src/formatters/CometChatMarkdownFormatter.ts var CometChatMarkdownFormatter = class extends CometChatTextFormatter { constructor() { super(...arguments); this.id = "markdown-formatter"; this.priority = 10; } getRegex() { return /(\*\*|__|~~|`|>|\[.*?\]\(.*?\)|(?:\d+|[a-z]|[ivxlcdm]+)\.\s|[•-]\s)/g; } format(text) { if (!text) { this.originalText = ""; this.formattedText = ""; return ""; } this.originalText = text; let result = text; result = this.formatCodeBlocks(result); result = this.formatOutsideCodeBlocks(result, (s) => this.formatBlockquotes(s)); result = this.formatOutsideCodeBlocks(result, (s) => this.formatBold(s)); result = this.formatOutsideCodeBlocks(result, (s) => this.formatUnderline(s)); result = this.formatOutsideCodeBlocks(result, (s) => this.formatItalic(s)); result = this.formatOutsideCodeBlocks(result, (s) => this.formatStrikethrough(s)); result = this.formatOutsideCodeBlocks(result, (s) => this.formatLinks(s)); result = this.formatOutsideCodeBlocks(result, (s) => this.formatOrderedLists(s)); result = this.formatOutsideCodeBlocks(result, (s) => this.formatUnorderedLists(s)); result = this.formatInlineCode(result); this.formattedText = result; return this.formattedText; } // ─── Code Blocks ─────────────────────────────────────────────────────── formatCodeBlocks(text) { return text.replace(/```\n?([\s\S]*?)\n?```/g, "<pre><code>$1</code></pre>"); } /** * Apply a formatting function only to text segments outside of code blocks. * Unlike formatOutsideCode, this does NOT split on inline <code> tags or mention placeholders. * Used for blockquote processing which runs before inline code conversion. */ formatOutsideCodeBlocks(text, formatter) { const mentionTokens = []; const mentionPattern = /(<@uid:[^>]+>|<@all:[^>]+>)/g; const protectedText = text.replace(mentionPattern, (match) => { const idx = mentionTokens.length; mentionTokens.push(match); return `\u200B\uFFFCMTKN${String(idx)}\uFFFC\u200B`; }); const codeBlockPattern = /(<pre><code>[\s\S]*?<\/code><\/pre>)/g; const parts = protectedText.split(codeBlockPattern); let result = parts.map((part) => part.startsWith("<pre><code>") ? part : formatter(part)).join(""); result = result.replace(/\u200B\uFFFCMTKN(\d+)\uFFFC\u200B/g, (_, idx) => { return mentionTokens[parseInt(idx, 10)] ?? ""; }); return result; } /** * Apply a formatting function only to text segments outside of code blocks and inline code. * Splits the text by <pre><code>...</code></pre> and <code>...</code> segments, * applies the formatter only to non-code parts, then reassembles. */ formatOutsideCode(text, formatter) { const mentionTokens = []; const mentionPattern = /(<@uid:[^>]+>|<@all:[^>]+>)/g; const textWithPlaceholders = text.replace(mentionPattern, (match) => { const idx = mentionTokens.length; mentionTokens.push(match); return `\u200B\uFFFCMTKN${String(idx)}\uFFFC\u200B`; }); const codePattern = /(<pre><code>[\s\S]*?<\/code><\/pre>|<code>[\s\S]*?<\/code>)/g; const parts = textWithPlaceholders.split(codePattern); let result = parts.map((part) => { if (part.startsWith("<code>") || part.startsWith("<pre><code>")) { return part; } return formatter(part); }).join(""); result = result.replace(/\u200B\uFFFCMTKN(\d+)\uFFFC\u200B/g, (_, idx) => { return mentionTokens[parseInt(idx, 10)] ?? ""; }); return result; } formatInlineCode(text) { return text.replace(/`([^`]+)`/g, "<code>$1</code>"); } // ─── Inline Formatting ───────────────────────────────────────────────── formatBold(text) { return text.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>"); } formatItalic(text) { return text.replace(/_([^_]+)_/g, "<i>$1</i>"); } formatUnderline(text) { let result = text.replace(/__([^_]+)__/g, "<u>$1</u>"); result = result.replace(/\+\+([^+]+)\+\+/g, "<u>$1</u>"); return result; } formatStrikethrough(text) { return text.replace(/~~([^~]+)~~/g, "<s>$1</s>"); } formatLinks(text) { return text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label, rawUrl) => { const href = this.normalizeLinkUrl(rawUrl); if (!href) return label; return `<a href="${href}" target="_blank" rel="noopener noreferrer" class="cometchat-link">${label}</a>`; }); } normalizeLinkUrl(rawUrl) { const url = rawUrl.trim(); if (!url) return ""; const compact = Array.from(url).filter((ch) => ch.charCodeAt(0) > 32).join("").toLowerCase(); if (/^(?:javascript|data|vbscript|file):/.test(compact)) return ""; let href = url; const hasScheme = /^[a-z][a-z0-9+.-]*:/i.test(href); const isAnchorOrRelative = /^[#/?]/.test(href) || href.startsWith("./") || href.startsWith("../"); if (!hasScheme && !isAnchorOrRelative) { href = `https://${href}`; } return href.replace(/"/g, "&quot;"); } // ─── Block Formatting ────────────────────────────────────────────────── formatBlockquotes(text) { const lines = text.split("\n"); const result = []; let blockquoteGroup = []; const flushGroup = () => { if (blockquoteGroup.length === 0) return; let innerContent = blockquoteGroup.join("\n"); innerContent = this.formatOrderedLists(innerContent); innerContent = this.formatUnorderedLists(innerContent); result.push("<blockquote>" + innerContent + "</blockquote>"); blockquoteGroup = []; }; for (const line of lines) { if (/^>\s?/.test(line) || /^&gt;\s?/.test(line)) { blockquoteGroup.push(line.replace(/^(?:&gt;|>)\s?/, "")); } else { flushGroup(); result.push(line); } } flushGroup(); return result.join("\n"); } formatOrderedLists(text) { const lines = text.split("\n"); const result = []; const depthStack = []; const isListHtml = []; const listStyleForDepth = (d) => { if (d === 0) return "decimal"; if (d === 1) return "lower-alpha"; return "lower-roman"; }; const closeToDepth = (targetDepth) => { while (depthStack.length > targetDepth) { depthStack.pop(); result.push("</ol></li>"); isListHtml.push(true); } }; const orderedListRegex = /^( *)(?:(\d+)\.|([a-z])\.|([ivxlcdm]+)\.)\s+(.+)$/; const getDepthFromMarker = (leadingSpaces, decimal, alpha, roman) => { const indentDepth = Math.floor(leadingSpaces / 4); if (decimal) return indentDepth; if (roman && (indentDepth >= 2 || depthStack.length > 0 && (depthStack[depthStack.length - 1] ?? 0) >= 1)) { return Math.max(indentDepth, 2); } if (alpha) return Math.max(indentDepth, 1); return indentDepth; }; for (const line of lines) { const match = orderedListRegex.exec(line); if (match) { const leadingSpaces = match[1]?.length ?? 0; const decimal = match[2]; const alpha = match[3]; const roman = match[4]; const content = match[5]; const currentDepth = getDepthFromMarker(leadingSpaces, decimal, alpha, roman); if (depthStack.length === 0) { result.push( `<ol style="list-style-type: ${listStyleForDepth(0)}; list-style-position: inside; padding: 0; margin: 0;">` ); isListHtml.push(true); depthStack.push(0); } if (currentDepth > depthStack.length - 1) { const lastLine = result[result.length - 1]; if (lastLine?.endsWith("</li>")) { result[result.length - 1] = lastLine.slice(0, -5); } while (depthStack.length - 1 < currentDepth) { const d = depthStack.length; result.push( `<ol style="list-style-type: ${listStyleForDepth(d)}; list-style-position: inside; padding: 0; margin: 0;">` ); isListHtml.push(true); depthStack.push(d); } } else if (currentDepth < depthStack.length - 1) { closeToDepth(currentDepth + 1); } result.push( `<li style="display: list-item; padding-left: 1.5em; text-indent: -1.5em; margin: 0;">${content ?? ""}</li>` ); isListHtml.push(true); } else { if (depthStack.length > 0) { closeToDepth(0); result.push("</ol>"); isListHtml.push(true); depthStack.length = 0; } result.push(line); isListHtml.push(false); } } if (depthStack.length > 0) { closeToDepth(0); result.push("</ol>"); isListHtml.push(true); } let output = ""; for (let i = 0; i < result.length; i++) { if (i > 0 && !(isListHtml[i] && isListHtml[i - 1])) { output += "\n"; } output += result[i] ?? ""; } return output; } formatUnorderedLists(text) { const lines = text.split("\n"); const result = []; const depthStack = []; const isListHtml = []; const listStyleForDepth = (d) => { if (d === 0) return "disc"; if (d === 1) return "circle"; return "square"; }; const closeToDepth = (targetDepth) => { while (depthStack.length > targetDepth) { depthStack.pop(); result.push("</ul></li>"); isListHtml.push(true); } }; for (const line of lines) { const match = /^( *)[•-]\s+(.+)$/.exec(line); if (match) { const leadingSpaces = match[1]?.length ?? 0; const content = match[2]; const currentDepth = Math.floor(leadingSpaces / 4); if (depthStack.length === 0) { result.push( `<ul style="list-style-type: ${listStyleForDepth(0)}; list-style-position: inside; padding: 0; margin: 0;">` ); isListHtml.push(true); depthStack.push(0); } if (currentDepth > depthStack.length - 1) { const lastLine = result[result.length - 1]; if (lastLine?.endsWith("</li>")) { result[result.length - 1] = lastLine.slice(0, -5); } while (depthStack.length - 1 < currentDepth) { const d = depthStack.length; result.push( `<ul style="list-style-type: ${listStyleForDepth(d)}; list-style-position: inside; padding: 0; margin: 0;">` ); isListHtml.push(true); depthStack.push(d); } } else if (currentDepth < depthStack.length - 1) { closeToDepth(currentDepth + 1); } result.push( `<li style="display: list-item; padding-left: 1.5em; text-indent: -1.5em; margin: 0;">${content ?? ""}</li>` ); isListHtml.push(true); } else { if (depthStack.length > 0) { closeToDepth(0); result.push("</ul>"); isListHtml.push(true); depthStack.length = 0; } result.push(line); isListHtml.push(false); } } if (depthStack.length > 0) { closeToDepth(0); result.push("</ul>"); isListHtml.push(true); } let output = ""; for (let i = 0; i < result.length; i++) { if (i > 0 && !(isListHtml[i] && isListHtml[i - 1])) { output += "\n"; } output += result[i] ?? ""; } return output; } /** * Strip markdown for conversation subtitle display. * Converts markdown to simple HTML (bold, italic, etc.) without block-level elements. */ stripMarkdownForConversation(text) { let result = text; result = result.replace(/\u200B/g, ""); result = escapeUserHtml(result); result = result.replace(/```\n?([\s\S]*?)\n?```/g, "$1"); result = result.replace(/`([\s\S]+?)`/g, "<code>$1</code>"); result = this.normalizeOrderedListMarkers(result); const lines = result.split("\n"); const processedLines = lines.map((line) => { let r = line; r = r.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>"); r = r.replace(/__([^_]+)__/g, "<u>$1</u>"); r = r.replace(/\+\+([^+]+)\+\+/g, "<u>$1</u>"); r = r.replace(/_([^_]+)_/g, "<i>$1</i>"); r = r.replace(/~~([^~]+)~~/g, "<s>$1</s>"); r = r.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1"); r = r.replace(/^(?:&gt;|>)\s?/, ""); return r; }); return processedLines.join("\n"); } /** * Normalize ordered list markers so indented items show correct depth markers. * Handles backward compatibility: old messages store all levels as "1. 2. 3." * regardless of nesting. This converts them based on indentation: * - depth 0 (no indent): keep numeric (1. 2. 3.) * - depth 1 (4 spaces): convert to alpha (a. b. c.) * - depth 2 (8 spaces): convert to roman (i. ii. iii.) */ normalizeOrderedListMarkers(text) { const lines = text.split("\n"); const depthCounters = [0, 0, 0]; let prevDepth = -1; const numberToAlpha = (n) => { let result = ""; let num = n; while (num > 0) { num--; result = String.fromCharCode(97 + num % 26) + result; num = Math.floor(num / 26); } return result; }; const numberToRoman = (n) => { const values = [1e3, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; const symbols = ["m", "cm", "d", "cd", "c", "xc", "l", "xl", "x", "ix", "v", "iv", "i"]; let result = ""; let num = n; for (let i = 0; i < values.length; i++) { while (num >= (values[i] ?? 0)) { result += symbols[i] ?? ""; num -= values[i] ?? 0; } } return result; }; const processedLines = lines.map((line) => { const match = /^( *)(?:(\d+)\.|([a-z])\.|([ivxlcdm]+)\.)\s+(.+)$/.exec(line); if (!match) { prevDepth = -1; depthCounters[0] = 0; depthCounters[1] = 0; depthCounters[2] = 0; return line; } const leadingSpaces = match[1]?.length ?? 0; const content = match[5] ?? ""; const depth = Math.min(Math.floor(leadingSpaces / 4), 2); if (match[3] || match[4]) { prevDepth = depth; return line; } if (depth === 0) { prevDepth = depth; return line; } if (depth <= prevDepth) { for (let d = depth + 1; d < depthCounters.length; d++) { depthCounters[d] = 0; } } if (depth > prevDepth) { depthCounters[depth] = 0; } depthCounters[depth] = (depthCounters[depth] ?? 0) + 1; prevDepth = depth; const indent = " ".repeat(depth); const count = depthCounters[depth] ?? 1; if (depth === 1) { return `${indent}${numberToAlpha(count)}. ${content}`; } if (depth >= 2) { return `${indent}${numberToRoman(count)}. ${content}`; } return line; }); return processedLines.join("\n"); } }; export { CometChatEventsContext, CometChatMarkdownFormatter, escapeUserHtml, sanitizeHtml, stripInvalidMentionFormats, useCometChatEventsContext, usePublishEvent };