[0];
+ const mobileAttachment = mobileAttachmentContentOf(asMessage);
+ if (mobileAttachment) {
+ const label =
+ mobileAttachment.kind === 'image'
+ ? '[图片]'
+ : mobileAttachment.kind === 'video'
+ ? '[视频]'
+ : mobileAttachment.kind === 'voice'
+ ? '[语音]'
+ : `[文件] ${mobileAttachment.name || ''}`;
+ return {label};
+ }
+ if (contactCardContentOf(asMessage)) return [名片];
return {renderDrafty(content, highlightQuery)}
;
}
diff --git a/web/src/lib/api/messages.ts b/web/src/lib/api/messages.ts
index dbb152d5..f2d6cb17 100644
--- a/web/src/lib/api/messages.ts
+++ b/web/src/lib/api/messages.ts
@@ -65,6 +65,14 @@ export interface SearchHit {
// 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).
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;
};
}
diff --git a/web/src/lib/messages/actions.ts b/web/src/lib/messages/actions.ts
index 658126a1..3326fc52 100644
--- a/web/src/lib/messages/actions.ts
+++ b/web/src/lib/messages/actions.ts
@@ -1,7 +1,7 @@
import type { StoredMessage } from 'tinode-sdk';
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';
/** 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.
const isCard = contactCardContentOf(msg) !== 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[];
if (isCall) {
actions = ['favorite'];
- } else if (isCard) {
+ } else if (isCard || isMobileAttachment) {
actions = ['quote', 'favorite', 'forward'];
} else {
actions = ['quote', 'copy', 'favorite', 'forward'];
diff --git a/web/src/lib/messages/drafty.tsx b/web/src/lib/messages/drafty.tsx
index c0c544db..c52957aa 100644
--- a/web/src/lib/messages/drafty.tsx
+++ b/web/src/lib/messages/drafty.tsx
@@ -25,6 +25,13 @@ import { openUserCard } from '@/stores/userCard';
* 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[] {
+ // 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();
if (!needle) return [text];
const haystack = text.toLowerCase();