diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx index a7600d69..43a9b997 100644 --- a/web/src/features/messages/ConversationView.tsx +++ b/web/src/features/messages/ConversationView.tsx @@ -17,7 +17,7 @@ import { VoiceRecorder } from '@/features/messages/VoiceRecorder'; import { GroupAnnouncementModal } from '@/features/groups/GroupAnnouncementModal'; import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal'; import { useBootstrap } from '@/lib/api/hooks'; -import { favoriteMessages, getTopicDraft, type MediaType, putTopicDraft, recallMessage } from '@/lib/api/messages'; +import { favoriteMessages, forwardMessages, getTopicDraft, type MediaType, putTopicDraft, recallMessage } from '@/lib/api/messages'; import { getAllGroupMembers, getAllGroupMutes, getGroup, getGroupGovernance, getGroupSummary } from '@/lib/api/groups'; import { getConversationContext, getUserProfile } from '@/lib/api/friends'; import { getMe } from '@/lib/api/profile'; @@ -31,7 +31,7 @@ import { groupSenderLabel } from '@/lib/messages/sender-name'; import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments'; import { isTopicGone } from '@/lib/tinode/errors'; import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks'; -import { forwardMessage, sendPrebuiltContent, sendReply, sendText } from '@/lib/tinode/send'; +import { sendPrebuiltContent, sendReply, sendText } from '@/lib/tinode/send'; import { closeConversation, ensureTopicAttached, @@ -818,25 +818,36 @@ export function ConversationView() { // order, landing #10 ahead of #3 in the target conversation and misrepresenting which one was // actually said first. const orderedSeqs = [...selectedSeqs].sort((a, b) => a - b); - for (const targetTopic of targetTopics) { - // A target that isn't the conversation currently open (i.e. almost any real forward target) - // is not actually subscribed, and publishing on an unsubscribed topic is rejected outright by - // the SDK -- see ensureTopicAttached's own comment for why this is the common case, not an - // edge case. - ensureTopicAttached(targetTopic) - .then((target) => { - for (const seq of orderedSeqs) { - const msg = topic.findMessage(seq); - // A message can be recalled by its sender AFTER it was already selected (the checkbox - // itself refuses a NEW selection of a recalled message, but can't retroactively un-select - // one recalled mid-session) -- forwarding null content would silently send an empty bubble - // with a misleading "forwarded from" tag rather than the message the user actually picked. - if (msg && !isRecalled(msg)) forwardMessage(topicId, msg, target).catch((err) => showError(err, '转发失败')); - } - if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败')); + // A message can be recalled by its sender AFTER it was already selected (the checkbox itself + // refuses a NEW selection of a recalled message, but can't retroactively un-select one recalled + // mid-session) -- forwarding null content would silently send an empty bubble with a misleading + // "forwarded from" tag rather than the message the user actually picked. + const sources = orderedSeqs + .map((seq) => topic.findMessage(seq)) + .filter((msg): msg is StoredMessage => !!msg && !isRecalled(msg)) + .map((msg) => ({ topic: topicId, seq: msg.seq as number })); + // One REST call now covers every (source, target) pair -- forwardMessages()'s own comment + // explains why this replaced per-target `ensureTopicAttached()` + per-message client-side + // `{pub}` publishes (was N sources x M targets separate WS sends, each independently racing + // subscription/attachment; the server handles every pair in one request with real per-pair + // success/failure reporting instead of each WS publish failing silently on its own). + if (sources.length > 0) { + forwardMessages(sources, targetTopics) + .then((res) => { + if (res.failed.length > 0) showError(undefined, `${res.failed.length} 条消息转发失败`); }) .catch((err) => showError(err, '转发失败')); } + // Comment stays on the OLD client-side path deliberately -- see forwardMessages()'s own comment + // on why (preserves this app's existing "comment appears AFTER the forwarded messages" order, + // which the endpoint's own built-in comment field does not). + if (comment) { + for (const targetTopic of targetTopics) { + ensureTopicAttached(targetTopic) + .then((target) => sendText(target, comment)) + .catch((err) => showError(err, '附言发送失败')); + } + } setBulkForwardOpen(false); exitSelectMode(); }; @@ -1218,14 +1229,23 @@ export function ConversationView() { setForwardMsg(null)} onPick={(targetTopics, comment) => { - for (const targetTopic of targetTopics) { - ensureTopicAttached(targetTopic) - .then((target) => { - forwardMessage(topicId, forwardMsg, target).catch((err) => showError(err, '转发失败')); - if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败')); + if (typeof forwardMsg.seq === 'number') { + forwardMessages([{ topic: topicId, seq: forwardMsg.seq }], targetTopics) + .then((res) => { + if (res.failed.length > 0) showError(undefined, `${res.failed.length} 个目标转发失败`); }) .catch((err) => showError(err, '转发失败')); } + // Comment stays on the OLD client-side path deliberately -- see forwardMessages()'s own + // comment in lib/api/messages.ts on why (preserves the existing "comment appears AFTER + // the forwarded message" order). + if (comment) { + for (const targetTopic of targetTopics) { + ensureTopicAttached(targetTopic) + .then((target) => sendText(target, comment)) + .catch((err) => showError(err, '附言发送失败')); + } + } setForwardMsg(null); }} /> diff --git a/web/src/features/messages/ForwardPickerModal.tsx b/web/src/features/messages/ForwardPickerModal.tsx index 6348435d..4397bf9a 100644 --- a/web/src/features/messages/ForwardPickerModal.tsx +++ b/web/src/features/messages/ForwardPickerModal.tsx @@ -20,10 +20,9 @@ interface ForwardPickerModalProps { * context menu's single-message "转发", the multi-select bulk bar's "转发", and favorites' bulk * forward -- all three previously shared this component when it only supported picking ONE target * and sending immediately with no comment, meaning forwarding to several people required repeating - * the whole picker flow once per recipient. `forwardFavorites` (favorites.ts) already accepts - * `target_topics: string[]`/`comment` server-side; chat-message forwarding has no such batch REST - * endpoint (`forwardMessage` in lib/tinode/send.ts publishes one native {pub} per target instead), - * so callers loop `targetTopics` themselves -- this component only collects the selection. + * the whole picker flow once per recipient. Both `forwardFavorites` (favorites.ts) and chat-message + * forwarding (`forwardMessages`, lib/api/messages.ts) accept `target_topics: string[]` server-side + * in one batched call -- this component only collects the selection and comment; callers send. */ export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps) { const contacts = useChatList(); diff --git a/web/src/features/messages/HistorySearchModal.tsx b/web/src/features/messages/HistorySearchModal.tsx index 13cc57df..9bb375e4 100644 --- a/web/src/features/messages/HistorySearchModal.tsx +++ b/web/src/features/messages/HistorySearchModal.tsx @@ -78,8 +78,9 @@ function MediaThumb({ item, isVideo }: { item: MediaItem; isVideo: boolean }) { * 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). */ + * on it -- this app has no error boundary anywhere to contain that). Same flat-vs-Drafty shape + * distinction `dispatch.ts`'s own `mobileAttachmentContentOf`/`contactCardContentOf` already + * document for message bubbles. */ function SnapshotContent({ item, highlightQuery }: { item: SearchHit; highlightQuery?: string }) { const content = item.snapshot?.content; if (!content) return (内容不可用); diff --git a/web/src/lib/api/messages.ts b/web/src/lib/api/messages.ts index f2d6cb17..aedd3fe0 100644 --- a/web/src/lib/api/messages.ts +++ b/web/src/lib/api/messages.ts @@ -153,3 +153,41 @@ export function favoriteMessages(items: Array<{ topic: string; seq: number }>) { messages: items.map((i) => ({ topic: i.topic, seq: String(i.seq) })), }); } + +export interface ForwardResultItem { + source?: { topic: string; seq: number }; + target_topic: string; + client_mid: string; + seq: number; + status: 'sent' | 'pending' | 'uncertain'; + reason?: string; +} + +export interface ForwardFailureItem { + source?: { topic: string; seq: number }; + target_topic?: string; + status: 'failed'; + reason: string; +} + +/** + * `POST /v1/im/messages/forward` (server/im_http.go's serveIMMessageForward, confirmed by reading + * the real Go source directly, not the protocol doc) -- the SAME backend-authoritative forward path + * both real mobile clients use (confirmed by reading their real source too: iOS's + * ChatDetailViewController+Forward.swift, Android's ImMessageActionApi.forward(), NEITHER ever builds + * a `{pub}` packet client-side). One call handles every (source, target) pair at once -- up to 100 + * sources x 20 targets -- with per-pair success/failure reporting, and the server copies + * `msg.Content` verbatim regardless of shape (real Drafty or a mobile-sent flat object), so unlike + * the client-side approach this replaces, there is no content-shape detection to get wrong here. + * `comment` is deliberately NOT wired here even though the endpoint accepts one -- callers keep + * sending an optional forward comment as its own separate, ordinary text message afterward (matching + * this app's existing, unchanged UX: the comment appears AFTER the forwarded message(s), whereas the + * endpoint's own built-in `comment` would save it BEFORE -- a real behavioral difference that's the + * caller's choice to take on, not something to silently opt into here). + */ +export function forwardMessages(sources: Array<{ topic: string; seq: number }>, targetTopics: string[]) { + return imPost<{ items: ForwardResultItem[]; failed: ForwardFailureItem[] }>('/messages/forward', { + sources, + target_topics: targetTopics, + }); +} diff --git a/web/src/lib/messages/actions.ts b/web/src/lib/messages/actions.ts index 3326fc52..7b7c1c32 100644 --- a/web/src/lib/messages/actions.ts +++ b/web/src/lib/messages/actions.ts @@ -45,9 +45,10 @@ export function availableActions(msg: StoredMessage, myUserId: string | null, me // 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`). + // forward are unaffected -- quote uses the x-im-reply snapshot same as a contact card, favorite + // uses its own snapshot (favorites.ts), and forward now goes through the backend's REST forward + // endpoint (lib/api/messages.ts's `forwardMessages`), which copies content verbatim regardless of + // shape. const isMobileAttachment = mobileAttachmentContentOf(msg) !== null; let actions: MessageAction[]; if (isCall) { diff --git a/web/src/lib/tinode/send.ts b/web/src/lib/tinode/send.ts index 0b8f81fd..5c8c5cf8 100644 --- a/web/src/lib/tinode/send.ts +++ b/web/src/lib/tinode/send.ts @@ -2,89 +2,6 @@ import { Drafty, type DraftyContent, type StoredMessage, type Topic } from 'tino import { buildReplyInfo } from '@/lib/messages/reply'; -function toDraftyContent(content: DraftyContent | string | null | undefined): DraftyContent { - if (content == null) return Drafty.init(''); - return typeof content === 'string' ? Drafty.init(content) : content; -} - -/** - * Real, DB-confirmed root cause of a reported bug ("转发消息显示已经删除"): this backend's mobile - * apps send several message kinds as FLAT, non-Drafty JSON objects tagged only by `head['x-im-type']` - * (plain text as `{text:"..."}`, not `{txt:"..."}` -- same "flat object, not a real Drafty document" - * shape dispatch.ts's mobileAttachmentContentOf/contactCardContentOf already document for - * image/file/voice/video/contact_card) or by `head.webrtc` (a call result, `{callId,callType,...}`). - * The original `forwardMessage` fed `msg.content` straight into `Drafty.forwardedContent()` assuming - * it was always a plain string or a real `{txt,fmt,ent}` document -- for any of these flat shapes, - * `draftyToTree()` on an object with none of those fields degenerates to empty content, which the - * SDK's own outbound `simplify()` (every `{pub}` packet gets this pass -- see this file's other - * comments on it) then strips entirely before the packet reaches the server: confirmed live via a - * direct DB query on a real reported case (a real "seq测试" group, tenant 3) -- the persisted row had - * `head.forwarded` set correctly but `content`/`content_nonce` genuinely NULL, which the client - * (correctly, from its own perspective) then renders as "already recalled" (`isRecalled()` in - * dispatch.ts treats null content exactly that way; the original source message, one seq earlier in - * the chain, was itself an already-forwarded flat `{text:"11"}` mobile message, `head:{"x-im-type": - * "text",...}` -- confirmed via `DESCRIBE`+`SELECT` against the real `im_003_group_messages_202609`/ - * `im_003_messages` tables on the k3s MySQL cluster, not inferred). - * - * Returns the right `{content, headOverrides}` to forward with, or `null` for an ordinary - * string/real-Drafty message (the pre-existing `Drafty.forwardedContent(toDraftyContent(...))` path - * still handles that case, unchanged). Duplicates dispatch.ts's own tiny shape checks inline rather - * than importing them: dispatch.ts imports from lib/tinode/calls.ts, which itself imports this file's - * own newClientMid/publishWithReattach, so importing dispatch.ts here would create a 3-way cycle - * (send.ts -> dispatch.ts -> calls.ts -> send.ts) for what's otherwise a handful of stable, - * unlikely-to-drift structural checks. - */ -function flatForwardPayload(msg: StoredMessage): { content: DraftyContent | Record; headOverrides: Record } | null { - const c = msg.content as unknown; - if (!c || typeof c !== 'object' || Array.isArray(c)) return null; - const headType = msg.head?.['x-im-type']; - // A real Drafty document (this app's own composer never produces any of the flat shapes below) - // always has `ent`/`fmt` -- bail out to the normal Drafty path rather than misclassifying it. - if ('ent' in c || 'fmt' in c) return null; - // Head-gated shapes (attachment/card/call) are checked BEFORE the ungated flat-text shape below, - // matching the check order every other consumer of these shapes uses (preview.ts, MessageBubble.tsx) - // -- no current real shape has both a `.text` and a `file_id`/`user_id`/`callId`, but keeping this - // file's own order the same as theirs means a future shape that combines both (an image with a - // caption, say) forwards as the richer shape instead of silently degrading to caption-only text. - if (typeof headType === 'string' && (headType === 'image' || headType === 'file' || headType === 'voice' || headType === 'video')) { - // Mirrors mobileAttachmentContentOf's own validity check (dispatch.ts) -- an attachment payload - // that's missing both isn't safely forwardable either (nothing on the far end could resolve it), - // so fall through to the ordinary Drafty path rather than preserving known-broken content verbatim. - const attachment = c as { file_id?: unknown; ref?: unknown }; - if (typeof attachment.file_id === 'string' || typeof attachment.ref === 'string') { - // Flat attachment content -- Drafty has no equivalent entity to rebuild (this was never a real - // Drafty entity to begin with), so preserve the original content AND its `x-im-type` tag - // verbatim, but as a fresh shallow copy: the SDK's own outbound `simplify()` (see this file's - // other comments on it) mutates a `{pub}` packet's content IN PLACE, deleting falsy fields -- - // returning `c` itself would silently strip fields off the SOURCE message's own still-cached - // content too, a real object-ownership bug confirmed by reading `simplify`'s actual - // implementation, not hypothetical. - const headOverrides: Record = { 'x-im-type': headType }; - if (typeof msg.head?.mime === 'string') headOverrides.mime = msg.head.mime; - return { content: { ...(c as Record) }, headOverrides }; - } - } - if (headType === 'contact_card' && typeof (c as { user_id?: unknown }).user_id === 'string') { - // Mirrors contactCardContentOf's own validity check (dispatch.ts). Cloned for the same - // in-place-mutation reason as the attachment branch above. - return { content: { ...(c as Record) }, headOverrides: { 'x-im-type': 'contact_card' } }; - } - if (typeof msg.head?.webrtc === 'string' && typeof (c as { callId?: unknown }).callId === 'string') { - // A call-result message -- forwarding one is a low-value edge case (nobody has asked for it), - // but preserving it verbatim is still strictly better than silently losing it the way every - // other flat shape here used to. Cloned for the same in-place-mutation reason as above. - return { content: { ...(c as Record) }, headOverrides: { webrtc: msg.head.webrtc } }; - } - if (typeof (c as { text?: unknown }).text === 'string') { - // Flat plain text (mobile-sent, or a group-announcement auto-post) -- rebuild real Drafty from - // the extracted string instead of preserving the flat object verbatim, so it reads and searches - // like any other text message on this shape's next hop. Builds a fresh object (Drafty.init), so - // no cloning concern here. - return { content: Drafty.forwardedContent(Drafty.init((c as { text: string }).text)), headOverrides: {} }; - } - return null; -} - /** * docs/IM-前后端对接文档.md:486/952/1128 -- every `{pub}` must carry a client-generated * `x-im-client-mid` (scoped to "tenant + sender"), and a disconnect/failure retry MUST resend the @@ -201,36 +118,3 @@ export function sendReply(topic: Topic, text: string, quoted: StoredMessage): Pr msg.head = Object.assign(msg.head || {}, { 'x-im-reply': reply, 'x-im-client-mid': newClientMid() }); return publishWithReattach(topic, msg); } - -/** - * Single-message forward, sent directly to one target topic. Simplified from the old app's own - * flow (which stages the forwarded content and lets the user add a comment via the normal composer - * before sending) -- this sends immediately on picking a target, trading that one nicety for a - * much smaller "pick a contact" dialog. - * - * `head['x-im-forwarded']`/`head['x-im-forward-source']` (not the bare `forwarded: "topic:seq"` - * string this used to write): confirmed via direct DB inspection (the same investigation that found - * `flatForwardPayload`'s root cause) that mobile's OWN forwards use exactly this two-field shape -- - * `{"x-im-forwarded":true,"x-im-forward-source":{"seq":N,"from":"usr...","topic":"..."}}` -- a real, - * live example from a genuine mobile-to-mobile forward chain, not a guess. The bare `forwarded` - * string this web app used to write instead had zero readers anywhere in this codebase (confirmed: - * grepped for it), so nothing here regresses by switching -- but writing it meant every message this - * app forwards was tagged in a shape mobile's own client has no reason to recognize, which would - * silently fail to show a "转发自 XXX" indicator (or whatever mobile's UI does with this) for any - * message forwarded from web, even though the message CONTENT itself displays fine either way - * (content and forward-provenance are two independent things carried in the same packet). - */ -export function forwardMessage(sourceTopic: string, msg: StoredMessage, target: Topic): Promise { - const flat = flatForwardPayload(msg); - const content = flat ? flat.content : Drafty.forwardedContent(toDraftyContent(msg.content)); - // `createMessage`'s own type only admits Drafty content, but its real implementation (see - // lib/tinode/calls.ts's own identical cast) passes a plain object straight through as literal - // `content` -- true for the attachment/contact-card/call-result branches of `flatForwardPayload`. - const newMsg = target.createMessage(content as unknown as DraftyContent, false); - newMsg.head = Object.assign(newMsg.head || {}, flat?.headOverrides, { - 'x-im-forwarded': true, - 'x-im-forward-source': { seq: msg.seq, from: msg.from, topic: sourceTopic }, - 'x-im-client-mid': newClientMid(), - }); - return publishWithReattach(target, newMsg); -}