perf: WhatsApp消息 支持加粗, 斜体

main
Lei OT 8 months ago
parent be84e99d9e
commit 8c89ef3e80

@ -1071,3 +1071,120 @@ export const phoneNumberToWAID = (input) => {
}
export const uploadProgressSimulate = () => fixTo2Decimals(Math.random() * (0.8 - 0.2) + 0.2);
// Parse text segments for URLs and numbers
const parseTextForMarkdown = (text) => {
// Find URLs and four-digit numbers
const urlRegex = /https?:\/\/[^\s]+/g;
const numberRegex = /\d{4,}/g;
const matches = [];
// Find all URLs
let match;
while ((match = urlRegex.exec(text)) !== null) {
matches.push({ start: match.index, end: match.index + match[0].length, type: 'url', content: match[0] });
}
// Find all 4+ digit numbers
numberRegex.lastIndex = 0; // Reset regex
while ((match = numberRegex.exec(text)) !== null) {
matches.push({ start: match.index, end: match.index + match[0].length, type: 'number', content: match[0] });
}
// Sort matches by position
matches.sort((a, b) => a.start - b.start);
// Remove overlapping matches (URLs take priority)
const filteredMatches = [];
for (const current of matches) {
const isOverlapping = filteredMatches.some(existing =>
(current.start >= existing.start && current.start < existing.end) ||
(current.end > existing.start && current.end <= existing.end)
);
if (!isOverlapping) {
filteredMatches.push(current);
}
}
// Split text into segments
const segments = [];
let currentIndex = 0;
for (const match of filteredMatches) {
if (currentIndex < match.start) {
const textContent = text.slice(currentIndex, match.start);
if (textContent) {
segments.push({ type: 'text', content: textContent });
}
}
segments.push(match);
currentIndex = match.end;
}
if (currentIndex < text.length) {
const textContent = text.slice(currentIndex);
if (textContent) {
segments.push({ type: 'text', content: textContent });
}
}
return segments.length > 0 ? segments : [{ type: 'text', content: text }];
};
// Parse markdown with nesting support, autolinks, and number recognition
export const parseSimpleMarkdown = (text) => {
const tokens = [];
let current = '';
let i = 0;
while (i < text.length) {
const char = text[i];
if (char === '*' || char === '_') {
// Save any accumulated text before processing markdown
if (current) {
tokens.push(...parseTextForMarkdown(current));
current = '';
}
// Find the closing marker
const marker = char;
let j = i + 1;
let content = '';
let found = false;
while (j < text.length) {
if (text[j] === marker) {
found = true;
break;
}
content += text[j];
j++;
}
if (found && content) {
// Recursively parse the content inside the markers
tokens.push({
type: marker === '*' ? 'bold' : 'italic',
content: parseSimpleMarkdown(content)
});
i = j + 1; // Skip past the closing marker
} else {
// If no closing marker found, treat as regular text
current += char;
i++;
}
} else {
current += char;
i++;
}
}
// Add any remaining text
if (current) {
tokens.push(...parseTextForMarkdown(current));
}
return tokens;
};

