feat: 实现跨端引用回复协议,新增 QuoteBar 组件支持消息引用
本次提交实现了与 iOS 对齐的跨端消息引用协议,新增 QuoteBar 组件用于渲染引用消息栏,重构发送逻辑支持文本和媒体消息的引用功能。主要改动包括: - 新增 reply.ts 处理引用数据的构建和解析 - 新增 QuoteBar 组件渲染引用消息预览 - 重构发送逻辑,将引用快照放入消息头部而非内容 - 调整代理配置指向本地开发环境 - 优化消息操作权限,支持名片和媒体消息引用
This commit is contained in:
@@ -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, '语音发送失败'));
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user