Files
im_webapp/web/src/lib/api/messages.ts
T

194 lines
9.4 KiB
TypeScript

// 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<RecallResult>(`/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<string, unknown>;
};
}
/**
* 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<TopicDraft>(`/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<TopicDraft>(`/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,
});
}