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, '语音发送失败'));
}}
/>
) : (
@@ -16,6 +16,8 @@ import {
} from '@/lib/messages/dispatch';
import { renderDrafty } from '@/lib/messages/drafty';
import { AudioAttachment, FileAttachment, ImageAttachment, VideoAttachment } from '@/features/messages/AttachmentPreview';
import { QuoteBar } from '@/features/messages/QuoteBar';
import { parseReplyHead } from '@/lib/messages/reply';
import { formatBubbleTime, formatEventTimestamp } from '@/lib/messages/time';
import { isFailedStatus, isSendingStatus, outgoingStatusLabel } from '@/lib/messages/status';
import { groupSenderLabel } from '@/lib/messages/sender-name';
@@ -74,6 +76,10 @@ interface MessageRowProps {
highlighted?: boolean;
onToggleSelect?: (seq: number) => void;
onContextMenu?: (e: React.MouseEvent, msg: StoredMessage) => void;
/** Resolves a QUOTED message's sender uid (from head["x-im-reply"].from) to a display label.
* Owned by ConversationView so group remark/nickname resolution stays in one place; QuoteBar
* must never print a raw uid. */
resolveQuotedName?: (uid: string | undefined) => string;
/** Fired when the inline "重试" retry (below) itself fails -- e.g. still offline. Without this the
* failed-send icon just silently stays put with no feedback at all that the retry attempt was
* even made. */
@@ -95,6 +101,7 @@ export function MessageRow({
highlighted,
onToggleSelect,
onContextMenu,
resolveQuotedName,
onError,
}: MessageRowProps) {
if (isServerDeleted(msg)) {
@@ -121,6 +128,9 @@ export function MessageRow({
const callResult = callResultDisplay(msg, mine);
const contactCard = contactCardContentOf(msg);
const mobileAttachment = mobileAttachmentContentOf(msg);
// Quoted-reply snapshot (x-im-reply). Not rendered for a recalled message -- its whole bubble
// collapses to the "消息已删除" placeholder, quote bar included.
const replyInfo = isRecalled(msg) ? null : parseReplyHead(msg.head);
const retry = () => {
if (typeof msg.seq !== 'number') return;
@@ -201,6 +211,9 @@ export function MessageRow({
mine ? 'rounded-[12px_4px_12px_12px] bg-bubble-mine' : 'rounded-[4px_12px_12px_12px] bg-white shadow-bubble'
} ${isRecalled(msg) ? 'text-text-tertiary-4 italic' : ''} ${menuActive ? 'outline outline-2 outline-offset-2 outline-accent' : ''}`}
>
{replyInfo ? (
<QuoteBar topicName={topic.name} reply={replyInfo} resolveName={resolveQuotedName ?? (() => '消息')} />
) : null}
{isRecalled(msg) ? (
'⚠ 消息已删除'
) : callResult ? (
+138
View File
@@ -0,0 +1,138 @@
import { useMemo } from 'react';
import { Icon } from '@/app/Icon';
import { useAuthedBlobUrl } from '@/lib/hooks';
import { isRecalled, isServerDeleted } from '@/lib/messages/dispatch';
import {
placeholderForType,
quotedMediaOf,
type ReplyInfo,
type QuoteMedia,
} from '@/lib/messages/reply';
import { useTopicMessages } from '@/lib/tinode/hooks';
import { useMessageJumpStore } from '@/stores/messageJump';
import type { StoredMessage } from 'tinode-sdk';
/**
* Quote bar rendered at the top of a bubble, driven exclusively by `head["x-im-reply"]`
* (cross-client contract aligned with iOS, 2026-09-23):
*
* - the head only carries a lightweight snapshot {seq,type,text,from}; the quoted message itself
* is re-resolved from the LOCAL message cache by seq, which unlocks real thumbnails / voice
* durations and the "该消息已删除" state that no snapshot can express;
* - when the quoted message isn't local (fresh device, cleared history, unloaded range), the
* snapshot carries rendering alone -- localized placeholder per type, never blank;
* - clicking requests a scroll-locate + highlight via the same messageJump store the history
* search uses; it transparently fetches the surrounding range when the target isn't loaded.
*
* Deliberately a plain <button>-free clickable div: it must not submit anything, but must allow
* nested long-press/selection behavior of the bubble itself.
*/
function formatVoiceDuration(ms: number | undefined): string {
if (typeof ms !== 'number' || ms <= 0) return '';
return `${Math.max(1, Math.round(ms / 1000))}″`;
}
/** Small authenticated thumbnail (28x28 box per iOS spec) for image/video quotes. */
function QuoteThumb({ media }: { media: { val?: string; ref?: string; mime?: string } }) {
const inline = typeof media.val === 'string' ? `data:${media.mime || 'image/jpeg'};base64,${media.val}` : null;
const remote = useAuthedBlobUrl(inline ? undefined : media.ref);
const src = inline || remote.url;
if (!src) {
return (
<span className="grid h-7 w-7 flex-none place-items-center rounded bg-black/[0.06]">
<Icon name="image" className="text-[15px] text-text-tertiary-3" />
</span>
);
}
return <img src={src} alt="" className="h-7 w-7 flex-none rounded object-cover" />;
}
function MediaSummary({ media }: { media: QuoteMedia }) {
if ((media.kind === 'image' || media.kind === 'video') && media.image) {
return (
<span className="flex min-w-0 flex-none items-center gap-1">
{media.kind === 'video' ? <Icon name="play_circle" className="flex-none text-[14px]" /> : null}
<QuoteThumb media={media.image} />
</span>
);
}
if (media.kind === 'voice') {
const seconds = formatVoiceDuration(media.durationMs);
return (
<span className="flex flex-none items-center gap-1">
<Icon name="mic_none" className="text-[14px]" />
{seconds ? <span>{seconds}</span> : null}
</span>
);
}
return <Icon name="insert_drive_file" className="flex-none text-[14px]" />;
}
interface QuoteBarProps {
topicName: string;
reply: ReplyInfo;
/** Resolves a quoted sender uid to a display label (group remark/nick -> peer name); the parent
* already owns this lookup, and must never surface a raw uid to the user. */
resolveName: (uid: string | undefined) => string;
}
export function QuoteBar({ topicName, reply, resolveName }: QuoteBarProps) {
const messages = useTopicMessages(topicName);
const quoted: StoredMessage | undefined = useMemo(
() => messages.find((m) => typeof m.seq === 'number' && m.seq === reply.seq),
[messages, reply.seq],
);
const deleted = !!quoted && (isRecalled(quoted) || isServerDeleted(quoted));
// Local media is only usable when the quoted message is present AND alive -- a recalled message's
// thumbnail must not leak back through the quote bar (the snapshot is the only legal residue).
const localMedia = quoted && !deleted ? quotedMediaOf(quoted) : null;
const name = resolveName(quoted?.from || reply.from);
// Per the cross-client spec, only the media/card-like types render as placeholders; any unknown
// type is rendered as text using the snapshot body.
const MEDIA_LIKE_TYPES = new Set(['image', 'video', 'voice', 'file', 'contact_card', 'forward_bundle', 'sticker']);
const isTextLike = !MEDIA_LIKE_TYPES.has(reply.type);
// Real media affordances only when the local copy actually carries what they need: a usable image
// ref for image/video, a duration for voice. Otherwise fall back to the snapshot placeholder
// (exactly the iOS "取不到缩略图 → 名字:[图片]" / "取不到 → 名字:[语音]" behavior).
const hasThumb = !!localMedia && (localMedia.kind === 'image' || localMedia.kind === 'video') && !!localMedia.image;
const hasVoiceDuration = localMedia?.kind === 'voice' && typeof localMedia.durationMs === 'number' && localMedia.durationMs > 0;
const locate = () => {
useMessageJumpStore.getState().requestJump(topicName, reply.seq);
};
if (deleted) {
return (
<div
role="button"
tabIndex={0}
onClick={locate}
className="mb-1.5 truncate rounded border-l-2 border-text-tertiary-3 bg-black/[0.04] px-2 py-1 text-xs italic text-text-tertiary-3"
>
该消息已删除
</div>
);
}
return (
<div
role="button"
tabIndex={0}
onClick={locate}
title="查看原消息"
className="mb-1.5 flex max-w-full items-center gap-1.5 rounded border-l-2 border-accent bg-black/[0.04] px-2 py-1 text-xs text-[#4a4f57]"
>
<span className="max-w-[40%] flex-none truncate text-accent">{name}</span>
{hasThumb || hasVoiceDuration ? <MediaSummary media={localMedia as QuoteMedia} /> : null}
{isTextLike ? (
<span className="line-clamp-2 min-w-0 break-words">{reply.text}</span>
) : (
<span className="min-w-0 truncate">{reply.text || placeholderForType(reply.type)}</span>
)}
</div>
);
}