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
@@ -1,10 +1,16 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { Link, useOutletContext, useParams } from 'react-router-dom';
|
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 { Icon } from '@/app/Icon';
|
||||||
|
import { MessageContextMenu } from '@/features/messages/MessageContextMenu';
|
||||||
import { MessageRow } from '@/features/messages/MessageBubble';
|
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 { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||||
|
import { availableActions, type MessageAction } from '@/lib/messages/actions';
|
||||||
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
|
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
|
||||||
import { sendText } from '@/lib/tinode/send';
|
import { sendText } from '@/lib/tinode/send';
|
||||||
import { closeConversation, openConversation } from '@/lib/tinode/topics';
|
import { closeConversation, openConversation } from '@/lib/tinode/topics';
|
||||||
@@ -14,6 +20,12 @@ interface OutletCtx {
|
|||||||
onToggleDetail: () => void;
|
onToggleDetail: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ContextMenuState {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
msg: StoredMessage;
|
||||||
|
}
|
||||||
|
|
||||||
export function ConversationView() {
|
export function ConversationView() {
|
||||||
const { topicId } = useParams();
|
const { topicId } = useParams();
|
||||||
const { onToggleDetail } = useOutletContext<OutletCtx>();
|
const { onToggleDetail } = useOutletContext<OutletCtx>();
|
||||||
@@ -22,7 +34,11 @@ export function ConversationView() {
|
|||||||
const contacts = useChatList();
|
const contacts = useChatList();
|
||||||
const contact = contacts.find((c) => c.topic === topicId);
|
const contact = contacts.find((c) => c.topic === topicId);
|
||||||
const messages = useTopicMessages(topicId);
|
const messages = useTopicMessages(topicId);
|
||||||
|
const bootstrap = useBootstrap();
|
||||||
const [draft, setDraft] = useState('');
|
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);
|
const streamRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -31,6 +47,12 @@ export function ConversationView() {
|
|||||||
return () => closeConversation(topicId);
|
return () => closeConversation(topicId);
|
||||||
}, [topicId]);
|
}, [topicId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectMode(false);
|
||||||
|
setSelectedSeqs(new Set());
|
||||||
|
setMenu(null);
|
||||||
|
}, [topicId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
streamRef.current?.scrollTo({ top: streamRef.current.scrollHeight });
|
streamRef.current?.scrollTo({ top: streamRef.current.scrollHeight });
|
||||||
}, [messages.length]);
|
}, [messages.length]);
|
||||||
@@ -62,19 +84,76 @@ export function ConversationView() {
|
|||||||
submit();
|
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 (
|
return (
|
||||||
<div className="flex h-full w-full flex-col bg-surface-chat">
|
<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">
|
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-5">
|
||||||
|
{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">
|
<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">
|
<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" />
|
<Icon name="arrow_back_ios_new" />
|
||||||
</Link>
|
</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)}`}>
|
<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)}
|
{avatarLetter(peerName)}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<strong className="text-base font-semibold text-text-heading">{peerName}</strong>
|
<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>
|
<span className={`text-xs ${contact?.online ? 'text-online' : 'text-text-tertiary-2'}`}>
|
||||||
|
{contact?.online ? '在线' : '离线'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -95,6 +174,8 @@ export function ConversationView() {
|
|||||||
<Icon name="info" />
|
<Icon name="info" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div ref={streamRef} className="flex flex-1 flex-col gap-4 overflow-y-auto px-7 py-5">
|
<div ref={streamRef} className="flex flex-1 flex-col gap-4 overflow-y-auto px-7 py-5">
|
||||||
@@ -106,10 +187,22 @@ export function ConversationView() {
|
|||||||
mine={!!myUserId && msg.from === myUserId}
|
mine={!!myUserId && msg.from === myUserId}
|
||||||
isGroup={isGroup}
|
isGroup={isGroup}
|
||||||
peerName={peerName}
|
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>
|
||||||
|
|
||||||
|
{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 className="flex-none border-t border-border-panel bg-white px-4 pb-3 pt-2">
|
<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">
|
<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="sentiment_satisfied_alt" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
|
||||||
@@ -137,6 +230,17 @@ export function ConversationView() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,13 @@ interface MessageRowProps {
|
|||||||
mine: boolean;
|
mine: boolean;
|
||||||
isGroup: boolean;
|
isGroup: boolean;
|
||||||
peerName: string;
|
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)) {
|
if (isServerDeleted(msg)) {
|
||||||
return <div className="py-1 text-center text-xs text-text-tertiary-4">此消息已被删除</div>;
|
return <div className="py-1 text-center text-xs text-text-tertiary-4">此消息已被删除</div>;
|
||||||
}
|
}
|
||||||
@@ -40,7 +44,26 @@ export function MessageRow({ topic, msg, mine, isGroup, peerName }: MessageRowPr
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-end gap-2.5 ${mine ? 'flex-row-reverse' : ''}`}>
|
<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]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{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 ? (
|
{!mine ? (
|
||||||
<span
|
<span
|
||||||
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(msg.from || peerName)}`}
|
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(msg.from || peerName)}`}
|
||||||
@@ -68,5 +91,6 @@ export function MessageRow({ topic, msg, mine, isGroup, peerName }: MessageRowPr
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user