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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user