import { Fragment, type ReactNode } from 'react'; import { Drafty, type DraftyContent } from 'tinode-sdk'; import { AudioAttachment, FileAttachment, ImageAttachment, VideoAttachment } from '@/features/messages/AttachmentPreview'; import { SafeLink } from '@/features/messages/SafeLink'; import { flatTextContentOf } from '@/lib/messages/dispatch'; /** * Bubble content renderer for the Drafty style tags this app currently sends/receives. Grown * incrementally alongside composer features -- IM/VD/AU/QQ/TC/VC/BN/FM land when their respective * message types (images, voice, quotes, cards, calls, forms) are actually built, matching this * project's existing pattern for the SDK's own ambient types. * * @mention renders as a flat accent blue (README §3.2's "@提及:文本内 color:#1677ff") rather than * porting the old app's per-user hash-colored mentions -- a deliberate new-design simplification, * not an oversight (confirmed against the old app's formatters.js:122-128, which does hash-color * mentions the same way avatars are colored). */ /** README §6.3 (mock `05/1e`/`1f`): search-result keyword matches render as ``, not a yellow highlight -- a plain-text style, not * the SDK's own unrelated `HL` Drafty tag (that one is `bg-warn-bg`, a different, currently-unused * feature for user-authored highlight formatting, not search-match highlighting). Global, * case-insensitive, one `` per match. */ function highlightMatches(text: string, query: string, keyPrefix: string | number): ReactNode[] { const needle = query.trim().toLowerCase(); if (!needle) return [text]; const haystack = text.toLowerCase(); const parts: ReactNode[] = []; let cursor = 0; let idx = haystack.indexOf(needle, cursor); if (idx === -1) return [text]; let n = 0; while (idx !== -1) { if (idx > cursor) parts.push(text.slice(cursor, idx)); parts.push( {text.slice(idx, idx + query.trim().length)} , ); cursor = idx + query.trim().length; idx = haystack.indexOf(needle, cursor); } if (cursor < text.length) parts.push(text.slice(cursor)); return parts; } /** Curried on `highlightQuery` rather than taking it as a plain 5th parameter -- `Drafty.format`'s * own callback type has a REAL 5th parameter (`stack: unknown[]`, see treeBottomUp's actual call), * so a `formatNode` typed to accept `highlightQuery` in that same slot would stop structurally * matching the formatter type `Drafty.format` expects wherever it's passed directly by reference. */ function makeFormatNode(highlightQuery: string | undefined) { return function formatNode( style: string | undefined, data: Record | undefined, values: ReactNode[] | null, key: number | string, ): ReactNode { const children = values ?? undefined; switch (style) { case 'ST': return {children}; case 'EM': return {children}; case 'DL': return {children}; case 'CO': return ( {children} ); case 'BR': return
; case 'MN': return ( {children} ); case 'HL': return ( {children} ); case 'LN': { const url = typeof data?.url === 'string' ? data.url : undefined; return ( {children} ); } case 'IM': return ; case 'EX': return ; case 'AU': return ; case 'VD': return ; case 'QQ': // Drafty.quote() has no `key`/`data` of its own (tinode.dev.js:2160-2168) -- it's a plain // style span wrapping mention(header,uid) + linebreak + body, already in `children`. return (
{children}
); default: // A genuine plain-text leaf (per treeBottomUp: no children, so `values` collapses to the // node's own raw `[text]`) is the only shape safe to slice up for highlighting -- anything // else (an already-formatted child array from a styled ancestor) is left exactly as before. if (highlightQuery && values?.length === 1 && typeof values[0] === 'string') { return {highlightMatches(values[0], highlightQuery, key)}; } return {children}; } }; } /** `highlightQuery` is opt-in and additive -- every existing call site that omits it renders exactly * as before; only HistorySearchModal's search results pass one. */ export function renderDrafty(content: DraftyContent | string | null | undefined, highlightQuery?: string): ReactNode { if (content == null) return null; if (typeof content === 'string') return highlightQuery ? <>{highlightMatches(content, highlightQuery, 'plain')} : content; const flatText = flatTextContentOf(content); if (flatText != null) return highlightQuery ? <>{highlightMatches(flatText, highlightQuery, 'plain')} : flatText; if (Drafty.isPlainText(content)) { const text = Drafty.toPlainText(content); return highlightQuery ? <>{highlightMatches(text, highlightQuery, 'plain')} : text; } return Drafty.format(content, makeFormatNode(highlightQuery)); }