Files
im_webapp/web/src/features/messages/MessageContextMenu.tsx
T
Your NameandClaude Sonnet 5 6fcd779530 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>
2026-09-08 02:39:23 +08:00

86 lines
2.9 KiB
TypeScript

import { useEffect } from 'react';
import { Icon } from '@/app/Icon';
import type { MessageAction } from '@/lib/messages/actions';
interface MenuItem {
action: MessageAction;
label: string;
icon: string;
danger?: boolean;
}
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 },
};
interface MessageContextMenuProps {
x: number;
y: number;
actions: MessageAction[];
onAction: (action: MessageAction) => void;
onClose: () => void;
}
/** README §3.5: 180px popup, divider before the destructive-leaning tail (recall/delete). */
export function MessageContextMenu({ x, y, actions, onAction, onClose }: MessageContextMenuProps) {
useEffect(() => {
const handler = () => onClose();
window.addEventListener('click', handler);
window.addEventListener('scroll', handler, true);
return () => {
window.removeEventListener('click', handler);
window.removeEventListener('scroll', handler, true);
};
}, [onClose]);
const leading = actions.filter((a) => a !== 'recall' && a !== 'delete');
const trailing = actions.filter((a) => a === 'recall' || a === 'delete');
return (
<div
style={{ left: x, top: y }}
className="fixed z-50 w-[180px] rounded-[10px] border border-border-panel bg-white p-1.5 shadow-menu"
onClick={(e) => e.stopPropagation()}
>
{leading.map((action) => {
const item = ITEMS[action];
return (
<button
key={action}
type="button"
onClick={() => onAction(action)}
className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left text-sm text-text-secondary hover:bg-surface-rail"
>
<Icon name={item.icon} className="text-[18px]" />
{item.label}
</button>
);
})}
{trailing.length ? <div className="my-1 h-px bg-list-item" /> : null}
{trailing.map((action) => {
const item = ITEMS[action];
return (
<button
key={action}
type="button"
onClick={() => onAction(action)}
className={`flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left text-sm hover:bg-surface-rail ${
item.danger ? 'text-danger-text' : 'text-text-secondary'
}`}
>
<Icon name={item.icon} className="text-[18px]" />
{item.label}
</button>
);
})}
</div>
);
}