feat(web): message context menu, recall, and basic multi-select
Right-click a message for 复制/撤回/删除/多选 (README §3.5). 引用/收藏/翻译/
转发 deliberately left out of this pass -- quote and forward need their own
composer-state flows, and there's no confirmed single-message favorite
endpoint yet; a menu item that doesn't work is worse than a smaller correct
menu.
- lib/messages/actions.ts: gates recall to own messages, server-confirmed
seq (not the SDK's local pending-seq placeholder), and bootstrap's
message_policy.recall_window_seconds -- scoped to P2P for now, no
group-admin recall yet.
- lib/api/bootstrap.ts + api/hooks.ts: first GET /bootstrap wiring
(message_policy, forward limits, etc.), cached indefinitely per session.
- lib/api/messages.ts: recall goes through REST, not native WS -- and
requires a body `{version, reason}` this backend tracks per-message
independently of Tinode's own seq (confirmed in
docs/IM-前后端对接文档.md:1248 and by hitting a live 400
MALFORMED_REQUEST/version first, since no version field is actually
delivered anywhere in the WS {data} packet this app reads messages from).
Defaults to version:1 -- the correct value for any never-before-edited
message, which covers the common case; a real edit/recall race surfaces
as a version-conflict error rather than being silently papered over.
- Basic multi-select: enter via the context menu's "多选" (no long-press
gesture yet), header becomes "已选择 N 条消息"+取消, bottom bar currently
offers only 删除 (bulk 转发/收藏 wait on those features existing).
Verified end to end: recall correctly withheld past the 120s window and
correctly available within it, the real recall REST call round-trips and
the message disappears locally; multi-select mode enters/exits and
pre-selects the long-pressed/right-clicked message.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
7f5756671d
commit
2272b42c6f
@@ -0,0 +1,82 @@
|
||||
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> = {
|
||||
copy: { action: 'copy', label: '复制', icon: 'content_copy' },
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user