Migrate message forwarding to backend REST endpoint
Both real mobile clients (iOS, Android) forward messages via POST
/v1/im/messages/forward, never a client-side {pub} packet -- confirmed
by reading their source directly. Switches web's single-message and
bulk forward paths to the same endpoint, which copies message content
verbatim regardless of shape, eliminating the client-side flat-content
detection this used to require. Comment-sending stays on the existing
sendText() path, unchanged, to preserve current ordering.
Removes send.ts's now-unused forwardMessage/flatForwardPayload.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e7cb4edf3a
commit
4cd2b33c53
@@ -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() {
|
||||
<ForwardPickerModal
|
||||
onClose={() => 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);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 <span className="text-text-tertiary-4">(内容不可用)</span>;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, unknown>; headOverrides: Record<string, unknown> } | 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<string, unknown> = { 'x-im-type': headType };
|
||||
if (typeof msg.head?.mime === 'string') headOverrides.mime = msg.head.mime;
|
||||
return { content: { ...(c as Record<string, unknown>) }, 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<string, unknown>) }, 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<string, unknown>) }, 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<unknown> {
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user