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, '语音发送失败'));
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user