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:
co-authored by
Claude Sonnet 5
parent
bccbdff9b2
commit
6fcd779530
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user