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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { StoredMessage } from 'tinode-sdk';
|
||||
|
||||
import type { MessagePolicy } from '@/lib/api/bootstrap';
|
||||
import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted } from '@/lib/messages/dispatch';
|
||||
import { canQuote } from '@/lib/messages/reply';
|
||||
|
||||
/** SDK-internal placeholder seq threshold for a not-yet-server-confirmed message (tinode.dev.js's
|
||||
* own `LOCAL_SEQID`, not exported as a Tinode static -- verified against source, not guessed). */
|
||||
@@ -33,13 +34,26 @@ export function recallRemainingSeconds(msg: StoredMessage, myUserId: string | nu
|
||||
*/
|
||||
export function availableActions(msg: StoredMessage, myUserId: string | null, messagePolicy: MessagePolicy | undefined): MessageAction[] {
|
||||
if (isServerDeleted(msg) || isRecalled(msg)) return [];
|
||||
// 名片消息和通话结果消息are both flat, non-Drafty custom objects (dispatch.ts's own comments on
|
||||
// contactCardContentOf/callResultDisplay), not something Drafty.toPlainText/replyContent/
|
||||
// forwardedContent know how to handle -- copy/quote/forward would all feed a Drafty function an
|
||||
// object it doesn't recognize, producing empty/malformed output rather than an error a user would
|
||||
// notice. Favorite/delete/select don't re-parse content, so they stay safe for these two types.
|
||||
const isNonDrafty = contactCardContentOf(msg) !== null || callResultDisplay(msg, !!myUserId && msg.from === myUserId) !== null;
|
||||
const actions: MessageAction[] = isNonDrafty ? ['favorite'] : ['quote', 'copy', 'favorite', 'forward'];
|
||||
// Contact-card and call-result messages are flat, non-Drafty custom objects dispatched by
|
||||
// head['x-im-type'] (dispatch.ts), not bodies Drafty.toPlainText can handle -- "copy" stays off
|
||||
// both. Quote/forward differ since the 2026-09-23 x-im-reply snapshot protocol represents the
|
||||
// quoted message by {seq,type,text,from} rather than parsing its content: a contact card now
|
||||
// quotes fine ([名片] snapshot). Call bubbles remain favorite-only -- a transient call result
|
||||
// has no meaningful "forward/quote" semantics anywhere in the cross-client spec.
|
||||
const isCard = contactCardContentOf(msg) !== null;
|
||||
const isCall = callResultDisplay(msg, !!myUserId && msg.from === myUserId) !== null;
|
||||
let actions: MessageAction[];
|
||||
if (isCall) {
|
||||
actions = ['favorite'];
|
||||
} else if (isCard) {
|
||||
actions = ['quote', 'favorite', 'forward'];
|
||||
} else {
|
||||
actions = ['quote', 'copy', 'favorite', 'forward'];
|
||||
}
|
||||
// Quote is additionally gated on the x-im-reply contract: no failed/pending messages, no
|
||||
// system notices, positive confirmed seq (buildReplyInfo returns null otherwise, and the server
|
||||
// would reject a malformed x-im-reply head outright).
|
||||
if (!canQuote(msg)) actions = actions.filter((action) => action !== 'quote');
|
||||
// Defer to the server's own supported_actions list when it's present, so a server-side policy
|
||||
// change (recall disabled tenant-wide, say) doesn't leave the client offering an action that
|
||||
// will just 400 when tapped -- the time-window check below still applies on top of this.
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { Drafty, type DraftyContent, type StoredMessage } from 'tinode-sdk';
|
||||
|
||||
import { flatTextContentOf, isRecalled, isServerDeleted, mobileAttachmentContentOf, systemNoticeText } from '@/lib/messages/dispatch';
|
||||
|
||||
/**
|
||||
* Quoted-reply wire contract (`head["x-im-reply"]`), aligned with the iOS cross-client spec
|
||||
* 《消息引用数据格式-跨端对接说明》 (2026-09-23) and the im_server contract landed in commit
|
||||
* 3e92021 ("feat: support quoted message headers"):
|
||||
*
|
||||
* { "seq": 128, // NUMBER (positive int); a string seq makes iOS drop the quote bar
|
||||
* "type": "text", // quoted message's x-im-type; unknown values render as text
|
||||
* "text": "摘要快照", // full body for text (display side truncates); media = placeholder
|
||||
* "from": "usr..." } // optional quoted sender uid; OMIT the key entirely when empty
|
||||
*
|
||||
* The snapshot lives in `head`, NEVER in `content`. The server validates the shape
|
||||
* (non-empty type/text, positive integer seq, non-empty from when present) and rejects the whole
|
||||
* message with ErrMalformed on violation, so builders here must never emit an empty/placeholder
|
||||
* `from` or an invalid seq.
|
||||
*/
|
||||
export interface ReplyInfo {
|
||||
seq: number;
|
||||
type: string;
|
||||
text: string;
|
||||
from?: string;
|
||||
}
|
||||
|
||||
/** SDK-internal placeholder seq threshold for a not-yet-server-confirmed message (mirrors
|
||||
* actions.ts's own LOCAL_SEQID; tinode.dev.js LOCAL_SEQID, not exported). */
|
||||
const LOCAL_SEQID = 0xfffffff;
|
||||
|
||||
/** Media types whose snapshot text is a localized placeholder rather than a real body. */
|
||||
const MEDIA_TYPES = new Set(['image', 'video', 'voice', 'file']);
|
||||
|
||||
const PLACEHOLDER_BY_TYPE: Record<string, string> = {
|
||||
image: '[图片]',
|
||||
video: '[视频]',
|
||||
voice: '[语音]',
|
||||
file: '[文件]',
|
||||
contact_card: '[名片]',
|
||||
forward_bundle: '[聊天记录]',
|
||||
link: '[链接]',
|
||||
};
|
||||
|
||||
const PLACEHOLDER_FALLBACK = '[消息]';
|
||||
|
||||
/** Localized placeholder per quoted type (§7 of the cross-client spec). Unknown -> `[消息]`. */
|
||||
export function placeholderForType(type: string): string {
|
||||
return PLACEHOLDER_BY_TYPE[type] ?? PLACEHOLDER_FALLBACK;
|
||||
}
|
||||
|
||||
/** Legacy head `type` values seen in stored messages, mapped onto the product x-im-type enum. */
|
||||
const LEGACY_TYPE_MAP: Record<string, string> = {
|
||||
audio: 'voice',
|
||||
};
|
||||
|
||||
/** The effective product type of a message, read from head (`x-im-type` preferred over legacy
|
||||
* `type`). Falls back to "text" -- a message without an explicit type tag is a plain text body. */
|
||||
export function messageReplyType(msg: StoredMessage): string {
|
||||
const head = msg.head;
|
||||
const product = head?.['x-im-type'];
|
||||
if (typeof product === 'string' && product.trim()) return product.trim();
|
||||
const legacy = head?.['type'];
|
||||
if (typeof legacy === 'string' && legacy.trim()) return LEGACY_TYPE_MAP[legacy.trim()] ?? legacy.trim();
|
||||
return 'text';
|
||||
}
|
||||
|
||||
/** Whether a message may be quoted at all: server-confirmed positive seq, not failed/pending,
|
||||
* not deleted/recalled, and not a centered system notice or a transient call-result bubble. */
|
||||
export function canQuote(msg: StoredMessage): boolean {
|
||||
if (typeof msg.seq !== 'number' || !Number.isInteger(msg.seq) || msg.seq <= 0 || msg.seq >= LOCAL_SEQID) return false;
|
||||
if (isServerDeleted(msg) || isRecalled(msg) || msg.content == null) return false;
|
||||
const type = messageReplyType(msg);
|
||||
if (type === 'system_notice' || type === 'system_action' || type === 'system_event' || type === 'call') return false;
|
||||
// Belt and suspenders: systemNoticeText recognizes centered notices by head tag even if the type
|
||||
// string ever drifts.
|
||||
if (systemNoticeText(msg)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function draftyPlainText(content: StoredMessage['content']): string {
|
||||
if (content == null) return '';
|
||||
if (typeof content === 'string') return content;
|
||||
return Drafty.toPlainText(content as DraftyContent).trim();
|
||||
}
|
||||
|
||||
/** Snapshot text written into `x-im-reply.text` at send time: full text body for text-like
|
||||
* messages (no truncation -- the display side truncates), localized placeholder for media/card. */
|
||||
export function replySnapshotText(msg: StoredMessage, type: string = messageReplyType(msg)): string {
|
||||
if (MEDIA_TYPES.has(type) || type === 'contact_card' || type === 'forward_bundle' || type === 'sticker') {
|
||||
return placeholderForType(type);
|
||||
}
|
||||
const flat = flatTextContentOf(msg.content);
|
||||
if (flat !== null) return flat.trim();
|
||||
const plain = draftyPlainText(msg.content);
|
||||
return plain || PLACEHOLDER_FALLBACK;
|
||||
}
|
||||
|
||||
/** Builds the `x-im-reply` head value for an outgoing message, or null when the quoted message
|
||||
* is not quotable (caller must then refuse to arm the reply state). */
|
||||
export function buildReplyInfo(msg: StoredMessage): ReplyInfo | null {
|
||||
if (!canQuote(msg)) return null;
|
||||
const type = messageReplyType(msg);
|
||||
const info: ReplyInfo = {
|
||||
seq: msg.seq,
|
||||
type,
|
||||
text: replySnapshotText(msg, type),
|
||||
};
|
||||
// OMIT `from` when empty -- the server rejects an empty-string from as malformed.
|
||||
if (typeof msg.from === 'string' && msg.from.trim()) info.from = msg.from.trim();
|
||||
return info;
|
||||
}
|
||||
|
||||
function asPositiveInt(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value;
|
||||
// Tolerant receive path: the spec suggests accepting numeric strings even though senders must
|
||||
// write a real JSON number (a string seq is exactly what makes iOS fail to parse, but other
|
||||
// clients' history may still carry one).
|
||||
if (typeof value === 'string' && /^\d+$/.test(value.trim())) {
|
||||
const n = Number(value.trim());
|
||||
if (Number.isSafeInteger(n) && n > 0) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseReplyValue(raw: unknown): ReplyInfo | null {
|
||||
let value: unknown = raw;
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
value = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const obj = value as Record<string, unknown>;
|
||||
const seq = asPositiveInt(obj.seq);
|
||||
if (seq == null) return null;
|
||||
if (typeof obj.type !== 'string' || !obj.type.trim()) return null;
|
||||
if (typeof obj.text !== 'string') return null;
|
||||
const reply: ReplyInfo = { seq, type: obj.type.trim(), text: obj.text };
|
||||
// Unknown types are kept as-is here; the display layer maps them onto text rendering.
|
||||
if (typeof obj.from === 'string' && obj.from.trim()) reply.from = obj.from.trim();
|
||||
return reply;
|
||||
}
|
||||
|
||||
/** Parses a received message's `head["x-im-reply"]`, tolerating both object and JSON-string
|
||||
* carriers (iOS already accepts both) and any malformed shape -> null (no quote, never crash). */
|
||||
export function parseReplyHead(head: Record<string, unknown> | undefined): ReplyInfo | null {
|
||||
if (!head) return null;
|
||||
return parseReplyValue(head['x-im-reply']);
|
||||
}
|
||||
|
||||
/** Media descriptor of a LOCALLY AVAILABLE quoted message, allowing the quote bar to render a
|
||||
* real thumbnail / voice duration instead of the text placeholder. Returns null when the quoted
|
||||
* message isn't in the local cache; the caller then renders the snapshot placeholder. */
|
||||
export interface QuoteMedia {
|
||||
kind: 'image' | 'video' | 'voice' | 'file';
|
||||
/** Image/video box: authenticated inline bits or a remote ref. */
|
||||
image?: { val?: string; ref?: string; mime?: string };
|
||||
/** Voice bubble: duration in milliseconds. */
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
interface DraftyEntityDoc {
|
||||
ent?: Array<{ tp?: string; data?: Record<string, unknown> }>;
|
||||
}
|
||||
|
||||
function draftyEntityMedia(msg: StoredMessage): QuoteMedia | null {
|
||||
const c = msg.content as DraftyContent | null;
|
||||
if (!c || typeof c !== 'object') return null;
|
||||
const doc = c as Partial<DraftyEntityDoc>;
|
||||
if (!Array.isArray(doc.ent)) return null;
|
||||
// Prefer the first matching media entity -- a quoted message carries one primary attachment.
|
||||
for (const ent of doc.ent) {
|
||||
const data = ent.data || {};
|
||||
const asImage = () =>
|
||||
(typeof data.ref === 'string' || typeof data.val === 'string')
|
||||
? { image: { ref: typeof data.ref === 'string' ? data.ref : undefined, val: typeof data.val === 'string' ? data.val : undefined, mime: typeof data.mime === 'string' ? data.mime : undefined } }
|
||||
: null;
|
||||
switch (ent.tp) {
|
||||
case 'IM': {
|
||||
const image = asImage();
|
||||
if (image) return { kind: 'image', ...image };
|
||||
break;
|
||||
}
|
||||
case 'VD': {
|
||||
const image = asImage();
|
||||
if (image) return { kind: 'video', ...image };
|
||||
break;
|
||||
}
|
||||
case 'AU': {
|
||||
const durationMs = typeof data.duration === 'number' ? data.duration : undefined;
|
||||
return { kind: 'voice', durationMs };
|
||||
}
|
||||
case 'EX':
|
||||
return { kind: 'file' };
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Resolves locally-renderable media for a quoted message (mobile flat content or web Drafty
|
||||
* entities). Null when the message is text/not locally available in a recognizable shape. */
|
||||
export function quotedMediaOf(msg: StoredMessage | undefined): QuoteMedia | null {
|
||||
if (!msg || msg.content == null) return null;
|
||||
const mobile = mobileAttachmentContentOf(msg);
|
||||
if (mobile) {
|
||||
if (mobile.kind === 'image' || mobile.kind === 'video') {
|
||||
const ref = mobile.previewRef || mobile.ref;
|
||||
return ref ? { kind: mobile.kind, image: { ref, mime: mobile.mime } } : { kind: mobile.kind };
|
||||
}
|
||||
if (mobile.kind === 'voice') return { kind: 'voice', durationMs: mobile.duration };
|
||||
return { kind: 'file' };
|
||||
}
|
||||
return draftyEntityMedia(msg);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Drafty, type Topic } from 'tinode-sdk';
|
||||
|
||||
import { uploadFile, upgradeToHttps } from '@/lib/api/upload';
|
||||
import { MAX_INBAND_ATTACHMENT_SIZE } from '@/lib/config';
|
||||
import type { ReplyInfo } from '@/lib/messages/reply';
|
||||
import { newClientMid, publishWithReattach } from '@/lib/tinode/send';
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
@@ -29,6 +30,14 @@ function imageDimensions(file: File): Promise<{ width: number; height: number }>
|
||||
});
|
||||
}
|
||||
|
||||
/** Merges the standard send headers with the optional pending quoted-reply snapshot, so media
|
||||
* messages armed with a quote carry the same `x-im-reply` head as text replies. */
|
||||
function mediaHead(type: string, reply?: ReplyInfo | null): Record<string, unknown> {
|
||||
const head: Record<string, unknown> = { 'x-im-type': type, 'x-im-client-mid': newClientMid() };
|
||||
if (reply) head['x-im-reply'] = reply;
|
||||
return head;
|
||||
}
|
||||
|
||||
/**
|
||||
* Small files go inline as base64 (Drafty.insertImage's `bits` field) -- no upload round trip at
|
||||
* all. Larger ones upload first, then reference the returned URL (`refurl`). Simplified from the
|
||||
@@ -36,7 +45,7 @@ function imageDimensions(file: File): Promise<{ width: number; height: number }>
|
||||
* the upload happen in the background): this awaits the upload before creating the message at all,
|
||||
* trading that "shows immediately while uploading" nicety for meaningfully simpler code.
|
||||
*/
|
||||
export async function sendImage(topic: Topic, file: File): Promise<unknown> {
|
||||
export async function sendImage(topic: Topic, file: File, reply?: ReplyInfo | null): Promise<unknown> {
|
||||
const { width, height } = await imageDimensions(file);
|
||||
const content =
|
||||
file.size <= MAX_INBAND_ATTACHMENT_SIZE
|
||||
@@ -50,17 +59,17 @@ export async function sendImage(topic: Topic, file: File): Promise<unknown> {
|
||||
size: file.size,
|
||||
});
|
||||
const msg = topic.createMessage(content, false);
|
||||
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'image', 'x-im-client-mid': newClientMid() });
|
||||
msg.head = Object.assign(msg.head || {}, mediaHead('image', reply));
|
||||
return publishWithReattach(topic, msg);
|
||||
}
|
||||
|
||||
export async function sendFile(topic: Topic, file: File): Promise<unknown> {
|
||||
export async function sendFile(topic: Topic, file: File, reply?: ReplyInfo | null): Promise<unknown> {
|
||||
const content =
|
||||
file.size <= MAX_INBAND_ATTACHMENT_SIZE
|
||||
? Drafty.attachFile(undefined, { mime: file.type, data: await fileToBase64(file), filename: file.name, size: file.size })
|
||||
: Drafty.attachFile(undefined, { mime: file.type, refurl: upgradeToHttps((await uploadFile(file, topic.name)).url), filename: file.name, size: file.size });
|
||||
const msg = topic.createMessage(content, false);
|
||||
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'file', 'x-im-client-mid': newClientMid() });
|
||||
msg.head = Object.assign(msg.head || {}, mediaHead('file', reply));
|
||||
return publishWithReattach(topic, msg);
|
||||
}
|
||||
|
||||
@@ -72,7 +81,7 @@ export async function sendFile(topic: Topic, file: File): Promise<unknown> {
|
||||
* field this app actually displays from doesn't depend on it, only a browser's native <audio>
|
||||
* scrubber would).
|
||||
*/
|
||||
export async function sendVoice(topic: Topic, blob: Blob, durationMs: number): Promise<unknown> {
|
||||
export async function sendVoice(topic: Topic, blob: Blob, durationMs: number, reply?: ReplyInfo | null): Promise<unknown> {
|
||||
const file = new File([blob], `voice.${blob.type.includes('mp4') ? 'm4a' : 'webm'}`, { type: blob.type });
|
||||
const content =
|
||||
file.size <= MAX_INBAND_ATTACHMENT_SIZE
|
||||
@@ -85,6 +94,6 @@ export async function sendVoice(topic: Topic, blob: Blob, durationMs: number): P
|
||||
filename: file.name,
|
||||
});
|
||||
const msg = topic.createMessage(content, false);
|
||||
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'voice', 'x-im-client-mid': newClientMid() });
|
||||
msg.head = Object.assign(msg.head || {}, mediaHead('voice', reply));
|
||||
return publishWithReattach(topic, msg);
|
||||
}
|
||||
|
||||
+19
-17
@@ -1,6 +1,6 @@
|
||||
import { Drafty, type DraftyContent, type StoredMessage, type Topic } from 'tinode-sdk';
|
||||
|
||||
import { QUOTED_REPLY_LENGTH } from '@/lib/config';
|
||||
import { buildReplyInfo } from '@/lib/messages/reply';
|
||||
|
||||
function toDraftyContent(content: DraftyContent | string | null | undefined): DraftyContent {
|
||||
if (content == null) return Drafty.init('');
|
||||
@@ -85,9 +85,11 @@ export function sendPrebuiltContent(topic: Topic, content: DraftyContent | strin
|
||||
* cache (already flushed, or never existed).
|
||||
*
|
||||
* Reuses the ORIGINAL failed message's own `x-im-client-mid` rather than minting a fresh one -- see
|
||||
* newClientMid()'s own comment above for why a retry specifically must not get a new id. Falls back
|
||||
* to a fresh one only for a locally-cached failed message from before this field existed at all
|
||||
* (there's no original id to recover in that one case).
|
||||
* newClientMid()'s own comment above for why a retry specifically must not get a new id. The whole
|
||||
* original head is copied verbatim, which also keeps `x-im-reply` identical across retries (the
|
||||
* server requires head parity for a mid-deduped resend). Falls back to a fresh one only for a
|
||||
* locally-cached failed message from before this field existed at all (no original id to recover
|
||||
* in that one case).
|
||||
*/
|
||||
export function retryMessage(topic: Topic, seq: number): Promise<unknown> | undefined {
|
||||
const failed = topic.flushMessage(seq);
|
||||
@@ -105,20 +107,20 @@ export function messageIsMine(msg: StoredMessage, myUserId: string | null): bool
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply/quote (old app's messages-view.jsx handlePickReply/sendMessage): the quoted body is
|
||||
* truncated (Drafty.replyContent, QUOTED_REPLY_LENGTH) and wrapped with the sender's name
|
||||
* (Drafty.quote), then the whole quote block is INLINED into the outgoing content ahead of the
|
||||
* new text (Drafty.append) -- the receiving side never has to look up the original message to show
|
||||
* a quote preview. `head.reply` carries the original seq separately, purely so a click on the quote
|
||||
* block can scroll back to it.
|
||||
* Quoted reply (cross-client `x-im-reply` contract, aligned with iOS 2026-09-23): the quote is a
|
||||
* lightweight SNAPSHOT carried in `head["x-im-reply"]` (`{seq,type,text,from}`), NOT a Drafty QQ
|
||||
* block inlined into the content -- content stays the clean reply body. The receiving side renders
|
||||
* its own quote bar and locally re-fetches thumbnails/durations by seq. Builds the snapshot from
|
||||
* the quoted message via buildReplyInfo(); throws when the message isn't quotable (failed/pending/
|
||||
* system notice), which the UI gates before arming the reply state.
|
||||
*/
|
||||
export function sendReply(topic: Topic, text: string, quoted: StoredMessage, quotedSenderLabel: string): Promise<unknown> {
|
||||
const trimmed = Drafty.replyContent(toDraftyContent(quoted.content), QUOTED_REPLY_LENGTH);
|
||||
const quoteBlock = Drafty.quote(quotedSenderLabel, quoted.from || '', trimmed);
|
||||
const sanitized = Drafty.sanitizeEntities(JSON.parse(JSON.stringify(quoteBlock)) as DraftyContent);
|
||||
const finalContent = Drafty.append(sanitized, Drafty.parse(text));
|
||||
const msg = topic.createMessage(finalContent, false);
|
||||
msg.head = Object.assign(msg.head || {}, { reply: String(quoted.seq), 'x-im-client-mid': newClientMid() });
|
||||
export function sendReply(topic: Topic, text: string, quoted: StoredMessage): Promise<unknown> {
|
||||
const reply = buildReplyInfo(quoted);
|
||||
if (!reply) throw new Error('该消息不可引用');
|
||||
const parsed = Drafty.parse(text);
|
||||
const content: DraftyContent | string = Drafty.isPlainText(parsed) ? text : parsed;
|
||||
const msg = topic.createMessage(content, false);
|
||||
msg.head = Object.assign(msg.head || {}, { 'x-im-reply': reply, 'x-im-client-mid': newClientMid() });
|
||||
return publishWithReattach(topic, msg);
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -27,8 +27,8 @@ export default defineConfig({
|
||||
// straight to the tenant's real host regardless (confirmed working this way throughout the old
|
||||
// app's development too).
|
||||
proxy: {
|
||||
'/v1/im': { target: 'https://api.118im.com', changeOrigin: true, secure: true },
|
||||
'/v0/file': { target: 'https://api.118im.com', changeOrigin: true, secure: true },
|
||||
'/v1/im': { target: 'http://127.0.0.1:6060', changeOrigin: true, secure: false },
|
||||
'/v0/file': { target: 'http://127.0.0.1:6060', changeOrigin: true, secure: false },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user