import type { StoredMessage } from 'tinode-sdk'; import type { MessagePolicy } from '@/lib/api/bootstrap'; import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted } from '@/lib/messages/dispatch'; /** 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' | 'quote' | 'favorite' | 'forward' | 'recall' | 'delete' | 'select'; /** * Seconds left in which `msg` can still be recalled, or `null` if it can't be recalled at all * (not the sender, not yet server-confirmed, no window configured, or the window already lapsed). * Split out from availableActions() so the context menu can also render the "1:42 内" countdown * README §3.5 calls for, not just decide whether to show the action. */ export function recallRemainingSeconds(msg: StoredMessage, myUserId: string | null, messagePolicy: MessagePolicy | undefined): number | null { 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) return null; const remaining = recallWindow - Math.floor((Date.now() - msg.ts.getTime()) / 1000); return remaining > 0 ? remaining : null; } /** * 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). * 翻译 is deliberately never in this list -- confirmed via research that the old app has zero * backend call or handler for it anywhere, only a decorative id in its mock; there's nothing real * to wire up. */ export function availableActions(msg: StoredMessage, myUserId: string | null, messagePolicy: MessagePolicy | undefined): MessageAction[] { if (isServerDeleted(msg) || isRecalled(msg)) return []; // 名片消息和通话结果消息are both flat, non-Drafty custom objects (dispatch.ts's own comments on // contactCardContentOf/callResultDisplay), not something Drafty.toPlainText/replyContent/ // forwardedContent know how to handle -- copy/quote/forward would all feed a Drafty function an // object it doesn't recognize, producing empty/malformed output rather than an error a user would // notice. Favorite/delete/select don't re-parse content, so they stay safe for these two types. const isNonDrafty = contactCardContentOf(msg) !== null || callResultDisplay(msg, !!myUserId && msg.from === myUserId) !== null; const actions: MessageAction[] = isNonDrafty ? ['favorite'] : ['quote', 'copy', 'favorite', 'forward']; // Defer to the server's own supported_actions list when it's present, so a server-side policy // change (recall disabled tenant-wide, say) doesn't leave the client offering an action that // will just 400 when tapped -- the time-window check below still applies on top of this. const serverAllowsRecall = !messagePolicy?.supported_actions || messagePolicy.supported_actions.includes('recall'); if (serverAllowsRecall && recallRemainingSeconds(msg, myUserId, messagePolicy) !== null) { actions.push('recall'); } actions.push('delete', 'select'); return actions; }