From a8d350442439911abba2cabbc494a29f7ba260c1 Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 23 Sep 2026 20:40:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E8=B7=A8=E7=AB=AF?= =?UTF-8?q?=E5=BC=95=E7=94=A8=E5=9B=9E=E5=A4=8D=E5=8D=8F=E8=AE=AE=EF=BC=8C?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20QuoteBar=20=E7=BB=84=E4=BB=B6=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=B6=88=E6=81=AF=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交实现了与 iOS 对齐的跨端消息引用协议,新增 QuoteBar 组件用于渲染引用消息栏,重构发送逻辑支持文本和媒体消息的引用功能。主要改动包括: - 新增 reply.ts 处理引用数据的构建和解析 - 新增 QuoteBar 组件渲染引用消息预览 - 重构发送逻辑,将引用快照放入消息头部而非内容 - 调整代理配置指向本地开发环境 - 优化消息操作权限,支持名片和媒体消息引用 --- .../features/messages/ConversationView.tsx | 30 ++- web/src/features/messages/MessageBubble.tsx | 13 ++ web/src/features/messages/QuoteBar.tsx | 138 +++++++++++ web/src/lib/messages/actions.ts | 28 ++- web/src/lib/messages/reply.ts | 220 ++++++++++++++++++ web/src/lib/tinode/attachments.ts | 21 +- web/src/lib/tinode/send.ts | 36 +-- web/vite.config.ts | 4 +- 8 files changed, 452 insertions(+), 38 deletions(-) create mode 100644 web/src/features/messages/QuoteBar.tsx create mode 100644 web/src/lib/messages/reply.ts diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx index 17babf83..9fdc8c1a 100644 --- a/web/src/features/messages/ConversationView.tsx +++ b/web/src/features/messages/ConversationView.tsx @@ -25,8 +25,8 @@ 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 { renderDrafty } from '@/lib/messages/drafty'; import { buildMentionedContent, detectMentionTrigger, type PendingMention } from '@/lib/messages/mentions'; +import { buildReplyInfo, replySnapshotText } from '@/lib/messages/reply'; import { groupSenderLabel } from '@/lib/messages/sender-name'; import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments'; import { isTopicGone } from '@/lib/tinode/errors'; @@ -607,6 +607,16 @@ export function ConversationView() { return peerName; }; + /** Arms a media send with the pending quote and clears the composer's reply state in one step: + * the quote applies to the NEXT outgoing message only (cross-client rule), and a picked image + * or recorded voice is that message. Returns null when nothing is armed or the quoted message + * has become unquotable in the meantime (send proceeds as a plain media message). */ + const consumePendingReply = () => { + const pending = replyTo ? buildReplyInfo(replyTo) : null; + setReplyTo(null); + return pending; + }; + /** * "加载更多" (README §3.2/old app's messages-view.jsx): not simple since/before paging -- computes * which seq ranges are actually missing from the local cache within the currently-visible window @@ -670,7 +680,7 @@ export function ConversationView() { // Mentions inside a reply don't get spliced into real MN entities -- a quoted reply that // also pings someone is a rare enough compound case that it isn't worth a second // stitching path; the "@name" text still sends fine, just without the entity/highlight. - return sendReply(target, text, pendingReplyTo, quotedSenderLabel(pendingReplyTo.from)); + return sendReply(target, text, pendingReplyTo); } if (pendingMentions.length > 0) { return sendPrebuiltContent(target, buildMentionedContent(text, pendingMentions)); @@ -868,13 +878,19 @@ export function ConversationView() { const handlePickImage = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; e.target.value = ''; - if (file) sendImage(topic, file).catch((err) => showError(err, '图片发送失败')); + if (file) { + const reply = consumePendingReply(); + sendImage(topic, file, reply).catch((err) => showError(err, '图片发送失败')); + } }; const handlePickFile = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; e.target.value = ''; - if (file) sendFile(topic, file).catch((err) => showError(err, '文件发送失败')); + if (file) { + const reply = consumePendingReply(); + sendFile(topic, file, reply).catch((err) => showError(err, '文件发送失败')); + } }; return ( @@ -1002,6 +1018,7 @@ export function ConversationView() { highlighted={typeof msg.seq === 'number' && highlightedSeq === msg.seq} onToggleSelect={toggleSelect} onContextMenu={(e, m) => setMenu({ x: e.clientX, y: e.clientY, msg: m })} + resolveQuotedName={quotedSenderLabel} onError={setErrorToast} /> ); @@ -1042,7 +1059,7 @@ export function ConversationView() { {replyTo ? (
- 回复 {quotedSenderLabel(replyTo.from)}:{renderDrafty(replyTo.content)} + 回复 {quotedSenderLabel(replyTo.from)}:{replySnapshotText(replyTo)}