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
+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;
}