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 { GroupAnnouncementModal } from '@/features/groups/GroupAnnouncementModal';
|
||||||
import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal';
|
import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal';
|
||||||
import { useBootstrap } from '@/lib/api/hooks';
|
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 { getAllGroupMembers, getAllGroupMutes, getGroup, getGroupGovernance, getGroupSummary } from '@/lib/api/groups';
|
||||||
import { getConversationContext, getUserProfile } from '@/lib/api/friends';
|
import { getConversationContext, getUserProfile } from '@/lib/api/friends';
|
||||||
import { getMe } from '@/lib/api/profile';
|
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 { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments';
|
||||||
import { isTopicGone } from '@/lib/tinode/errors';
|
import { isTopicGone } from '@/lib/tinode/errors';
|
||||||
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
|
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 {
|
import {
|
||||||
closeConversation,
|
closeConversation,
|
||||||
ensureTopicAttached,
|
ensureTopicAttached,
|
||||||
@@ -818,25 +818,36 @@ export function ConversationView() {
|
|||||||
// order, landing #10 ahead of #3 in the target conversation and misrepresenting which one was
|
// order, landing #10 ahead of #3 in the target conversation and misrepresenting which one was
|
||||||
// actually said first.
|
// actually said first.
|
||||||
const orderedSeqs = [...selectedSeqs].sort((a, b) => a - b);
|
const orderedSeqs = [...selectedSeqs].sort((a, b) => a - b);
|
||||||
for (const targetTopic of targetTopics) {
|
// A message can be recalled by its sender AFTER it was already selected (the checkbox itself
|
||||||
// A target that isn't the conversation currently open (i.e. almost any real forward target)
|
// refuses a NEW selection of a recalled message, but can't retroactively un-select one recalled
|
||||||
// is not actually subscribed, and publishing on an unsubscribed topic is rejected outright by
|
// mid-session) -- forwarding null content would silently send an empty bubble with a misleading
|
||||||
// the SDK -- see ensureTopicAttached's own comment for why this is the common case, not an
|
// "forwarded from" tag rather than the message the user actually picked.
|
||||||
// edge case.
|
const sources = orderedSeqs
|
||||||
ensureTopicAttached(targetTopic)
|
.map((seq) => topic.findMessage(seq))
|
||||||
.then((target) => {
|
.filter((msg): msg is StoredMessage => !!msg && !isRecalled(msg))
|
||||||
for (const seq of orderedSeqs) {
|
.map((msg) => ({ topic: topicId, seq: msg.seq as number }));
|
||||||
const msg = topic.findMessage(seq);
|
// One REST call now covers every (source, target) pair -- forwardMessages()'s own comment
|
||||||
// A message can be recalled by its sender AFTER it was already selected (the checkbox
|
// explains why this replaced per-target `ensureTopicAttached()` + per-message client-side
|
||||||
// itself refuses a NEW selection of a recalled message, but can't retroactively un-select
|
// `{pub}` publishes (was N sources x M targets separate WS sends, each independently racing
|
||||||
// one recalled mid-session) -- forwarding null content would silently send an empty bubble
|
// subscription/attachment; the server handles every pair in one request with real per-pair
|
||||||
// with a misleading "forwarded from" tag rather than the message the user actually picked.
|
// success/failure reporting instead of each WS publish failing silently on its own).
|
||||||
if (msg && !isRecalled(msg)) forwardMessage(topicId, msg, target).catch((err) => showError(err, '转发失败'));
|
if (sources.length > 0) {
|
||||||
}
|
forwardMessages(sources, targetTopics)
|
||||||
if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败'));
|
.then((res) => {
|
||||||
|
if (res.failed.length > 0) showError(undefined, `${res.failed.length} 条消息转发失败`);
|
||||||
})
|
})
|
||||||
.catch((err) => showError(err, '转发失败'));
|
.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);
|
setBulkForwardOpen(false);
|
||||||
exitSelectMode();
|
exitSelectMode();
|
||||||
};
|
};
|
||||||
@@ -1218,14 +1229,23 @@ export function ConversationView() {
|
|||||||
<ForwardPickerModal
|
<ForwardPickerModal
|
||||||
onClose={() => setForwardMsg(null)}
|
onClose={() => setForwardMsg(null)}
|
||||||
onPick={(targetTopics, comment) => {
|
onPick={(targetTopics, comment) => {
|
||||||
for (const targetTopic of targetTopics) {
|
if (typeof forwardMsg.seq === 'number') {
|
||||||
ensureTopicAttached(targetTopic)
|
forwardMessages([{ topic: topicId, seq: forwardMsg.seq }], targetTopics)
|
||||||
.then((target) => {
|
.then((res) => {
|
||||||
forwardMessage(topicId, forwardMsg, target).catch((err) => showError(err, '转发失败'));
|
if (res.failed.length > 0) showError(undefined, `${res.failed.length} 个目标转发失败`);
|
||||||
if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败'));
|
|
||||||
})
|
})
|
||||||
.catch((err) => showError(err, '转发失败'));
|
.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);
|
setForwardMsg(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -20,10 +20,9 @@ interface ForwardPickerModalProps {
|
|||||||
* context menu's single-message "转发", the multi-select bulk bar's "转发", and favorites' bulk
|
* 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
|
* 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
|
* 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
|
* the whole picker flow once per recipient. Both `forwardFavorites` (favorites.ts) and chat-message
|
||||||
* `target_topics: string[]`/`comment` server-side; chat-message forwarding has no such batch REST
|
* forwarding (`forwardMessages`, lib/api/messages.ts) accept `target_topics: string[]` server-side
|
||||||
* endpoint (`forwardMessage` in lib/tinode/send.ts publishes one native {pub} per target instead),
|
* in one batched call -- this component only collects the selection and comment; callers send.
|
||||||
* so callers loop `targetTopics` themselves -- this component only collects the selection.
|
|
||||||
*/
|
*/
|
||||||
export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps) {
|
export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps) {
|
||||||
const contacts = useChatList();
|
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
|
* 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`
|
* `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()`
|
* 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
|
* on it -- this app has no error boundary anywhere to contain that). Same flat-vs-Drafty shape
|
||||||
* approach, as `forwardMessage()`'s own `flatForwardPayload` (lib/tinode/send.ts). */
|
* distinction `dispatch.ts`'s own `mobileAttachmentContentOf`/`contactCardContentOf` already
|
||||||
|
* document for message bubbles. */
|
||||||
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>;
|
||||||
|
|||||||
@@ -153,3 +153,41 @@ export function favoriteMessages(items: Array<{ topic: string; seq: number }>) {
|
|||||||
messages: items.map((i) => ({ topic: i.topic, seq: String(i.seq) })),
|
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
|
// 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
|
// 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/
|
// 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 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
|
// uses its own snapshot (favorites.ts), and forward now goes through the backend's REST forward
|
||||||
// `flatForwardPayload`).
|
// endpoint (lib/api/messages.ts's `forwardMessages`), which copies content verbatim regardless of
|
||||||
|
// shape.
|
||||||
const isMobileAttachment = mobileAttachmentContentOf(msg) !== null;
|
const isMobileAttachment = mobileAttachmentContentOf(msg) !== null;
|
||||||
let actions: MessageAction[];
|
let actions: MessageAction[];
|
||||||
if (isCall) {
|
if (isCall) {
|
||||||
|
|||||||
@@ -2,89 +2,6 @@ import { Drafty, type DraftyContent, type StoredMessage, type Topic } from 'tino
|
|||||||
|
|
||||||
import { buildReplyInfo } from '@/lib/messages/reply';
|
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
|
* 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
|
* `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() });
|
msg.head = Object.assign(msg.head || {}, { 'x-im-reply': reply, 'x-im-client-mid': newClientMid() });
|
||||||
return publishWithReattach(topic, msg);
|
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