// Favorites REST endpoints (docs/IM-前后端对接文档.md ch.7.1-7.8). `favoriteMessages` (the create // side) already lives in messages.ts since it's called from a message's context menu, not from // here -- this file is the read/manage side (list, detail, delete, batch-delete, forward). import type { DraftyContent } from 'tinode-sdk'; import { imDelete, imGet, imPost } from './client'; export type FavoriteType = 'text' | 'image' | 'video' | 'voice' | 'file' | 'link'; /** * `snapshot`'s shape is polymorphic per `type`. Confirmed live (2026-09-08, real favorite * `fav_I2yn5Rdux0E`) that a `type:"text"` snapshot is NOT the doc's minimal `{text}` example (line * 1933) -- it's essentially the whole original message: `{content, from, head, seq, text, topic, * ts, type, version}`, i.e. `text`/`content` are duplicates of each other and `seq`/`topic`/`type` * duplicate the outer `source`/`type` fields. Genuinely useful new field: `from` -- a real sender * user id, WITHOUT its `usr` prefix (confirmed: my own id is `usrH5dvZ3ckWxo`, this field came back * as `H5dvZ3ckWxo`) -- a per-field prefix-stripping quirk not seen on any other endpoint in this * app, so re-adding `usr` before matching it against `contacts[].topic` elsewhere is a real but * fragile workaround, not done here yet (see the plan file's Phase 5 findings). * * Confirmed live (2026-09-08, data-rich test account) that this SAME "whole original message" * shape applies to EVERY type, not just `text` -- `image`/`video`/`voice`/`file` all come back as * `{content, from, head, seq, text, topic, ts, type, version}` too, where `content` is a real Drafty * object (never the doc's flat `snapshot.file_id`/`duration_ms`/`snapshot.file{...}` shape). The * actual per-type media data lives nested in `content.ent[].data`, keyed by Drafty entity type * (`tp`): `IM` (image), `VD` (video), `AU` (voice), `EX` (file). Voice/image/video carry their * payload INLINE as base64 in `data.val` (confirmed by decoding the base64 header: voice -> `RIFF` * WAV, image -> PNG signature, video's `val` -> WebM/EBML signature); video ALSO carries a separate * inline base64 JPEG poster frame in `data.preview` (distinct from `val`, which is the actual video * bytes) -- there is no separate thumbnail for image since the image IS the thumbnail. `file` has no * `val` at all, only an external `ref` (`/v0/file/s/{id}`), matching how `EX` already works in real * chat messages. The old flat `snapshot.file_id`/`thumbnail_file_id`/`duration_ms`/`file{...}` fields * below are kept as a defensive fallback (never observed live, but the doc's own worked examples * disagree with its prose here too -- see next paragraph -- so it's cheap insurance, not dead code). * * The doc's own worked examples also disagree with its OWN prose for `type:"file"`: §7.3's text * says file snapshots nest under `snapshot.file{name,size,mime,file_id}`, but the worked JSON * example (line 1936) has `file_id`/`name` FLAT on `snapshot` directly, no nesting -- both shapes * are typed as optional so either renders, trusting the concrete example over the prose. */ export interface Favorite { favorite_id: string; type: FavoriteType; snapshot: { text?: string; content?: DraftyContent | string; from?: string; matches?: Array<{ at: number; len: number }>; thumbnail_file_id?: string; file_id?: string; name?: string; size?: number; mime?: string; file?: { name: string; size: number; mime: string; file_id: string }; duration_ms?: number; waveform?: number[]; url?: string; title?: string; summary?: string; risk_level?: string; }; source?: { topic: string; seq: number }; collected_at: string; availability: 'available' | 'removed' | string; permissions?: { can_copy?: boolean }; } /** Pulls one Drafty entity's `data` out of a favorite's `snapshot.content` by entity type (`tp`) -- * see the `Favorite` doc comment above for why this, not the flat `snapshot.*` fields, is where the * real per-type media data for `image`/`video`/`voice`/`file` actually lives. */ export function favoriteEntityData(content: Favorite['snapshot']['content'], tp: string): Record | undefined { if (!content || typeof content === 'string') return undefined; return content.ent?.find((e) => e.tp === tp)?.data; } export function getFavorites(type: FavoriteType | 'all' = 'all', query = '', cursor = '') { const qs = new URLSearchParams({ type }); if (query) qs.set('q', query); if (cursor) qs.set('cursor', cursor); return imGet<{ items: Favorite[]; next_cursor?: string }>(`/favorites?${qs.toString()}`); } export function deleteFavorite(favoriteId: string) { return imDelete<{ favorite_id: string; deleted: boolean }>(`/favorites/${encodeURIComponent(favoriteId)}`); } export function batchDeleteFavorites(favoriteIds: string[]) { return imPost<{ items: Array<{ favorite_id: string; deleted: boolean }>; failed?: Array<{ favorite_id: string; reason: string }> }>( '/favorites/batch-delete', { favorite_ids: favoriteIds }, ); } export function forwardFavorites(favoriteIds: string[], targetTopics: string[], comment = '') { return imPost<{ items: Array<{ favorite_id: string; target_topic: string; status: string; seq?: number }>; failed?: Array<{ favorite_id: string; target_topic: string; reason: string }>; }>('/favorites/forward', { favorite_ids: favoriteIds, target_topics: targetTopics, comment }); }