feat: 实现跨端引用回复协议,新增 QuoteBar 组件支持消息引用

本次提交实现了与 iOS 对齐的跨端消息引用协议,新增 QuoteBar 组件用于渲染引用消息栏,重构发送逻辑支持文本和媒体消息的引用功能。主要改动包括:
- 新增 reply.ts 处理引用数据的构建和解析
- 新增 QuoteBar 组件渲染引用消息预览
- 重构发送逻辑,将引用快照放入消息头部而非内容
- 调整代理配置指向本地开发环境
- 优化消息操作权限,支持名片和媒体消息引用
This commit is contained in:
Austin
2026-09-23 20:40:47 +08:00
parent fb92f4f703
commit a8d3504424
8 changed files with 452 additions and 38 deletions
+24 -6
View File
@@ -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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 ? (
<div className="mb-2 flex items-center justify-between rounded-lg bg-surface-input px-3 py-1.5 text-xs text-text-tertiary-1">
<span className="truncate">
回复 {quotedSenderLabel(replyTo.from)}:{renderDrafty(replyTo.content)}
回复 {quotedSenderLabel(replyTo.from)}:{replySnapshotText(replyTo)}
</span>
<button type="button" onClick={() => setReplyTo(null)} className="flex-none text-text-tertiary-3">
<Icon name="close" className="text-[16px]" />
@@ -1137,7 +1154,8 @@ export function ConversationView() {
onCancel={() => setRecording(false)}
onSend={(blob, durationMs) => {
setRecording(false);
sendVoice(topic, blob, durationMs).catch((err) => showError(err, '语音发送失败'));
const reply = consumePendingReply();
sendVoice(topic, blob, durationMs, reply).catch((err) => showError(err, '语音发送失败'));
}}
/>
) : (