// Friend/conversation-settings/blocklist REST endpoints. Paths and payload shapes ported from the // old app's src/lib/im-api.js (contact-management-view.jsx's real "聊天设置" panel, not the mock // direct-message-view.jsx) -- verified there, not guessed. import { imDelete, imGet, imPatch, imPost, imPut } from './client'; /** Confirmed live shape (doc line 1417): `initial` is server-computed (handles pinyin-to-letter * itself), so the contacts list groups by THIS field, not a client-side first-character guess like * CreateGroupModal's friend picker had to fall back to (that one has no `initial` in its response). */ export interface Friend { user_id: string; display_name?: string; remark?: string; avatar_file_id?: string; initial?: string; topic?: string; relationship?: string; online_visibility?: string; capabilities?: { can_send?: boolean; can_call?: boolean }; [key: string]: unknown; } export function getFriends(query = '', cursor = '', initial = '') { const qs = new URLSearchParams(); if (query) qs.set('query', query); if (initial) qs.set('initial', initial); if (cursor) qs.set('cursor', cursor); const suffix = qs.toString(); return imGet<{ items: Friend[]; next_cursor?: string }>(`/friends${suffix ? `?${suffix}` : ''}`); } export function deleteFriend(userId: string) { return imDelete(`/friends/${encodeURIComponent(userId)}`); } export function updateFriendRemark(userId: string, remark: string) { return imPatch(`/friends/${encodeURIComponent(userId)}`, { remark }); } /** Confirmed live shape (doc line 1423): no `action` field as this file originally typed (there's * nothing in the response describing what UI affordance to show -- that's derived client-side * from `relationship`/`can_add` instead), and `avatar_file_id` is present. */ export interface UserSearchResult { user_id: string; display_name: string; avatar_file_id?: string; public_account?: string; // docs/IM-前后端对接文档.md:1289 -- real enum is these 4 values, direction-specific, never a bare // "pending" (a prior version of this type had that wrong, which meant no caller's `=== 'pending'` // comparison could ever match either real pending state). relationship: 'none' | 'friend' | 'pending_inbound' | 'pending_outbound' | string; can_add: boolean; } export function searchUsers(query: string, type: 'account' | 'exact_account' | 'phone' = 'account', cursor = '') { const qs = new URLSearchParams({ q: query, type }); if (cursor) qs.set('cursor', cursor); return imGet<{ items: UserSearchResult[]; next_cursor?: string }>(`/users/search?${qs.toString()}`); } export function sendFriendRequest(targetUserId: string, message?: string, source = 'search', sourceTopic?: string) { return imPost<{ request_id: string; topic: string; status: string }>('/friend-requests', { target_user_id: targetUserId, message, source, source_topic: sourceTopic, }); } /** `source` and `created_at` (doc line 1419's worked example) were missing from this file's * original type -- needed for the 新朋友 list's "今天 10:12 · 通过手机号搜索" line. `handled_at` * is mentioned in the doc's prose but not its own worked example, kept optional either way. */ export interface FriendRequest { request_id: string; applicant_id: string; target_user_id: string; direction: 'inbound' | 'outbound'; status: string; message?: string; source?: string; created_at: string; handled_at?: string; peer?: { user_id: string; display_name?: string }; topic?: string; } /** Doc's confirmed query params (line 1277/1419) -- the original zero-arg version always hit the * endpoint with no filters at all, whose default direction/status scope isn't documented; the * design's 新朋友 list needs BOTH pending and already-handled inbound requests shown together * (处理过的显示灰字"已通过/已拒绝"), so this defaults to exactly that rather than relying on * whatever an unfiltered call happens to return. */ export function getFriendRequests(direction: 'inbound' | 'outbound' = 'inbound', status: 'pending' | 'all' = 'all', cursor = '') { const qs = new URLSearchParams({ direction, status }); if (cursor) qs.set('cursor', cursor); return imGet<{ items: FriendRequest[]; next_cursor?: string }>(`/friend-requests?${qs.toString()}`); } export function acceptFriendRequest(requestId: string, version?: number) { return imPost(`/friend-requests/${encodeURIComponent(requestId)}/accept`, version !== undefined ? { version } : {}); } export function rejectFriendRequest(requestId: string, version?: number) { return imPost(`/friend-requests/${encodeURIComponent(requestId)}/reject`, version !== undefined ? { version } : {}); } /** * Confirmed live (2026-09-08, real friend `usruF4Uil-jCKM`): `capabilities.can_add_friend`, not the * doc's own `can_add` (line 1425) -- another confirmed field-name mismatch, not this file's guess. * `remark` was also confirmed absent from a real friend's response even though the doc's worked * example shows `"remark":""` present -- FriendProfileView.tsx's `profile.remark || profile. * display_name` already degrades correctly either way, so this isn't a rendering bug, just a gap * between the doc and what this deployment actually returns right now. */ export interface UserProfile { user_id: string; display_name: string; remark?: string; avatar_file_id?: string; gender?: string; region?: string; public_account?: string; signature?: string; // Same real enum as UserSearchResult's (docs/IM-前后端对接文档.md:1289) -- direction-specific // pending states, never a bare "pending". relationship: 'none' | 'friend' | 'pending_inbound' | 'pending_outbound' | string; topic?: string; capabilities?: { can_send?: boolean; can_call?: boolean; can_add_friend?: boolean }; } /** `identifier` accepts `user_id`, `username`, or `public_account` -- P2P topic names are the peer's * own user_id (Tinode convention), so DetailColumn can pass `topicId` straight through. */ export function getUserProfile(identifier: string) { return imGet(`/users/${encodeURIComponent(identifier)}/profile`); } export interface ConversationSettings { pinned: boolean; mute_notifications: boolean; version: number; } /** P2P-only -- group pin/mute goes through a different, group-specific mechanism (native Tinode private/tags). */ export function getConversationSettings(topic: string) { return imGet(`/conversations/${encodeURIComponent(topic)}/settings`); } /** * P2P-only. Confirmed live (both directions of a real block): `relationship` is `"blocked_by_me"` on * the blocker's own side and `"blocked_by_peer"` on the blocked side -- `can_send`/`capabilities. * can_send_text` are `false` on both. This is the fix for a real, live-reproduced bug (Zentao #28): * sending into a blocked P2P conversation showed only the generic per-bubble "发送失败,点击重试" * with no reason on EITHER side -- reading tinode-sdk source confirmed why a specific reason can * never be recovered after the fact: `Topic#publishMessage`'s own `.catch()` (tinode.dev.js ~line * 4709) swallows the server's rejection into a bare `_failed` flag on the message and never rethrows, * so there's no error object left for application code to read once a send has already failed. This * endpoint is the way around that -- check `can_send` BEFORE attempting to send, not after. * `getUserProfile()`'s own `capabilities.can_send` looked like it might cover this more cheaply (already * fetched elsewhere), but confirmed live it 403s entirely from the blocked party's own side instead of * returning a normal response, so it can't be used for this specific check either. */ export interface ConversationContext { topic: string; peer_user_id: string; display_name: string; can_send: boolean; relationship: 'blocked_by_me' | 'blocked_by_peer' | string; capabilities?: { can_send_text?: boolean; can_send_media?: boolean; can_send_card?: boolean; can_call?: boolean; can_add_friend?: boolean; can_handle_request?: boolean }; } export function getConversationContext(topic: string) { return imGet(`/conversations/${encodeURIComponent(topic)}/context`); } export function updateConversationSettings(topic: string, body: Partial> & { version: number }) { return imPatch(`/conversations/${encodeURIComponent(topic)}/settings`, body); } export function blockUser(userId: string) { return imPut(`/blocks/${encodeURIComponent(userId)}`); } export function unblockUser(userId: string) { return imDelete(`/blocks/${encodeURIComponent(userId)}`); } /** docs/IM-前后端对接文档.md:1380,1431 -- up to 100 ids per call, per-item result in `items[]` (this * backend's usual batch-endpoint shape, see fetchAllPages's own header comment on the class of bug * that comes from assuming a 200 means every item succeeded). */ export function batchUnblockUsers(userIds: string[]) { return imPost<{ items: Array<{ user_id: string; blocked: boolean }>; failed?: Array<{ user_id: string; reason: string }> }>('/blocks/batch-delete', { user_ids: userIds, }); } export interface BlockedUser { user: { user_id: string; display_name?: string; avatar_file_id?: string }; blocked_at: string; } /** Unlike `/friends`, this endpoint nests the user under `.user` (docs/IM-前后端对接文档.md:1379,1428, * both the prose and the JSON example agree, and the old app's own consumer reads `item.user. * display_name` -- not a flat item like Friend). */ export function getBlocks(cursor = '') { return imGet<{ items: BlockedUser[]; next_cursor?: string }>(`/blocks${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`); }