export type ChatTextSeg = { text: string; mention?: boolean; hit?: boolean; bold?: boolean; italic?: boolean; 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; const ITALIC_RE = /_([^_\n]+)_/g; 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 }, }); } 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 }, }); } 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) { if (part.mention || part.link || part.crm || part.bold || part.italic) { 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; } /** * فرمت سبک شبیه تلگرام: *پررنگ*، _ایتالیک_، لیست با - ، لینک http، mento://… */ 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; }