排查转发bug同类问题:修复查找聊天记录崩溃 + 复制功能复制出"undefined"
审查了所有"读取消息content做数据处理"的地方,找到两处和转发bug同一根因 (手机端扁平非Drafty内容格式)的真实问题: 1. 查找聊天记录(HistorySearchModal)的搜索结果渲染,对手机端发送的图片/文件/ 语音/视频/名片消息命中关键词时会让整个应用崩溃——Drafty.toPlainText在这 类扁平内容上返回undefined,highlightMatches对undefined调用.toLowerCase() 直接抛异常,而这个应用完全没有错误边界(ErrorBoundary)兜底,等于白屏。 已用真实搜索接口返回确认snapshot里其实带着head字段(此前SearchHit类型 没建模),据此在渲染前识别这几种扁平内容并显示[图片]/[文件]/[语音]/[名片] 这样的占位文案,不再无脑喂给renderDrafty;顺手给highlightMatches补了防御 性判空,避免类似疏漏再次崩溃整个应用。 2. 消息右键菜单"复制"功能,对手机端发送的扁平文本消息(如群公告自动发布的 那条)和图片/文件/语音/视频消息,复制到剪贴板的是字面字符串"undefined"。 文本类修成正确提取真实文本;附件类直接从"复制"这个操作里去掉(参考名片/ 通话记录消息已有的排除逻辑),复制一段JSON式的附件数据本来就没有意义。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
eb0a6eba7e
commit
105ff76959
@@ -24,7 +24,7 @@ import { getMe } from '@/lib/api/profile';
|
|||||||
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
|
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
|
||||||
import { useClickOutside } from '@/lib/hooks';
|
import { useClickOutside } from '@/lib/hooks';
|
||||||
import { availableActions, recallRemainingSeconds, type MessageAction } from '@/lib/messages/actions';
|
import { availableActions, recallRemainingSeconds, type MessageAction } from '@/lib/messages/actions';
|
||||||
import { isRecalled } from '@/lib/messages/dispatch';
|
import { flatTextContentOf, isRecalled } from '@/lib/messages/dispatch';
|
||||||
import { buildMentionedContent, detectMentionTrigger, type PendingMention } from '@/lib/messages/mentions';
|
import { buildMentionedContent, detectMentionTrigger, type PendingMention } from '@/lib/messages/mentions';
|
||||||
import { buildReplyInfo, replySnapshotText } from '@/lib/messages/reply';
|
import { buildReplyInfo, replySnapshotText } from '@/lib/messages/reply';
|
||||||
import { groupSenderLabel } from '@/lib/messages/sender-name';
|
import { groupSenderLabel } from '@/lib/messages/sender-name';
|
||||||
@@ -846,9 +846,16 @@ export function ConversationView() {
|
|||||||
setMenu(null);
|
setMenu(null);
|
||||||
if (!msg || typeof msg.seq !== 'number') return;
|
if (!msg || typeof msg.seq !== 'number') return;
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'copy':
|
case 'copy': {
|
||||||
navigator.clipboard.writeText(Drafty.toPlainText(msg.content ?? '')).catch(() => {});
|
// Real, live-confirmed bug: a flat non-Drafty text message (e.g. the group-announcement
|
||||||
|
// auto-post, `{text:"..."}` not `{txt:"..."}`) isn't excluded from 'copy' by availableActions
|
||||||
|
// (it reads fine in the bubble via renderDrafty's own flatTextContentOf fallback -- only this
|
||||||
|
// handler's `Drafty.toPlainText` didn't know about it), so "复制" silently put the literal
|
||||||
|
// string "undefined" on the clipboard instead of the text visibly shown on screen.
|
||||||
|
const flatText = flatTextContentOf(msg.content);
|
||||||
|
navigator.clipboard.writeText(flatText ?? Drafty.toPlainText(msg.content ?? '')).catch(() => {});
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'recall':
|
case 'recall':
|
||||||
recallMessage(topicId, msg.seq)
|
recallMessage(topicId, msg.seq)
|
||||||
.then(() => topic.delMessagesList([msg.seq], true))
|
.then(() => topic.delMessagesList([msg.seq], true))
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { getTopicMedia, searchTopicMessages, type MediaFetchType, type MediaItem
|
|||||||
import { checkUrlSafety } from '@/lib/api/security';
|
import { checkUrlSafety } from '@/lib/api/security';
|
||||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||||
import { useAuthedBlobUrl, useDebouncedValue, useEscapeKey } from '@/lib/hooks';
|
import { useAuthedBlobUrl, useDebouncedValue, useEscapeKey } from '@/lib/hooks';
|
||||||
|
import { contactCardContentOf, mobileAttachmentContentOf } from '@/lib/messages/dispatch';
|
||||||
import { renderDrafty } from '@/lib/messages/drafty';
|
import { renderDrafty } from '@/lib/messages/drafty';
|
||||||
import { groupSenderLabel } from '@/lib/messages/sender-name';
|
import { groupSenderLabel } from '@/lib/messages/sender-name';
|
||||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||||
@@ -68,10 +69,34 @@ function MediaThumb({ item, isVideo }: { item: MediaItem; isVideo: boolean }) {
|
|||||||
* through rather than picking fields back out of it by hand. The previous version's manual
|
* through rather than picking fields back out of it by hand. The previous version's manual
|
||||||
* `{txt:content.txt||' ', ...}` reconstruction silently produced a single blank space for the bare-
|
* `{txt:content.txt||' ', ...}` reconstruction silently produced a single blank space for the bare-
|
||||||
* string case (`content.txt` on a string primitive is `undefined`), which is exactly what a live
|
* string case (`content.txt` on a string primitive is `undefined`), which is exactly what a live
|
||||||
* search against a real plain-text message showed before this fix. */
|
* search against a real plain-text message showed before this fix.
|
||||||
|
*
|
||||||
|
* Real, DB/API-confirmed bug fixed here (2026-09-24): a mobile-sent message's `snapshot.content` is
|
||||||
|
* a FLAT, non-Drafty object (`{text:...}` for text, `{file_id,...}` for image/file/voice/video,
|
||||||
|
* `{user_id,...}` for a contact card), tagged only by `snapshot.head['x-im-type']` -- confirmed live
|
||||||
|
* via a real search hit's raw network response (`snapshot.head` IS present despite not previously
|
||||||
|
* being modeled on `SearchHit`, same as favorites.ts's own snapshot). Handing that straight to
|
||||||
|
* `renderDrafty` used to crash the WHOLE APP the moment a keyword matched one (`Drafty.toPlainText`
|
||||||
|
* returns `undefined` for a shape with no `.txt`, and `highlightMatches` then called `.toLowerCase()`
|
||||||
|
* on it -- this app has no error boundary anywhere to contain that). Same root cause, and same fix
|
||||||
|
* approach, as `forwardMessage()`'s own `flatForwardPayload` (lib/tinode/send.ts). */
|
||||||
function SnapshotContent({ item, highlightQuery }: { item: SearchHit; highlightQuery?: string }) {
|
function SnapshotContent({ item, highlightQuery }: { item: SearchHit; highlightQuery?: string }) {
|
||||||
const content = item.snapshot?.content;
|
const content = item.snapshot?.content;
|
||||||
if (!content) return <span className="text-text-tertiary-4">(内容不可用)</span>;
|
if (!content) return <span className="text-text-tertiary-4">(内容不可用)</span>;
|
||||||
|
const asMessage = { content, head: item.snapshot?.head } as Parameters<typeof mobileAttachmentContentOf>[0];
|
||||||
|
const mobileAttachment = mobileAttachmentContentOf(asMessage);
|
||||||
|
if (mobileAttachment) {
|
||||||
|
const label =
|
||||||
|
mobileAttachment.kind === 'image'
|
||||||
|
? '[图片]'
|
||||||
|
: mobileAttachment.kind === 'video'
|
||||||
|
? '[视频]'
|
||||||
|
: mobileAttachment.kind === 'voice'
|
||||||
|
? '[语音]'
|
||||||
|
: `[文件] ${mobileAttachment.name || ''}`;
|
||||||
|
return <span className="text-text-body">{label}</span>;
|
||||||
|
}
|
||||||
|
if (contactCardContentOf(asMessage)) return <span className="text-text-body">[名片]</span>;
|
||||||
return <div className="text-sm text-text-body">{renderDrafty(content, highlightQuery)}</div>;
|
return <div className="text-sm text-text-body">{renderDrafty(content, highlightQuery)}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ export interface SearchHit {
|
|||||||
// other Drafty consumer in this app, discovered here because a real search hit for a plain-text
|
// other Drafty consumer in this app, discovered here because a real search hit for a plain-text
|
||||||
// message rendered as blank space until this was fixed (see HistorySearchModal's SnapshotContent).
|
// message rendered as blank space until this was fixed (see HistorySearchModal's SnapshotContent).
|
||||||
content?: DraftyContent | string;
|
content?: DraftyContent | string;
|
||||||
|
// Confirmed LIVE (2026-09-24, a real mobile-sent flat-text hit): the snapshot also carries the
|
||||||
|
// original message's `head`, same as favorites.ts's own snapshot already documents. Needed to
|
||||||
|
// recognize a mobile-sent flat content shape (`{text:...}`/`{file_id,...}`/etc., tagged only by
|
||||||
|
// `head['x-im-type']`) BEFORE handing `content` to renderDrafty -- that flat shape isn't a real
|
||||||
|
// Drafty document, and used to crash the whole search modal (no `.txt`/`.text` field `renderDrafty`
|
||||||
|
// recognizes -> `Drafty.toPlainText` returns `undefined` -> highlightMatches() called `.toLowerCase()`
|
||||||
|
// on it). See HistorySearchModal's SnapshotContent for the fix.
|
||||||
|
head?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { StoredMessage } from 'tinode-sdk';
|
import type { StoredMessage } from 'tinode-sdk';
|
||||||
|
|
||||||
import type { MessagePolicy } from '@/lib/api/bootstrap';
|
import type { MessagePolicy } from '@/lib/api/bootstrap';
|
||||||
import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted } from '@/lib/messages/dispatch';
|
import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted, mobileAttachmentContentOf } from '@/lib/messages/dispatch';
|
||||||
import { canQuote } from '@/lib/messages/reply';
|
import { canQuote } from '@/lib/messages/reply';
|
||||||
|
|
||||||
/** SDK-internal placeholder seq threshold for a not-yet-server-confirmed message (tinode.dev.js's
|
/** SDK-internal placeholder seq threshold for a not-yet-server-confirmed message (tinode.dev.js's
|
||||||
@@ -42,10 +42,17 @@ export function availableActions(msg: StoredMessage, myUserId: string | null, me
|
|||||||
// has no meaningful "forward/quote" semantics anywhere in the cross-client spec.
|
// has no meaningful "forward/quote" semantics anywhere in the cross-client spec.
|
||||||
const isCard = contactCardContentOf(msg) !== null;
|
const isCard = contactCardContentOf(msg) !== null;
|
||||||
const isCall = callResultDisplay(msg, !!myUserId && msg.from === myUserId) !== null;
|
const isCall = callResultDisplay(msg, !!myUserId && msg.from === myUserId) !== null;
|
||||||
|
// Same flat-object reasoning as contact cards -- a mobile-sent image/file/voice/video message has
|
||||||
|
// no `.txt`/`.text` for `copy` to extract (confirmed live: `ConversationView.tsx`'s copy handler
|
||||||
|
// used to put the literal string "undefined" on the clipboard for one of these). Quote/favorite/
|
||||||
|
// forward are unaffected -- quote uses the x-im-reply snapshot same as a contact card, favorite/
|
||||||
|
// forward already handle this shape correctly (favorites.ts's own snapshot, `forwardMessage`'s
|
||||||
|
// `flatForwardPayload`).
|
||||||
|
const isMobileAttachment = mobileAttachmentContentOf(msg) !== null;
|
||||||
let actions: MessageAction[];
|
let actions: MessageAction[];
|
||||||
if (isCall) {
|
if (isCall) {
|
||||||
actions = ['favorite'];
|
actions = ['favorite'];
|
||||||
} else if (isCard) {
|
} else if (isCard || isMobileAttachment) {
|
||||||
actions = ['quote', 'favorite', 'forward'];
|
actions = ['quote', 'favorite', 'forward'];
|
||||||
} else {
|
} else {
|
||||||
actions = ['quote', 'copy', 'favorite', 'forward'];
|
actions = ['quote', 'copy', 'favorite', 'forward'];
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ import { openUserCard } from '@/stores/userCard';
|
|||||||
* feature for user-authored highlight formatting, not search-match highlighting). Global,
|
* feature for user-authored highlight formatting, not search-match highlighting). Global,
|
||||||
* case-insensitive, one `<mark>` per match. */
|
* case-insensitive, one `<mark>` per match. */
|
||||||
function highlightMatches(text: string, query: string, keyPrefix: string | number): ReactNode[] {
|
function highlightMatches(text: string, query: string, keyPrefix: string | number): ReactNode[] {
|
||||||
|
// Defensive, not decorative: a caller one level up (`renderDrafty`'s plain-text branch) can still
|
||||||
|
// hand this `undefined` for a flat non-Drafty shape it doesn't recognize (a mobile-sent attachment/
|
||||||
|
// contact-card snapshot with no `.text`/`.txt` field) -- live-confirmed to crash the whole app with
|
||||||
|
// no error boundary to catch it (`text.toLowerCase()` on `undefined`) the moment a search keyword
|
||||||
|
// matched one of those. `renderDrafty` itself still degrades to a placeholder-less `undefined`
|
||||||
|
// node for the no-keyword case, which this alone doesn't fix -- see SnapshotContent's own guard.
|
||||||
|
if (text == null) return [];
|
||||||
const needle = query.trim().toLowerCase();
|
const needle = query.trim().toLowerCase();
|
||||||
if (!needle) return [text];
|
if (!needle) return [text];
|
||||||
const haystack = text.toLowerCase();
|
const haystack = text.toLowerCase();
|
||||||
|
|||||||
Reference in New Issue
Block a user