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 { 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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