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
+39
View File
@@ -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<unknown> | 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<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) });
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<unknown> {
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());
}