From 6fcd779530170b4b8a34d661848eb42f10b431f7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 8 Sep 2026 02:39:23 +0800 Subject: [PATCH] feat(web): pagination, quote/reply, favorite, and single-message forward MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConversationView: infinite-scroll "加载更多" -- scroll-to-40px triggers topic.msgHasMoreMessages()+getMessagesPage() to fill exactly the missing seq ranges (not naive since/before paging, since server-side deletions can punch holes in an otherwise-contiguous range), with scroll position preserved across the prepend. - lib/tinode/send.ts: sendReply() ports the old app's exact quote construction (Drafty.replyContent truncates, Drafty.quote wraps with the sender's name, Drafty.append inlines the whole quote block ahead of the new text -- the receiving side never needs to look up the original message). forwardMessage() sends a single message directly to one picked target topic (ForwardPickerModal) -- simplified from the old app's stage-then-compose flow, trading that nicety for a much smaller dialog. - lib/messages/drafty.tsx: added the 'QQ' Drafty style-tag case ported against the SDK's actual Drafty.quote() output shape (no `key`/`data` of its own -- verified by reading tinode.dev.js, not guessed). - lib/api/messages.ts: favoriteMessages() -- a single message is just a one-item batch against POST /v1/im/favorites/batch (confirmed in docs/IM-前后端对接文档.md:1935). - Context menu now shows 引用/复制/收藏/转发/多选/[撤回]/删除 in spec order (README §3.5); 翻译 still deliberately absent (no real backend for it, confirmed by research). Verified end to end: sent a real quote reply and confirmed the exact wire packet (head.reply, QQ fmt span, MN entity) and that it renders correctly via the new QQ formatter case; favorite POSTed and returned created_count:1; forward POSTed the expected {head:{forwarded:"topic:seq"}} packet. Co-Authored-By: Claude Sonnet 5 --- .../features/messages/ConversationView.tsx | 91 ++++++++++++++++++- .../features/messages/ForwardPickerModal.tsx | 63 +++++++++++++ .../features/messages/MessageContextMenu.tsx | 3 + web/src/lib/api/messages.ts | 12 +++ web/src/lib/messages/actions.ts | 12 ++- web/src/lib/messages/drafty.tsx | 8 ++ web/src/lib/tinode/send.ts | 39 ++++++++ 7 files changed, 218 insertions(+), 10 deletions(-) create mode 100644 web/src/features/messages/ForwardPickerModal.tsx diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx index 2a1a0285..97de3b81 100644 --- a/web/src/features/messages/ConversationView.tsx +++ b/web/src/features/messages/ConversationView.tsx @@ -5,14 +5,17 @@ import type { StoredMessage } from 'tinode-sdk'; import { Drafty } from 'tinode-sdk'; import { Icon } from '@/app/Icon'; +import { ForwardPickerModal } from '@/features/messages/ForwardPickerModal'; import { MessageContextMenu } from '@/features/messages/MessageContextMenu'; import { MessageRow } from '@/features/messages/MessageBubble'; import { useBootstrap } from '@/lib/api/hooks'; -import { recallMessage } from '@/lib/api/messages'; +import { favoriteMessages, recallMessage } from '@/lib/api/messages'; import { avatarBgClass, avatarLetter } from '@/lib/avatar'; +import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config'; import { availableActions, type MessageAction } from '@/lib/messages/actions'; +import { renderDrafty } from '@/lib/messages/drafty'; import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks'; -import { sendText } from '@/lib/tinode/send'; +import { forwardMessage, sendReply, sendText } from '@/lib/tinode/send'; import { closeConversation, openConversation } from '@/lib/tinode/topics'; import { useSessionStore } from '@/stores/session'; @@ -39,7 +42,11 @@ export function ConversationView() { const [menu, setMenu] = useState(null); const [selectMode, setSelectMode] = useState(false); const [selectedSeqs, setSelectedSeqs] = useState>(new Set()); + const [loadingOlder, setLoadingOlder] = useState(false); + const [replyTo, setReplyTo] = useState(null); + const [forwardMsg, setForwardMsg] = useState(null); const streamRef = useRef(null); + const autoScrollRef = useRef(true); useEffect(() => { if (!topicId) return undefined; @@ -51,10 +58,13 @@ export function ConversationView() { setSelectMode(false); setSelectedSeqs(new Set()); setMenu(null); + setReplyTo(null); }, [topicId]); useEffect(() => { - streamRef.current?.scrollTo({ top: streamRef.current.scrollHeight }); + if (autoScrollRef.current) { + streamRef.current?.scrollTo({ top: streamRef.current.scrollHeight }); + } }, [messages.length]); useEffect(() => { @@ -67,11 +77,52 @@ export function ConversationView() { const peerName = contact?.public?.fn || topicId; const isGroup = topic.isGroupType(); + /** + * "加载更多" (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 + * (topic.msgHasMoreMessages) and fills exactly those gaps (topic.getMessagesPage), since messages + * can have server-side deletions punching holes in an otherwise-contiguous range. + */ + const loadOlder = async () => { + if (loadingOlder) return; + const el = streamRef.current; + const min = topic.minMsgSeq(); + const max = topic.maxMsgSeq(); + const gaps = topic.msgHasMoreMessages(min, max, false); + if (gaps.length === 0) return; + setLoadingOlder(true); + const prevHeight = el?.scrollHeight ?? 0; + const prevTop = el?.scrollTop ?? 0; + try { + await topic.getMessagesPage(MESSAGES_PAGE, gaps, min, max); + } finally { + setLoadingOlder(false); + requestAnimationFrame(() => { + if (el) el.scrollTop = prevTop + (el.scrollHeight - prevHeight); + }); + } + }; + + const handleScroll = () => { + const el = streamRef.current; + if (!el) return; + autoScrollRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80; + if (el.scrollTop <= FETCH_PAGE_TRIGGER) { + loadOlder(); + } + }; + const submit = () => { const text = draft.trim(); if (!text) return; setDraft(''); - sendText(topic, text); + if (replyTo) { + const label = myUserId && replyTo.from === myUserId ? '我' : peerName; + sendReply(topic, text, replyTo, label); + setReplyTo(null); + } else { + sendText(topic, text); + } }; const handleKeyDown = (e: React.KeyboardEvent) => { @@ -125,6 +176,15 @@ export function ConversationView() { setSelectMode(true); setSelectedSeqs(new Set([msg.seq])); break; + case 'quote': + setReplyTo(msg); + break; + case 'favorite': + favoriteMessages([{ topic: topicId, seq: msg.seq }]).catch(() => {}); + break; + case 'forward': + setForwardMsg(msg); + break; } }; @@ -178,7 +238,8 @@ export function ConversationView() { )} -
+
+ {loadingOlder ?
加载中…
: null} {messages.map((msg) => ( ) : (
+ {replyTo ? ( +
+ + 回复 {myUserId && replyTo.from === myUserId ? '我' : peerName}:{renderDrafty(replyTo.content)} + + +
+ ) : null}
@@ -241,6 +312,16 @@ export function ConversationView() { onClose={() => setMenu(null)} /> ) : null} + + {forwardMsg ? ( + setForwardMsg(null)} + onPick={(targetTopic) => { + forwardMessage(topicId, forwardMsg, tinode.getTopic(targetTopic)); + setForwardMsg(null); + }} + /> + ) : null}
); } diff --git a/web/src/features/messages/ForwardPickerModal.tsx b/web/src/features/messages/ForwardPickerModal.tsx new file mode 100644 index 00000000..443a8cfb --- /dev/null +++ b/web/src/features/messages/ForwardPickerModal.tsx @@ -0,0 +1,63 @@ +import { useState } from 'react'; + +import { Icon } from '@/app/Icon'; +import { avatarBgClass, avatarLetter } from '@/lib/avatar'; +import { useChatList } from '@/lib/tinode/hooks'; + +interface ForwardPickerModalProps { + onPick: (targetTopic: string) => void; + onClose: () => void; +} + +/** + * Simplified single-target contact picker for the message context menu's "转发". The full design + * (README §3.6) supports multi-select + a merge/separate-forward toggle for the multi-select bulk + * bar's forward action -- this is the smaller single-message version, one tap sends immediately. + */ +export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps) { + const contacts = useChatList(); + const [query, setQuery] = useState(''); + const filtered = contacts.filter((c) => !query.trim() || (c.public?.fn || '').includes(query.trim())); + + return ( +
+
e.stopPropagation()}> +
+ 转发到 + +
+
+
+ + setQuery(e.target.value)} + placeholder="搜索会话或联系人" + className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2" + /> +
+
+
+ {filtered.map((c) => ( + + ))} +
+
+
+ ); +} diff --git a/web/src/features/messages/MessageContextMenu.tsx b/web/src/features/messages/MessageContextMenu.tsx index 8316f008..3955818d 100644 --- a/web/src/features/messages/MessageContextMenu.tsx +++ b/web/src/features/messages/MessageContextMenu.tsx @@ -11,7 +11,10 @@ interface MenuItem { } const ITEMS: Record = { + quote: { action: 'quote', label: '引用', icon: 'format_quote' }, copy: { action: 'copy', label: '复制', icon: 'content_copy' }, + favorite: { action: 'favorite', label: '收藏', icon: 'inventory_2' }, + forward: { action: 'forward', label: '转发', icon: 'reply' }, recall: { action: 'recall', label: '撤回', icon: 'undo' }, select: { action: 'select', label: '多选', icon: 'select_all' }, delete: { action: 'delete', label: '删除', icon: 'delete_outline', danger: true }, diff --git a/web/src/lib/api/messages.ts b/web/src/lib/api/messages.ts index e071655f..3aa58789 100644 --- a/web/src/lib/api/messages.ts +++ b/web/src/lib/api/messages.ts @@ -68,3 +68,15 @@ export function getTopicMedia(topic: string, type: MediaType, cursor = '') { if (cursor) qs.set('cursor', cursor); return imGet<{ topic: string; items: MediaItem[]; next_cursor?: string }>(`/topics/${encodeURIComponent(topic)}/media?${qs.toString()}`); } + +/** + * A single message's "收藏" context-menu action is really just a one-item batch (docs/ + * IM-前后端对接文档.md:1935): `POST /v1/im/favorites/batch {"messages":[{"topic","seq"}]}` -> + * `{"created_count","existing_count","failed":[]}`. `seq` travels as a string per the documented + * example, not a number. + */ +export function favoriteMessages(items: Array<{ topic: string; seq: number }>) { + return imPost<{ created_count: number; existing_count: number; failed: Array<{ seq: number; reason: string }> }>('/favorites/batch', { + messages: items.map((i) => ({ topic: i.topic, seq: String(i.seq) })), + }); +} diff --git a/web/src/lib/messages/actions.ts b/web/src/lib/messages/actions.ts index 01464420..a2b1a2db 100644 --- a/web/src/lib/messages/actions.ts +++ b/web/src/lib/messages/actions.ts @@ -1,22 +1,24 @@ import type { StoredMessage } from 'tinode-sdk'; import type { MessagePolicy } from '@/lib/api/bootstrap'; +import { isRecalled, isServerDeleted } from '@/lib/messages/dispatch'; /** 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). */ const LOCAL_SEQID = 0xfffffff; -export type MessageAction = 'copy' | 'recall' | 'delete' | 'select'; +export type MessageAction = 'copy' | 'quote' | 'favorite' | 'forward' | 'recall' | 'delete' | 'select'; /** * Which context-menu actions apply to one message. Scoped to P2P for now (no group-admin recall, * no self-topic hard-delete-always -- those need group role/topic-type knowledge Phase 3 will add). - * 引用/收藏/翻译/转发 are deliberately not in this list yet: quote and forward need their own - * composer-state flows, and there's no confirmed single-message favorite endpoint yet -- adding - * them here before they actually work would just be dead menu items. + * 翻译 is deliberately never in this list -- confirmed via research that the old app has zero + * backend call or handler for it anywhere, only a decorative id in its mock; there's nothing real + * to wire up. */ export function availableActions(msg: StoredMessage, myUserId: string | null, messagePolicy: MessagePolicy | undefined): MessageAction[] { - const actions: MessageAction[] = ['copy']; + if (isServerDeleted(msg) || isRecalled(msg)) return []; + const actions: MessageAction[] = ['quote', 'copy', 'favorite', 'forward']; const mine = !!myUserId && msg.from === myUserId; const isConfirmed = typeof msg.seq === 'number' && msg.seq < LOCAL_SEQID; const recallWindow = messagePolicy?.recall_window_seconds ?? 0; diff --git a/web/src/lib/messages/drafty.tsx b/web/src/lib/messages/drafty.tsx index 1d41bde4..0094fcb7 100644 --- a/web/src/lib/messages/drafty.tsx +++ b/web/src/lib/messages/drafty.tsx @@ -55,6 +55,14 @@ function formatNode( ); } + case 'QQ': + // Drafty.quote() has no `key`/`data` of its own (tinode.dev.js:2160-2168) -- it's a plain style + // span wrapping mention(header,uid) + linebreak + body, all already rendered into `children`. + return ( +
+ {children} +
+ ); default: return {children}; } diff --git a/web/src/lib/tinode/send.ts b/web/src/lib/tinode/send.ts index 20c06572..f7868b53 100644 --- a/web/src/lib/tinode/send.ts +++ b/web/src/lib/tinode/send.ts @@ -1,5 +1,12 @@ import { Drafty, type DraftyContent, type StoredMessage, type Topic } from 'tinode-sdk'; +import { QUOTED_REPLY_LENGTH } from '@/lib/config'; + +function toDraftyContent(content: DraftyContent | string | null | undefined): DraftyContent { + if (content == null) return Drafty.init(''); + return typeof content === 'string' ? Drafty.init(content) : content; +} + /** * Plain-text send: createMessage + publishDraft (not the simpler `.publish()`), matching the old * app's own choice -- publishDraft inserts the message into the local cache and fires onData @@ -30,3 +37,35 @@ export function retryMessage(topic: Topic, seq: number): Promise | unde export function messageIsMine(msg: StoredMessage, myUserId: string | null): boolean { return !!myUserId && msg.from === myUserId; } + +/** + * 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. + */ +export function sendReply(topic: Topic, text: string, quoted: StoredMessage, quotedSenderLabel: string): Promise { + 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) }); + return topic.publishDraft(msg, Promise.resolve()); +} + +/** + * Single-message forward, sent directly to one target topic. Simplified from the old app's own + * flow (which stages the forwarded content and lets the user add a comment via the normal composer + * before sending) -- this sends immediately on picking a target, trading that one nicety for a + * much smaller "pick a contact" dialog. `head.forwarded` records the source for traceability; it + * has no reader anywhere yet, matching the old app (no confirmed call site reads it back either). + */ +export function forwardMessage(sourceTopic: string, msg: StoredMessage, target: Topic): Promise { + const content = Drafty.forwardedContent(toDraftyContent(msg.content)); + const newMsg = target.createMessage(content, false); + newMsg.head = Object.assign(newMsg.head || {}, { forwarded: `${sourceTopic}:${msg.seq}` }); + return target.publishDraft(newMsg, Promise.resolve()); +}