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:
Your Name
2026-09-08 02:01:53 +08:00
co-authored by Claude Sonnet 5
parent 7f5756671d
commit 2272b42c6f
7 changed files with 368 additions and 79 deletions
+159 -55
View File
@@ -1,10 +1,16 @@
import { useEffect, useRef, useState } from 'react';
import { Link, useOutletContext, useParams } from 'react-router-dom';
import type { StoredMessage } from 'tinode-sdk';
import { Drafty } from 'tinode-sdk';
import { Icon } from '@/app/Icon';
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 { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { availableActions, type MessageAction } from '@/lib/messages/actions';
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
import { sendText } from '@/lib/tinode/send';
import { closeConversation, openConversation } from '@/lib/tinode/topics';
@@ -14,6 +20,12 @@ interface OutletCtx {
onToggleDetail: () => void;
}
interface ContextMenuState {
x: number;
y: number;
msg: StoredMessage;
}
export function ConversationView() {
const { topicId } = useParams();
const { onToggleDetail } = useOutletContext<OutletCtx>();
@@ -22,7 +34,11 @@ export function ConversationView() {
const contacts = useChatList();
const contact = contacts.find((c) => c.topic === topicId);
const messages = useTopicMessages(topicId);
const bootstrap = useBootstrap();
const [draft, setDraft] = useState('');
const [menu, setMenu] = useState<ContextMenuState | null>(null);
const [selectMode, setSelectMode] = useState(false);
const [selectedSeqs, setSelectedSeqs] = useState<Set<number>>(new Set());
const streamRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -31,6 +47,12 @@ export function ConversationView() {
return () => closeConversation(topicId);
}, [topicId]);
useEffect(() => {
setSelectMode(false);
setSelectedSeqs(new Set());
setMenu(null);
}, [topicId]);
useEffect(() => {
streamRef.current?.scrollTo({ top: streamRef.current.scrollHeight });
}, [messages.length]);
@@ -62,39 +84,98 @@ export function ConversationView() {
submit();
};
const toggleSelect = (seq: number) => {
setSelectedSeqs((prev) => {
const next = new Set(prev);
if (next.has(seq)) next.delete(seq);
else next.add(seq);
return next;
});
};
const exitSelectMode = () => {
setSelectMode(false);
setSelectedSeqs(new Set());
};
const bulkDelete = () => {
if (selectedSeqs.size === 0) return;
if (!window.confirm(`删除已选择的 ${selectedSeqs.size} 条消息?`)) return;
topic.delMessagesList([...selectedSeqs], false);
exitSelectMode();
};
const handleAction = (action: MessageAction) => {
const msg = menu?.msg;
setMenu(null);
if (!msg || typeof msg.seq !== 'number') return;
switch (action) {
case 'copy':
navigator.clipboard.writeText(Drafty.toPlainText(msg.content ?? '')).catch(() => {});
break;
case 'recall':
recallMessage(topicId, msg.seq)
.then(() => topic.delMessagesList([msg.seq], true))
.catch(() => {});
break;
case 'delete':
topic.delMessagesList([msg.seq], false);
break;
case 'select':
setSelectMode(true);
setSelectedSeqs(new Set([msg.seq]));
break;
}
};
return (
<div className="flex h-full w-full flex-col bg-surface-chat">
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-5">
<div className="flex items-center gap-2.5">
<Link to="/messages" className="grid h-9.5 w-9.5 flex-none place-items-center text-text-heading md:hidden">
<Icon name="arrow_back_ios_new" />
</Link>
<span className={`grid h-9.5 w-9.5 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(topicId)}`}>
{avatarLetter(peerName)}
</span>
<div className="flex flex-col">
<strong className="text-base font-semibold text-text-heading">{peerName}</strong>
<span className={`text-xs ${contact?.online ? 'text-online' : 'text-text-tertiary-2'}`}>{contact?.online ? '在线' : '离线'}</span>
</div>
</div>
<div className="flex items-center gap-1">
<button type="button" className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1">
<Icon name="call" />
</button>
<button type="button" className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1">
<Icon name="videocam" />
</button>
<button type="button" className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1">
<Icon name="search" />
</button>
<button
type="button"
onClick={onToggleDetail}
className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1 md:bg-accent-soft-bg md:text-accent lg:bg-transparent lg:text-text-tertiary-1"
>
<Icon name="info" />
</button>
</div>
{selectMode ? (
<>
<span className="text-[15px] font-medium text-text-heading">已选择 {selectedSeqs.size} 条消息</span>
<button type="button" onClick={exitSelectMode} className="text-sm text-accent">
取消
</button>
</>
) : (
<>
<div className="flex items-center gap-2.5">
<Link to="/messages" className="grid h-9.5 w-9.5 flex-none place-items-center text-text-heading md:hidden">
<Icon name="arrow_back_ios_new" />
</Link>
<span
className={`grid h-9.5 w-9.5 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(topicId)}`}
>
{avatarLetter(peerName)}
</span>
<div className="flex flex-col">
<strong className="text-base font-semibold text-text-heading">{peerName}</strong>
<span className={`text-xs ${contact?.online ? 'text-online' : 'text-text-tertiary-2'}`}>
{contact?.online ? '在线' : '离线'}
</span>
</div>
</div>
<div className="flex items-center gap-1">
<button type="button" className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1">
<Icon name="call" />
</button>
<button type="button" className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1">
<Icon name="videocam" />
</button>
<button type="button" className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1">
<Icon name="search" />
</button>
<button
type="button"
onClick={onToggleDetail}
className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1 md:bg-accent-soft-bg md:text-accent lg:bg-transparent lg:text-text-tertiary-1"
>
<Icon name="info" />
</button>
</div>
</>
)}
</header>
<div ref={streamRef} className="flex flex-1 flex-col gap-4 overflow-y-auto px-7 py-5">
@@ -106,37 +187,60 @@ export function ConversationView() {
mine={!!myUserId && msg.from === myUserId}
isGroup={isGroup}
peerName={peerName}
selectMode={selectMode}
selected={typeof msg.seq === 'number' && selectedSeqs.has(msg.seq)}
onToggleSelect={toggleSelect}
onContextMenu={(e, m) => setMenu({ x: e.clientX, y: e.clientY, msg: m })}
/>
))}
</div>
<div className="flex-none border-t border-border-panel bg-white px-4 pb-3 pt-2">
<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" />
<Icon name="attach_file" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="contact_page" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="mic_none" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="history" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
</div>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
className="min-h-[72px] w-full resize-none bg-transparent text-[15px] leading-normal outline-none placeholder:text-text-placeholder-1"
/>
<div className="flex justify-end">
<button
type="button"
onClick={submit}
disabled={!draft.trim()}
className="h-9 rounded-lg bg-btn px-5.5 text-sm font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
>
发送
{selectMode ? (
<div className="flex h-18 flex-none items-center justify-around border-t border-border-panel bg-white">
<button type="button" onClick={bulkDelete} className="flex flex-col items-center gap-1 text-danger-text">
<Icon name="delete_outline" className="text-[22px]" />
<span className="text-xs">删除</span>
</button>
</div>
</div>
) : (
<div className="flex-none border-t border-border-panel bg-white px-4 pb-3 pt-2">
<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" />
<Icon name="attach_file" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="contact_page" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="mic_none" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="history" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
</div>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
className="min-h-[72px] w-full resize-none bg-transparent text-[15px] leading-normal outline-none placeholder:text-text-placeholder-1"
/>
<div className="flex justify-end">
<button
type="button"
onClick={submit}
disabled={!draft.trim()}
className="h-9 rounded-lg bg-btn px-5.5 text-sm font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
>
发送
</button>
</div>
</div>
)}
{menu ? (
<MessageContextMenu
x={menu.x}
y={menu.y}
actions={availableActions(menu.msg, myUserId, bootstrap.data?.message_policy)}
onAction={handleAction}
onClose={() => setMenu(null)}
/>
) : null}
</div>
);
}
+48 -24
View File
@@ -14,9 +14,13 @@ interface MessageRowProps {
mine: boolean;
isGroup: boolean;
peerName: string;
selectMode?: boolean;
selected?: boolean;
onToggleSelect?: (seq: number) => void;
onContextMenu?: (e: React.MouseEvent, msg: StoredMessage) => void;
}
export function MessageRow({ topic, msg, mine, isGroup, peerName }: MessageRowProps) {
export function MessageRow({ topic, msg, mine, isGroup, peerName, selectMode, selected, onToggleSelect, onContextMenu }: MessageRowProps) {
if (isServerDeleted(msg)) {
return <div className="py-1 text-center text-xs text-text-tertiary-4">此消息已被删除</div>;
}
@@ -40,32 +44,52 @@ export function MessageRow({ topic, msg, mine, isGroup, peerName }: MessageRowPr
};
return (
<div className={`flex items-end gap-2.5 ${mine ? 'flex-row-reverse' : ''}`}>
{!mine ? (
<span
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(msg.from || peerName)}`}
<div className="flex items-center gap-2">
{selectMode ? (
<button
type="button"
onClick={() => typeof msg.seq === 'number' && onToggleSelect?.(msg.seq)}
className={`grid h-5 w-5 flex-none place-items-center rounded-full text-[13px] text-white ${
selected ? 'bg-accent' : 'border-[1.5px] border-[#c5cad2]'
}`}
>
{avatarLetter(peerName)}
</span>
) : (
<span className="w-9 flex-none" />
)}
<div className={`flex max-w-[60%] flex-col gap-1 ${mine ? 'items-end' : 'items-start'}`}>
<div
className={`px-3.5 py-2.5 text-[15px] leading-[1.55] break-words ${
mine ? 'rounded-[12px_4px_12px_12px] bg-bubble-mine' : 'rounded-[4px_12px_12px_12px] bg-white shadow-bubble'
} ${isRecalled(msg) ? 'text-text-tertiary-4 italic' : ''}`}
>
{isRecalled(msg) ? '⚠ 消息已删除' : renderDrafty(msg.content)}
</div>
{failed ? (
<button type="button" onClick={retry} className="flex items-center gap-1 text-xs text-danger-text">
<Icon name="error" className="text-[16px]" />
发送失败,点击重试
</button>
{selected ? <Icon name="check" className="text-[14px]" /> : null}
</button>
) : null}
<div
onContextMenu={(e) => {
if (selectMode) return;
e.preventDefault();
onContextMenu?.(e, msg);
}}
className={`flex flex-1 items-end gap-2.5 ${mine ? 'flex-row-reverse' : ''}`}
>
{!mine ? (
<span
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(msg.from || peerName)}`}
>
{avatarLetter(peerName)}
</span>
) : (
<span className="text-[11px] text-text-tertiary-3">{sending ? '发送中…' : statusLabel}</span>
<span className="w-9 flex-none" />
)}
<div className={`flex max-w-[60%] flex-col gap-1 ${mine ? 'items-end' : 'items-start'}`}>
<div
className={`px-3.5 py-2.5 text-[15px] leading-[1.55] break-words ${
mine ? 'rounded-[12px_4px_12px_12px] bg-bubble-mine' : 'rounded-[4px_12px_12px_12px] bg-white shadow-bubble'
} ${isRecalled(msg) ? 'text-text-tertiary-4 italic' : ''}`}
>
{isRecalled(msg) ? '⚠ 消息已删除' : renderDrafty(msg.content)}
</div>
{failed ? (
<button type="button" onClick={retry} className="flex items-center gap-1 text-xs text-danger-text">
<Icon name="error" className="text-[16px]" />
发送失败,点击重试
</button>
) : (
<span className="text-[11px] text-text-tertiary-3">{sending ? '发送中…' : statusLabel}</span>
)}
</div>
</div>
</div>
);
@@ -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>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { imGet } from './client';
export interface MessagePolicy {
supported_actions?: string[];
edit_window_seconds?: number;
recall_window_seconds?: number;
}
export interface BootstrapConfig {
message_policy?: MessagePolicy;
limits?: { max_forward_targets?: number; [key: string]: unknown };
[key: string]: unknown;
}
export function getBootstrap() {
return imGet<BootstrapConfig>('/bootstrap');
}
+8
View File
@@ -0,0 +1,8 @@
import { useQuery } from '@tanstack/react-query';
import { getBootstrap } from './bootstrap';
/** Bootstrap config barely changes within a session -- fetch once, keep indefinitely. */
export function useBootstrap() {
return useQuery({ queryKey: ['bootstrap'], queryFn: getBootstrap, staleTime: Infinity });
}
+26
View File
@@ -0,0 +1,26 @@
// Message-level REST endpoints that sit alongside the native WS protocol. Recall goes through REST
// (not a Tinode WS operation) -- ported from the old app's context-menu.jsx recallOwnMessage, which
// calls this then hard-deletes the message from the local topic cache itself.
import { imPost } from './client';
export interface RecallResult {
topic: string;
seq: number;
status: string;
version: number;
updated_at: string;
}
/**
* This backend tracks its own optimistic-concurrency `version` per message (distinct from Tinode's
* `seq` -- confirmed in docs/IM-前后端对接文档.md:1248, `{"version":1,"reason":"..."}` request,
* `{"version":2,...}` response), which is NOT delivered anywhere in the native WS `{data}` packet
* this app reads messages from (confirmed live: a real message's WS payload carries no `version`
* field at all). Since there's currently no source for a message's true current version, this
* defaults to 1 (the version of any never-before-edited-or-recalled message, the overwhelmingly
* common case) -- a real edit/recall race would surface as a version-conflict error from the
* server, which the caller should treat as "refetch and retry," not silently ignore.
*/
export function recallMessage(topic: string, seq: number, version = 1, reason?: string) {
return imPost<RecallResult>(`/topics/${encodeURIComponent(topic)}/messages/${seq}/recall`, { version, reason });
}
+28
View File
@@ -0,0 +1,28 @@
import type { StoredMessage } from 'tinode-sdk';
import type { MessagePolicy } from '@/lib/api/bootstrap';
/** 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';
/**
* 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.
*/
export function availableActions(msg: StoredMessage, myUserId: string | null, messagePolicy: MessagePolicy | undefined): MessageAction[] {
const actions: MessageAction[] = ['copy'];
const mine = !!myUserId && msg.from === myUserId;
const isConfirmed = typeof msg.seq === 'number' && msg.seq < LOCAL_SEQID;
const recallWindow = messagePolicy?.recall_window_seconds ?? 0;
if (mine && isConfirmed && recallWindow > 0 && msg.ts && msg.ts.getTime() > Date.now() - recallWindow * 1000) {
actions.push('recall');
}
actions.push('delete', 'select');
return actions;
}