diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx index 9fdc8c1a..cd08d3a7 100644 --- a/web/src/features/messages/ConversationView.tsx +++ b/web/src/features/messages/ConversationView.tsx @@ -24,7 +24,7 @@ import { getMe } from '@/lib/api/profile'; import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config'; import { useClickOutside } from '@/lib/hooks'; 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 { buildReplyInfo, replySnapshotText } from '@/lib/messages/reply'; import { groupSenderLabel } from '@/lib/messages/sender-name'; @@ -846,9 +846,16 @@ export function ConversationView() { setMenu(null); if (!msg || typeof msg.seq !== 'number') return; switch (action) { - case 'copy': - navigator.clipboard.writeText(Drafty.toPlainText(msg.content ?? '')).catch(() => {}); + case 'copy': { + // 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; + } case 'recall': recallMessage(topicId, msg.seq) .then(() => topic.delMessagesList([msg.seq], true)) diff --git a/web/src/features/messages/HistorySearchModal.tsx b/web/src/features/messages/HistorySearchModal.tsx index 4b54f579..52d89804 100644 --- a/web/src/features/messages/HistorySearchModal.tsx +++ b/web/src/features/messages/HistorySearchModal.tsx @@ -8,6 +8,7 @@ import { getTopicMedia, searchTopicMessages, type MediaFetchType, type MediaItem import { checkUrlSafety } from '@/lib/api/security'; import { avatarBgClass, avatarLetter } from '@/lib/avatar'; import { useAuthedBlobUrl, useDebouncedValue, useEscapeKey } from '@/lib/hooks'; +import { contactCardContentOf, mobileAttachmentContentOf } from '@/lib/messages/dispatch'; import { renderDrafty } from '@/lib/messages/drafty'; import { groupSenderLabel } from '@/lib/messages/sender-name'; 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 * `{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 - * 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 }) { const content = item.snapshot?.content; if (!content) return (内容不可用); + const asMessage = { content, head: item.snapshot?.head } as Parameters[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();