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
@@ -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) })),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -55,6 +55,14 @@ function formatNode(
|
||||
</a>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<div key={key} className="mb-1.5 rounded border-l-2 border-accent bg-white/50 px-2 py-0.5 text-xs text-[#4a4f57]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return <Fragment key={key}>{children}</Fragment>;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user