2026-09-30 14:58:44 +03:30
|
|
|
export type ChatTextSeg = {
|
|
|
|
|
text: string;
|
|
|
|
|
mention?: boolean;
|
|
|
|
|
hit?: boolean;
|
|
|
|
|
bold?: boolean;
|
2026-09-30 15:23:19 +03:30
|
|
|
italic?: boolean;
|
2026-09-30 14:58:44 +03:30
|
|
|
link?: string;
|
|
|
|
|
/** deep CRM link */
|
|
|
|
|
crm?: { kind: 'person' | 'deal' | 'ticket'; id: string };
|
|
|
|
|
listItem?: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const URL_RE =
|
|
|
|
|
/https?:\/\/[^\s<>"'`]+|(?:www\.)[^\s<>"'`]+/gi;
|
|
|
|
|
const CRM_RE = /mento:\/\/(person|deal|ticket)\/([A-Za-z0-9_-]+)/gi;
|
|
|
|
|
const BOLD_RE = /\*([^*\n]+)\*/g;
|
2026-09-30 15:23:19 +03:30
|
|
|
const ITALIC_RE = /_([^_\n]+)_/g;
|
2026-09-30 14:58:44 +03:30
|
|
|
const LIST_LINE_RE = /^\s*[-•]\s+/;
|
|
|
|
|
|
|
|
|
|
function escapeRegExp(s: string) {
|
|
|
|
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pushPlain(parts: ChatTextSeg[], text: string) {
|
|
|
|
|
if (text) parts.push({ text });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Parse one inline run (no newlines) into segments. */
|
|
|
|
|
function parseInline(
|
|
|
|
|
raw: string,
|
|
|
|
|
mentionNames: string[],
|
|
|
|
|
): ChatTextSeg[] {
|
|
|
|
|
if (!raw) return [];
|
|
|
|
|
|
|
|
|
|
type Mark = { start: number; end: number; seg: ChatTextSeg };
|
|
|
|
|
const marks: Mark[] = [];
|
|
|
|
|
|
|
|
|
|
const mentionRe = mentionNames.length
|
|
|
|
|
? new RegExp(
|
|
|
|
|
`(@(?:${mentionNames.map(escapeRegExp).join('|')}))`,
|
|
|
|
|
'g',
|
|
|
|
|
)
|
|
|
|
|
: /(@[^\s@]{2,40})/g;
|
|
|
|
|
|
|
|
|
|
for (const m of raw.matchAll(mentionRe)) {
|
|
|
|
|
const start = m.index ?? 0;
|
|
|
|
|
marks.push({
|
|
|
|
|
start,
|
|
|
|
|
end: start + m[0].length,
|
|
|
|
|
seg: { text: m[0], mention: true },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const m of raw.matchAll(CRM_RE)) {
|
|
|
|
|
const start = m.index ?? 0;
|
|
|
|
|
const kind = m[1] as 'person' | 'deal' | 'ticket';
|
|
|
|
|
const id = m[2];
|
|
|
|
|
const label =
|
|
|
|
|
kind === 'person' ? 'شخص' : kind === 'deal' ? 'معامله' : 'تیکت';
|
|
|
|
|
marks.push({
|
|
|
|
|
start,
|
|
|
|
|
end: start + m[0].length,
|
|
|
|
|
seg: { text: label, link: m[0], crm: { kind, id } },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const m of raw.matchAll(URL_RE)) {
|
|
|
|
|
const start = m.index ?? 0;
|
|
|
|
|
let url = m[0];
|
|
|
|
|
// trim trailing punctuation common in Persian/English
|
|
|
|
|
url = url.replace(/[)،.,;:!?]+$/g, '');
|
|
|
|
|
const end = start + url.length;
|
|
|
|
|
const href = url.startsWith('http') ? url : `https://${url}`;
|
|
|
|
|
marks.push({
|
|
|
|
|
start,
|
|
|
|
|
end,
|
|
|
|
|
seg: { text: url, link: href },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const m of raw.matchAll(BOLD_RE)) {
|
|
|
|
|
const start = m.index ?? 0;
|
|
|
|
|
marks.push({
|
|
|
|
|
start,
|
|
|
|
|
end: start + m[0].length,
|
|
|
|
|
seg: { text: m[1], bold: true },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 15:23:19 +03:30
|
|
|
for (const m of raw.matchAll(ITALIC_RE)) {
|
|
|
|
|
const start = m.index ?? 0;
|
|
|
|
|
marks.push({
|
|
|
|
|
start,
|
|
|
|
|
end: start + m[0].length,
|
|
|
|
|
seg: { text: m[1], italic: true },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 14:58:44 +03:30
|
|
|
marks.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
|
|
|
const chosen: Mark[] = [];
|
|
|
|
|
let cursor = 0;
|
|
|
|
|
for (const mark of marks) {
|
|
|
|
|
if (mark.start < cursor) continue;
|
|
|
|
|
chosen.push(mark);
|
|
|
|
|
cursor = mark.end;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const out: ChatTextSeg[] = [];
|
|
|
|
|
let last = 0;
|
|
|
|
|
for (const mark of chosen) {
|
|
|
|
|
if (mark.start > last) pushPlain(out, raw.slice(last, mark.start));
|
|
|
|
|
out.push(mark.seg);
|
|
|
|
|
last = mark.end;
|
|
|
|
|
}
|
|
|
|
|
if (last < raw.length) pushPlain(out, raw.slice(last));
|
|
|
|
|
return out.length ? out : [{ text: raw }];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyHighlight(parts: ChatTextSeg[], q: string): ChatTextSeg[] {
|
|
|
|
|
const qLower = q.trim().toLowerCase();
|
|
|
|
|
if (!qLower) return parts;
|
|
|
|
|
const out: ChatTextSeg[] = [];
|
|
|
|
|
for (const part of parts) {
|
2026-09-30 15:23:19 +03:30
|
|
|
if (part.mention || part.link || part.crm || part.bold || part.italic) {
|
2026-09-30 14:58:44 +03:30
|
|
|
out.push(part);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const text = part.text;
|
|
|
|
|
const lower = text.toLowerCase();
|
|
|
|
|
let cursor = 0;
|
|
|
|
|
let found = lower.indexOf(qLower, cursor);
|
|
|
|
|
if (found < 0) {
|
|
|
|
|
out.push(part);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
while (found >= 0) {
|
|
|
|
|
if (found > cursor) out.push({ text: text.slice(cursor, found) });
|
|
|
|
|
out.push({ text: text.slice(found, found + q.length), hit: true });
|
|
|
|
|
cursor = found + q.length;
|
|
|
|
|
found = lower.indexOf(qLower, cursor);
|
|
|
|
|
}
|
|
|
|
|
if (cursor < text.length) out.push({ text: text.slice(cursor) });
|
|
|
|
|
}
|
|
|
|
|
return out.length ? out : parts;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-30 15:23:19 +03:30
|
|
|
* فرمت سبک شبیه تلگرام: *پررنگ*، _ایتالیک_، لیست با - ، لینک http، mento://…
|
2026-09-30 14:58:44 +03:30
|
|
|
*/
|
|
|
|
|
export function parseChatMessageText(
|
|
|
|
|
content: string,
|
|
|
|
|
opts?: { mentionNames?: string[]; highlightQuery?: string },
|
|
|
|
|
): ChatTextSeg[] {
|
|
|
|
|
const raw = String(content || '');
|
|
|
|
|
if (!raw) return [];
|
|
|
|
|
const names = [...(opts?.mentionNames || [])]
|
|
|
|
|
.map((n) => n.trim())
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.sort((a, b) => b.length - a.length);
|
|
|
|
|
|
|
|
|
|
const lines = raw.split('\n');
|
|
|
|
|
const parts: ChatTextSeg[] = [];
|
|
|
|
|
lines.forEach((line, i) => {
|
|
|
|
|
if (i > 0) parts.push({ text: '\n' });
|
|
|
|
|
const isList = LIST_LINE_RE.test(line);
|
|
|
|
|
const body = isList ? line.replace(LIST_LINE_RE, '') : line;
|
|
|
|
|
const inline = parseInline(body, names);
|
|
|
|
|
if (isList) {
|
|
|
|
|
if (!inline.length) {
|
|
|
|
|
parts.push({ text: '• ', listItem: true });
|
|
|
|
|
} else {
|
|
|
|
|
parts.push({ text: '• ', listItem: true });
|
|
|
|
|
for (const seg of inline) parts.push({ ...seg, listItem: true });
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
parts.push(...inline);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return applyHighlight(parts.length ? parts : [{ text: raw }], opts?.highlightQuery || '');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function crmPathFor(kind: 'person' | 'deal' | 'ticket', id: string): string {
|
|
|
|
|
if (kind === 'person') return `/contacts/people/${id}`;
|
|
|
|
|
if (kind === 'deal') return `/deals/${id}`;
|
|
|
|
|
return `/tickets/${id}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function insertCrmLink(
|
|
|
|
|
kind: 'person' | 'deal' | 'ticket',
|
|
|
|
|
id: string,
|
|
|
|
|
title?: string,
|
|
|
|
|
): string {
|
|
|
|
|
const link = `mento://${kind}/${id}`;
|
|
|
|
|
return title ? `${title} ${link}` : link;
|
|
|
|
|
}
|