feat(web): pagination, quote/reply, favorite, and single-message forward

- 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 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-08 02:39:23 +08:00
co-authored by Claude Sonnet 5
parent bccbdff9b2
commit 6fcd779530
7 changed files with 218 additions and 10 deletions
+86 -5
View File
@@ -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<ContextMenuState | null>(null);
const [selectMode, setSelectMode] = useState(false);
const [selectedSeqs, setSelectedSeqs] = useState<Set<number>>(new Set());
const [loadingOlder, setLoadingOlder] = useState(false);
const [replyTo, setReplyTo] = useState<StoredMessage | null>(null);
const [forwardMsg, setForwardMsg] = useState<StoredMessage | null>(null);
const streamRef = useRef<HTMLDivElement>(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<HTMLTextAreaElement>) => {
@@ -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() {
)}
</header>
<div ref={streamRef} className="flex flex-1 flex-col gap-4 overflow-y-auto px-7 py-5">
<div ref={streamRef} onScroll={handleScroll} className="flex flex-1 flex-col gap-4 overflow-y-auto px-7 py-5">
{loadingOlder ? <div className="py-2 text-center text-xs text-text-tertiary-4">加载中…</div> : null}
{messages.map((msg) => (
<MessageRow
key={msg.seq}
@@ -204,6 +265,16 @@ export function ConversationView() {
</div>
) : (
<div className="flex-none border-t border-border-panel bg-white px-4 pb-3 pt-2">
{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">
回复 {myUserId && replyTo.from === myUserId ? '我' : peerName}:{renderDrafty(replyTo.content)}
</span>
<button type="button" onClick={() => setReplyTo(null)} className="flex-none text-text-tertiary-3">
<Icon name="close" className="text-[16px]" />
</button>
</div>
) : null}
<div className="flex items-center gap-1 px-1 pb-1">
<Icon name="sentiment_satisfied_alt" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="image" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
@@ -241,6 +312,16 @@ export function ConversationView() {
onClose={() => setMenu(null)}
/>
) : null}
{forwardMsg ? (
<ForwardPickerModal
onClose={() => setForwardMsg(null)}
onPick={(targetTopic) => {
forwardMessage(topicId, forwardMsg, tinode.getTopic(targetTopic));
setForwardMsg(null);
}}
/>
) : null}
</div>
);
}
@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex h-[480px] w-[420px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<header className="flex h-14 flex-none items-center justify-between border-b border-list-item px-5">
<strong className="text-[15px] font-semibold text-text-heading">转发到</strong>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
</header>
<div className="flex-none px-4 pt-3">
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索会话或联系人"
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto px-2 py-2">
{filtered.map((c) => (
<button
key={c.topic}
type="button"
onClick={() => c.topic && onPick(c.topic)}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-rail"
>
<span
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(c.topic || '')}`}
>
{avatarLetter(c.public?.fn)}
</span>
<span className="truncate text-sm text-text-body">{c.public?.fn || c.topic}</span>
</button>
))}
</div>
</div>
</div>
);
}
@@ -11,7 +11,10 @@ interface MenuItem {
}
const ITEMS: Record<MessageAction, MenuItem> = {
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 },