@ -10,6 +10,7 @@ import MergeConversationTo from './MergeConversationTo';
import BubbleIM from '../Online/Components/BubbleIM';
import BubbleEmail from '../Online/Components/BubbleEmail';
import { ERROR_IMG, POPUP_FEATURES } from '@/config';
import { parseSimpleMarkdown } from '@/channel/bubbleMsgUtils';
const BIG_PAGE_SIZE = MESSAGE_PAGE_SIZE * 20;
const MessagesList = ({ ...listProps }) => {
@ -148,6 +149,27 @@ const MessagesList = ({ ...listProps }) => {
setFocusMsg(id);
}
};
// Render parsed tokens to React elements
const renderMDTokens = (tokens) => {
return tokens.map((token, index) => {
switch (token.type) {
case 'text':
return <span key={index}>{token.content}</span>;
case 'bold':
return <b key={index}>{renderMDTokens(token.content)}</b>;
case 'italic':
return <i key={index}>{renderMDTokens(token.content)}</i>;
case 'url':
return (
<a key={index} href={token.content} target='_blank' rel='noopener noreferrer' className=' underline text-sm'>
{token.content}
</a>
)
default:
return <span key={index}>{token.content}</span>;
}
});
};
const RenderText = memo(function renderText({ str, className, template }) {
let headerObj, footerObj, buttonsArr;
@ -157,23 +179,8 @@ const MessagesList = ({ ...listProps }) => {
footerObj = componentsObj?.footer?.[0];
buttonsArr = componentsObj?.buttons?.reduce((r, c) => r.concat(c.buttons), []);
}
const parts = str.split(/(https?:\/\/[^\s()]+|\p{Emoji_Presentation})/gmu).filter((s) => s !== '');
const links = str.match(/https?:\/\/[^\s()]+/gi) || [];
const emojis = str.match(/([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g) || [];
const extraClass = isEmpty(emojis) ? '' : '';
const objArr = parts.reduce((prev, curr, index) => {
if (links.includes(curr)) {
prev.push({ type: 'link', key: curr });
} else if (emojis.includes(curr)) {
prev.push({ type: 'emoji', key: curr });
} else {
prev.push({ type: 'text', key: curr });
}
return prev;
}, []);
return (
<span className={`text-sm leading-5 emoji-text whitespace-pre-wrap ${className} ${extraClass} `} key={'msg-text'}>
<span className={`text-sm leading-5 emoji-text whitespace-pre-wrap ${className} `} key={'msg-text'}>
{headerObj ? (
<div className='text-neutral-500 text-center'>
{'text' === (headerObj?.parameters?.[0]?.type || '').toLowerCase() && <div>{headerObj.text}</div>}
@ -185,17 +192,7 @@ const MessagesList = ({ ...listProps }) => {
)}
</div>
) : null}
{(objArr || []).map((part, index) => {
if (part.type === 'link') {
return (
<a href={part.key} target='_blank' key={`${part.key}${index}`} rel='noreferrer' className='text-sm'>
{part.key}
</a>
);
} else {
return part.key;
}
})}
{renderMDTokens(parseSimpleMarkdown(str))}
</span>
);
});

@ -3,6 +3,7 @@ import { App, Button, Image } from 'antd';
import { ExportOutlined, CopyOutlined, PhoneOutlined } from '@ant-design/icons';
import { MessageBox } from 'react-chat-elements';
import { groupBy, isEmpty, TagColorStyle } from '@/utils/commons';
import { parseSimpleMarkdown } from '@/channel/bubbleMsgUtils';
import useConversationStore from '@/stores/ConversationStore';
import { useShallow } from 'zustand/react/shallow';
import { ReplyIcon } from '@/components/Icons';
@ -23,6 +24,34 @@ const BubbleIM = ({ handlePreview, handleContactClick, setNewChatModalVisible, s
setNewChatModalVisible(true);
setNewChatFormValues((prev) => ({ ...prev, phone_number: wa_id, name: wa_name }));
};
// Render parsed tokens to React elements
const renderMDTokens = (tokens) => {
return tokens.map((token, index) => {
switch (token.type) {
case 'text':
return <span key={index}>{token.content}</span>
case 'bold':
return <b key={index}>{renderMDTokens(token.content)}</b>
case 'italic':
return <i key={index}>{renderMDTokens(token.content)}</i>
case 'url':
return (
<a key={index} href={token.content} target='_blank' rel='noopener noreferrer' className=' underline text-sm'>
{token.content}
</a>
)
case 'number':
return (
<a key={`${index}`} className='text-sm ' onClick={() => openNewChatModal({ wa_id: token.content, wa_name: token.content })}>
{token.content}
</a>
)
default:
return <span key={index}>{token.content}</span>
}
})
};
const RenderText = memo(function renderText({ str, className, template, message }) {
let headerObj, footerObj, buttonsArr;
if (!isEmpty(template) && !isEmpty(template.components)) {
@ -32,26 +61,8 @@ const BubbleIM = ({ handlePreview, handleContactClick, setNewChatModalVisible, s
buttonsArr = componentsObj?.button; // ?.reduce((r, c) => r.concat(c.buttons), []);
}
const parts = str.split(/(https?:\/\/[^\s()]+|\p{Emoji_Presentation}|\d{4,})/gmu).filter((s) => s !== '');
const links = str.match(/https?:\/\/[^\s()]+/gi) || [];
const numbers = str.match(/\d{4,}/g) || [];
const emojis = str.match(/([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g) || [];
const extraClass = isEmpty(emojis) ? '' : '';
const objArr = parts.reduce((prev, curr, index) => {
if (links.includes(curr)) {
prev.push({ type: 'link', key: curr });
} else if (numbers.includes(curr)) {
prev.push({ type: 'number', key: curr });
} else if (emojis.includes(curr)) {
prev.push({ type: 'emoji', key: curr });
} else {
prev.push({ type: 'text', key: curr });
}
return prev;
}, []);
return (
<span className={`text-sm leading-5 emoji-text whitespace-pre-wrap ${className} ${extraClass}`} key={'msg-text'}>
<div className={`text-sm leading-5 emoji-text whitespace-pre-wrap ${className}`} key={'msg-text'}>
{headerObj ? (
<div className='text-neutral-500 text-center'>
{'text' === (headerObj?.parameters?.[0]?.type || '').toLowerCase() && <div>{headerObj.text}</div>}
@ -63,24 +74,7 @@ const BubbleIM = ({ handlePreview, handleContactClick, setNewChatModalVisible, s
)}
</div>
) : null}
{(objArr || []).map((part, index) => {
if (part.type === 'link') {
return (
<a href={part.key} target='_blank' key={`${part.key}${index}`} rel='noreferrer' className='text-sm'>
{part.key}
</a>
)
} else if (part.type === 'number') {
return (
<a key={`${part.key}${index}`} className='text-sm' onClick={() => openNewChatModal({ wa_id: part.key, wa_name: part.key })}>
{part.key}
</a>
)
} else {
// if (part.type === 'emoji')
return part.key
}
})}
{renderMDTokens(parseSimpleMarkdown(str))}
{footerObj ? <div className=' text-neutral-500'>{footerObj.text}</div> : null}
{buttonsArr && buttonsArr.length > 0 ? (
<div className='flex flex-row gap-1'>
@ -101,7 +95,7 @@ const BubbleIM = ({ handlePreview, handleContactClick, setNewChatModalVisible, s
)}
</div>
) : null}
</span>
</div>
)
});
return (

Loading…
Cancel
Save