// 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 type { DraftyContent } from 'tinode-sdk'; import { imGet, imPost, imPut } 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(`/topics/${encodeURIComponent(topic)}/messages/${seq}/recall`, { version, reason }); } interface DraftyEntityData { ref?: string; val?: string; mime?: string; name?: string; size?: number; width?: number; height?: number; duration?: number; } interface DraftyEntity { tp?: string; data?: DraftyEntityData; } /** * docs/IM-前后端对接文档.md:1879,1889,1898 describe `file`/`link` results as flat `{file:{...}}` / * `{url,title,...}` objects -- confirmed LIVE against the real server to be wrong. The real payload * wraps a `snapshot` of the ORIGINAL raw message (`snapshot.content` is a full `{txt,fmt,ent}` * Drafty document -- pass it to lib/messages/drafty.ts's renderDrafty as-is, don't reconstruct it) * around thin search metadata. `snapshot.text` (not modeled here) is a redundant, sometimes-JSON- * stringified fallback the server fills in for non-text messages -- do not render it. */ export interface SearchHit { seq: number; from: string; ts: string; availability?: string; matches?: Array<{ start: number; end: number }>; snippet?: string; snapshot?: { // A plain, unformatted text message's `content` is confirmed LIVE to arrive as a bare string // (not the `{txt,fmt,ent}` object shape this field was originally typed as) -- the same // string-vs-object duality `renderDrafty` (lib/messages/drafty.tsx) already handles for every // other Drafty consumer in this app, discovered here because a real search hit for a plain-text // message rendered as blank space until this was fixed (see HistorySearchModal's SnapshotContent). content?: DraftyContent | string; // Confirmed LIVE (2026-09-24, a real mobile-sent flat-text hit): the snapshot also carries the // original message's `head`, same as favorites.ts's own snapshot already documents. Needed to // recognize a mobile-sent flat content shape (`{text:...}`/`{file_id,...}`/etc., tagged only by // `head['x-im-type']`) BEFORE handing `content` to renderDrafty -- that flat shape isn't a real // Drafty document, and used to crash the whole search modal (no `.txt`/`.text` field `renderDrafty` // recognizes -> `Drafty.toPlainText` returns `undefined` -> highlightMatches() called `.toLowerCase()` // on it). See HistorySearchModal's SnapshotContent for the fix. head?: Record; }; } /** * README §3.4's "查找聊天记录" -- ported endpoint shapes from docs/IM-前后端对接文档.md:1879,1889,1898. * `file`/`link` are this same search endpoint (not the `/media` one below) with an empty `q` -- per * §7.13/7.14 of the protocol doc, only `image`/`video` actually live behind `/media`. */ export function searchTopicMessages(topic: string, query: string, type: 'text' | 'file' | 'link' = 'text', cursor = '') { const qs = new URLSearchParams({ q: query, type }); if (cursor) qs.set('cursor', cursor); return imGet<{ topic: string; items: SearchHit[]; next_cursor?: string }>(`/topics/${encodeURIComponent(topic)}/messages/search?${qs.toString()}`); } /** README §3.4's 4-icon grid (图片/视频/文件/链接) -- a UI-level union for "which of the 4 tabs is * selected." Only 2 of the 4 are actually served by `/media` (see MediaFetchType below); file/link * go through searchTopicMessages instead (docs/IM-前后端对接文档.md §7.13-7.16). */ export type MediaType = 'image' | 'video' | 'file' | 'link'; /** The subset of MediaType that `/media` itself understands. */ export type MediaFetchType = 'image' | 'video'; /** * docs/IM-前后端对接文档.md:1907 describes a flat `{seq,thumbnail_file_id,file_id,width,height,ts, * availability}` -- confirmed LIVE to be wrong the same way as SearchHit above: the real payload is * the raw message's own `{ent,fmt,txt}` at the top level (no `snapshot` wrapper this time), so the * actual attachment lives at `ent[0].data`, same DraftyEntityData shape as everywhere else. */ export interface MediaItem { seq: number; from?: string; ts: string; availability: string; ent?: DraftyEntity[]; } export function getTopicMedia(topic: string, type: MediaFetchType, cursor = '') { const qs = new URLSearchParams({ type }); if (cursor) qs.set('cursor', cursor); return imGet<{ topic: string; items: MediaItem[]; next_cursor?: string }>(`/topics/${encodeURIComponent(topic)}/media?${qs.toString()}`); } export interface TopicDraft { topic: string; content: string; entities: unknown[]; version: number; updated_at?: string; } /** * README §3.2's unsent-message-draft persistence (Zentao #19: input clears on conversation switch/ * page refresh). docs/IM-前后端对接文档.md:1003/1247 marks `PUT /v1/im/topics/{topic}/draft` as * "待实现" (not yet implemented) and describes local-only storage as the baseline -- but confirmed by * reading im_be's own source (server/im_http.go's serveIMTopicDraft, wired to store.Conversations. * GetDraft/PutDraft), this endpoint is REAL and already live, the doc is just stale. A draft that's * never been saved comes back as a 200 (not 404) with `content:""`/`version:0`, so GET never needs * special-casing a not-found error -- confirmed in the same source. */ export function getTopicDraft(topic: string) { return imGet(`/topics/${encodeURIComponent(topic)}/draft`); } /** `entities` intentionally always `[]` for now -- this app's draft state is plain text only (no * in-progress @mention/attachment entities tracked yet), and the field is optional server-side. */ export function putTopicDraft(topic: string, content: string, version: number) { return imPut(`/topics/${encodeURIComponent(topic)}/draft`, { content, entities: [], version }); } /** * A single message's "收藏" context-menu action is really just a one-item batch (docs/ * IM-前后端对接文档.md:1935): `POST /v1/im/favorites/batch {"messages":[{"topic","seq"}]}` -> * `{"created_count","existing_count","failed":[]}`. `seq` travels as a string per the documented * example, not a number. */ export function favoriteMessages(items: Array<{ topic: string; seq: number }>) { return imPost<{ created_count: number; existing_count: number; failed: Array<{ seq: number; reason: string }> }>('/favorites/batch', { messages: items.map((i) => ({ topic: i.topic, seq: String(i.seq) })), }); } export interface ForwardResultItem { source?: { topic: string; seq: number }; target_topic: string; client_mid: string; seq: number; status: 'sent' | 'pending' | 'uncertain'; reason?: string; } export interface ForwardFailureItem { source?: { topic: string; seq: number }; target_topic?: string; status: 'failed'; reason: string; } /** * `POST /v1/im/messages/forward` (server/im_http.go's serveIMMessageForward, confirmed by reading * the real Go source directly, not the protocol doc) -- the SAME backend-authoritative forward path * both real mobile clients use (confirmed by reading their real source too: iOS's * ChatDetailViewController+Forward.swift, Android's ImMessageActionApi.forward(), NEITHER ever builds * a `{pub}` packet client-side). One call handles every (source, target) pair at once -- up to 100 * sources x 20 targets -- with per-pair success/failure reporting, and the server copies * `msg.Content` verbatim regardless of shape (real Drafty or a mobile-sent flat object), so unlike * the client-side approach this replaces, there is no content-shape detection to get wrong here. * `comment` is deliberately NOT wired here even though the endpoint accepts one -- callers keep * sending an optional forward comment as its own separate, ordinary text message afterward (matching * this app's existing, unchanged UX: the comment appears AFTER the forwarded message(s), whereas the * endpoint's own built-in `comment` would save it BEFORE -- a real behavioral difference that's the * caller's choice to take on, not something to silently opt into here). */ export function forwardMessages(sources: Array<{ topic: string; seq: number }>, targetTopics: string[]) { return imPost<{ items: ForwardResultItem[]; failed: ForwardFailureItem[] }>('/messages/forward', { sources, target_topics: targetTopics, }); }