194 lines
9.4 KiB
TypeScript
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,
|
|
});
|
|
}
|