100 lines
5.4 KiB
TypeScript
100 lines
5.4 KiB
TypeScript
// 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<string, unknown> | 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 });
|
|
}
|