Files
im_webapp/web/src/lib/messages/drafty.tsx
T
Your Name 823a47e182 修复群公告发布通知消息正文完全不显示、聊天列表预览显示"undefined"
群公告发布后自动生成的通知消息,真实content是{text:"群公告\n<内容>"}这种
扁平对象(不是纯字符串,也不是标准Drafty的{txt,fmt,ent}结构),且
head['x-im-type']是"text"而不是已处理的"system_notice",落到Drafty处理
分支后既渲染不出气泡正文、列表预览也变成字面的"undefined"字符串。

抽出dispatch.ts里原本只服务system_notice的{text}扁平内容识别逻辑
(重命名为flatTextContentOf并导出),在renderDrafty和
formatMessagePreview的Drafty分支之前都接上——不改变已有的居中系统提示
样式(这条消息本来就该是普通气泡,发送者信息一直是正确的,只是正文
丢了),只是把"文本内容是扁平{text}对象"这一种情况也当作合法纯文本处理。

实测:真实发布一条群公告后,气泡正文和列表预览都正确显示"群公告
全功能测试公告内容",不再是空白或"undefined"。
2026-09-16 16:37:48 +08:00

136 lines
6.0 KiB
TypeScript

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 `<mark style="background:
* transparent; color:#1677ff; font-weight:500">`, 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 `<mark>` 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(
<mark key={`${keyPrefix}-hl-${n++}`} className="bg-transparent font-medium text-accent">
{text.slice(idx, idx + query.trim().length)}
</mark>,
);
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<string, unknown> | undefined,
values: ReactNode[] | null,
key: number | string,
): ReactNode {
const children = values ?? undefined;
switch (style) {
case 'ST':
return <b key={key}>{children}</b>;
case 'EM':
return <i key={key}>{children}</i>;
case 'DL':
return <s key={key}>{children}</s>;
case 'CO':
return (
<code key={key} className="rounded bg-surface-input px-1 py-0.5 font-mono text-[0.9em]">
{children}
</code>
);
case 'BR':
return <br key={key} />;
case 'MN':
return (
<span key={key} className="text-accent">
{children}
</span>
);
case 'HL':
return (
<mark key={key} className="bg-warn-bg">
{children}
</mark>
);
case 'LN': {
const url = typeof data?.url === 'string' ? data.url : undefined;
return (
<SafeLink key={key} href={url}>
{children}
</SafeLink>
);
}
case 'IM':
return <ImageAttachment key={key} data={data || {}} />;
case 'EX':
return <FileAttachment key={key} data={data || {}} />;
case 'AU':
return <AudioAttachment key={key} data={data || {}} />;
case 'VD':
return <VideoAttachment key={key} data={data || {}} />;
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 (
<div key={key} className="mb-1.5 rounded border-l-2 border-accent bg-white/50 px-2 py-0.5 text-xs text-[#4a4f57]">
{children}
</div>
);
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 <Fragment key={key}>{highlightMatches(values[0], highlightQuery, key)}</Fragment>;
}
return <Fragment key={key}>{children}</Fragment>;
}
};
}
/** `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));
}