Files
im_webapp/src/lib/im-api.js
T
Your NameandClaude Opus 5 505a6d7d56 补齐第3-4章composer功能缺口:跨话题草稿/@提醒/表情面板/附件面板/名片/收藏发送/按住说话/语音未读红点
第3-4章复测确认这批原型图要求的功能之前完全没做(真实聊天引擎 messages-view.jsx/
send-message.jsx 从Ch1开始一直是最基础的版本:两个附件图标+文本框+发送/麦克风,
没有@、没有表情面板、没有7宫格附件面板、没有名片/收藏发送、语音是点一下进入录音模式
不是按住说话)。这次一次性把8项都接上,除了新功能外,过程中顺带发现并修了4个真bug。

**跨话题草稿**(im-api.js 新增 getTopicDraft/updateTopicDraft,messages-view.jsx)
话题切换时拉取 GET /topics/{topic}/draft(真实验证过P2P用原生话题名——对方user_id——
可以正常读写,接口内部自己会查Subs.Get校验,不需要额外归一化),输入框内容600ms防抖
PUT回去;真正发送成功后立即清空服务端草稿,不等下一次防抖。真实验证:输入文字→切走
再切回→草稿还在;刷新整个页面(新WS会话)→草稿依然从服务端正确恢复。

**@提醒**(send-message.jsx 新增 mention 相关状态和UI,messages-view.jsx 新增
mentionSearch/mentionedUsers/applyMentionResolution)
只在群聊开放,检测"光标前最近一个未闭合的@"触发面板,调 getGroupMembers(topic,
{mentionable:true, query}) 做实时搜索。协议层面:Tinode原生Drafty.parse本来就会把
纯文本里的"@word"自动识别成MN实体,但data.val存的是匹配到的原始文字,不是uid——
真正的uid只有用户从选择器里点选时才知道,记在 messages-view.jsx 的
this.mentionedUsers,发送前重新parse一遍把匹配上的占位符换成真uid。真实验证:选
"kcg051307"发送后直接查后端存储,MN实体的data.val确认是真实uid
"usrYVeV0yZs8tw",不是原始文字。

**表情面板**(send-message.jsx)
2个tab(😊表情/♡常用),常用表情本地localStorage维护。图片没有拍到第二个tab具体
内容,判断"常用"是这类两tab设计里最常见、也是本项目里唯一有本地存储依据能撑起来的
语义。真实验证:点表情正确插入文本框,用过的表情正确出现在"常用"tab。

**7宫格附件面板**(send-message.jsx,替换原来常驻的照片/文件两个图标)
照片/拍摄/语音通话/名片/文件/收藏/视频通话。语音通话、视频通话复用第8章已有的
handleStartAudioCall/handleStartVideoCall(从 tinode-web.jsx 新抽出 canStartCall()
方法,同时替换掉"..."菜单里原来的内联判断,避免同一个判断逻辑两处维护),仅P2P且
真实通话功能可用时才不置灰——真实验证过群聊里这两项正确显示为禁用状态。

**名片消息**(send-message.jsx openCardPicker/sendCard,复用原生TheCard/
Drafty.appendTheCard)
选好友生成名片,uid必须用TheCard.setTinodeID写进vCard的comm数组(不是随便塞进某个
字段——widgets/the-card-mini.jsx渲染时用TheCard.getFirstTinodeID读这个协议项来判断
"发消息"按钮指向谁)。真实验证:选中好友后发出的名片消息真实带"聊天"按钮,点击可用。

**收藏发送到当前会话**(send-message.jsx openFavPicker/sendFavorite,复用Ch7的
forwardFavorites接口)
真实联调中发现并修复一个新bug:这个接口和其它几个批量接口一样,HTTP层恒定200,
真实成败要看items[].status——原来的写法完全没检查这个字段,等于假装总是成功。已改成
检查失败项并报错。同时真实测试还发现该接口对P2P目标会返回200+items[0].status:
"failed", reason:"PERMISSION_DENIED"(哪怕是真实好友、这个会话本身发消息完全正常),
看起来是后端这个接口自己的写权限判断有问题,不是前端能修的,已记录。

**按住说话+上滑取消录音**(send-message.jsx新增手势处理,audio-recorder.jsx新增
holdMode展示态)
mousedown/touchstart直接挂载AudioRecorder(挂载即开始录音,复用原有机制不动),
移动超过80px判定为"松开取消",松手时通过ref直接调组件内部的handleDone/handleDelete。
过程中发现并修复3个真bug:① injectIntl包裹AudioRecorder默认不支持ref转发
("Function components cannot be given refs"警告,ref静默失效),加
{forwardRef:true}修好;② handleDelete从来没有像handleDone那样判空mediaRecorder,
录音还没真正开始(getUserMedia尚未resolve)就点删除会直接抛TypeError,这是原有代码
一直存在的问题,这次因为按住松手可能在录音刚开始的瞬间发生而被真实触发;③
cleanUp()同理没判空this.stream。补上判空后,"从未真正开始录音就结束"这个场景会
直接当作取消处理并正确调用onDeleted,不会再把UI卡在录音态出不来(真实验证:这个
环境的浏览器面板本身会拦截麦克风访问,用这个"getUserMedia永远拒绝"的真实场景反而
完整测出了正常/取消两条路径在录音真正开始前结束时都不再卡死或报错)。

**语音消息未读红点**(chat-message.jsx/audio-player.jsx/formatters.js)
纯客户端概念,原生Tinode的recv/read只到"消息本身有没有被看到",跟"这条语音有没有
真的被点开听过"是两回事。audio-player.jsx的播放按钮新增可选onPlay回调(不传不影响
现有调用方),formatters.js的AU分支透传给它,chat-message.jsx按话题维护一份
localStorage"已播放seq集合",对收到的(非自己发的)语音消息,没播放过就在气泡右上角
显示红点。真实验证:用原生SDK的_routeData往话题本地缓存注入一条真实结构的语音消息
(浏览器面板拦截麦克风,没有真实语音消息可用,用这个方式验证渲染/播放回调/持久化
三段逻辑),红点正确出现;直接触发播放回调后红点消失且localStorage正确写入对应seq。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:59:09 +08:00

739 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Thin REST client for the product-specific `/v1/im/*` API that sits alongside the Tinode WS protocol.
// Endpoint shapes verified directly against the deployed backend (im_team/im_server, server/im_http.go).
import { API_KEY } from '../config.js';
import { PACKAGE_VERSION } from '../version.js';
import LocalStorageUtil from './local-storage.js';
const DEFAULT_API_BASE = '/v1/im';
const DEFAULT_FILE_BASE = '/v0/file';
export class ImApiError extends Error {
constructor(message, reason, status, details) {
super(message);
this.reason = reason;
this.status = status;
this.details = details;
}
}
// docs/IM-前后端对接文档.md §12.4's full reason list. Every existing call site already does
// `catch(err => onToast(err.message || '...'))`, so translating known reasons at the one place the
// error is constructed fixes all of them at once, rather than needing a matching change at every
// individual catch block scattered across the app.
const REASON_MESSAGES = {
TENANT_UNAVAILABLE: '企业码不存在或已停用',
TENANT_MISMATCH: '登录状态异常,请重新进入企业码',
AUTH_FAILED: '账号或密码错误',
TOKEN_EXPIRED: '登录已过期,请重新登录',
ACCOUNT_DISABLED: '账号已被禁用',
RATE_LIMITED: '操作过于频繁,请稍后再试',
VALIDATION_FAILED: '提交的内容不符合要求',
IDEMPOTENCY_CONFLICT: '内容已发生变化,请刷新后重试',
RESOURCE_NOT_FOUND: '内容不存在或已被删除',
FORBIDDEN: '没有权限执行此操作',
FRIEND_RELATION_REQUIRED: '需要先添加对方为好友',
MESSAGE_NOT_ALLOWED: '当前状态无法发送消息',
GROUP_MUTED: '你已被禁言,无法发言',
GROUP_MEMBER_LIMIT_EXCEEDED: '群成员数量已达上限',
TENANT_USER_QUOTA_EXCEEDED: '企业账号数已达上限',
TENANT_GROUP_QUOTA_EXCEEDED: '企业群组数已达上限',
FILE_TOO_LARGE: '文件大小超出限制',
FILE_FORBIDDEN: '没有权限访问此文件',
FILE_UNAVAILABLE: '文件暂不可用',
STORAGE_QUOTA_EXCEEDED: '存储空间已达上限',
REALNAME_REQUIRED: '需要先完成实名认证',
YOUTH_MODE_RESTRICTED: '青少年模式下无法使用此功能',
RISK_CONFIRMATION_REQUIRED: '需要先完成安全确认',
RISK_ACTION_BLOCKED: '该操作已被安全策略拦截',
// 独立密码校验的两个 reason(对接文档 809 行有写),青少年模式关闭/开启走的都是这条接口。
// 原来没收录,报错时直接透传后端英文原文——PIN 输错显示 "code invalid",连续错误锁定后
// 显示 "too many attempts",且不告诉用户要等多久能重试。
CODE_INVALID: '独立密码不正确',
TOO_MANY_ATTEMPTS: details => details && details.retry_after_seconds
? `连续输错次数过多,请 ${details.retry_after_seconds} 秒后重试`
: '连续输错次数过多,请稍后重试'
};
function requestId() {
return (typeof crypto != 'undefined' && crypto.randomUUID) ? crypto.randomUUID() :
'web_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2);
}
// Tenant context persists in localStorage (not sessionStorage): it must survive browser restarts
// the same way the auth-token does, so a returning user's auto-login can still supply `hi.tenant`.
export function getTenantContext() {
return LocalStorageUtil.getObject('im-tenant');
}
export function currentTenantCode() {
const tenant = getTenantContext();
return (tenant && tenant.code) || 'default';
}
function apiBase() {
const tenant = getTenantContext();
return ((tenant && tenant.endpoints && tenant.endpoints.api) || DEFAULT_API_BASE).replace(/\/$/, '');
}
function fileBase() {
const tenant = getTenantContext();
return ((tenant && tenant.endpoints && tenant.endpoints.file) || DEFAULT_FILE_BASE).replace(/\/$/, '');
}
// tinode-web.jsx always sets this on login/reconnect (sessionStorage, tab-lifetime), independent of
// whether the user opted into "keep logged in" (which only controls the separate localStorage token
// used for surviving a browser restart). REST calls need a token for the current session regardless.
function authToken() {
return sessionStorage.getItem('im-session-token') || '';
}
function normalizeResponse(payload) {
return (payload && payload.code === 0 && payload.data !== undefined) ? payload.data : payload;
}
// Core request helper. `auth: false` skips the Authorization header (pre-login calls).
export async function imRequest(path, {method = 'GET', body, headers, auth = true, tenantCodeOverride} = {}) {
const finalHeaders = {
'Content-Type': 'application/json',
'X-MeetingIM-APIKey': API_KEY,
'X-IM-Schema': '1',
'X-Tenant-Code': tenantCodeOverride || currentTenantCode(),
'X-Request-ID': requestId(),
...headers
};
if (auth) {
const token = authToken();
if (token) {
finalHeaders.Authorization = 'Bearer ' + token;
}
}
const response = await fetch(apiBase() + path, {
method,
headers: finalHeaders,
body: body !== undefined ? JSON.stringify(body) : undefined
});
const text = await response.text();
let payload = {};
if (text) {
try {
payload = JSON.parse(text);
} catch (e) {
throw new ImApiError(response.ok ? '接口返回非 JSON 数据' : (response.statusText || '接口请求失败'),
'NON_JSON_RESPONSE', response.status, text.slice(0, 160));
}
}
if (!response.ok || (payload && payload.code && payload.code !== 0)) {
// A known reason always wins over the raw backend message -- gives every caller (they all just do
// `err.message || '...'`) the doc-mandated, consistently-worded text instead of whatever string
// that particular backend handler happened to write, and instead of duplicating this mapping at
// every individual call site. An unrecognized reason still falls back to the backend's own message
// (not silently replaced with a generic string) since this backend's messages have consistently
// been reasonable, specific, business-appropriate text in real testing, not raw internals -- only
// reasons this app doesn't otherwise know how to react to fall through to it.
const entry = payload.reason && REASON_MESSAGES[payload.reason];
const known = typeof entry === 'function' ? entry(payload.details) : entry;
throw new ImApiError(known || payload.message || payload.reason || response.statusText || '请求失败',
payload.reason, response.status, payload.details);
}
return normalizeResponse(payload);
}
const idem = () => ({'Idempotency-Key': requestId()});
export const imGet = (path, opts) => imRequest(path, opts);
export const imPost = (path, body, opts = {}) => imRequest(path, {method: 'POST', body, headers: idem(), ...opts});
export const imPatch = (path, body, opts = {}) => imRequest(path, {method: 'PATCH', body, headers: idem(), ...opts});
export const imPut = (path, body, opts = {}) => imRequest(path, {method: 'PUT', body, headers: idem(), ...opts});
export const imDelete = (path, opts = {}) => imRequest(path, {method: 'DELETE', headers: idem(), ...opts});
function qs(params) {
const usp = new URLSearchParams();
Object.entries(params || {}).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') {
usp.set(k, v);
}
});
const s = usp.toString();
return s ? '?' + s : '';
}
// -- Public uploads (multipart; used before login and for standalone attachments not tied to a chat message). --
export async function uploadPublicFile(file, purpose, onProgress) {
const form = new FormData();
form.append('purpose', purpose);
form.append('file', file);
// Use XHR instead of fetch to get upload progress events.
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', apiBase() + '/public/uploads');
xhr.setRequestHeader('X-MeetingIM-APIKey', API_KEY);
xhr.setRequestHeader('X-IM-Schema', '1');
xhr.setRequestHeader('X-Tenant-Code', currentTenantCode());
xhr.setRequestHeader('X-Request-ID', requestId());
if (onProgress) {
xhr.upload.onprogress = e => onProgress(e.lengthComputable ? e.loaded / e.total : 0);
}
xhr.onload = () => {
let payload = {};
try { payload = JSON.parse(xhr.responseText); } catch (e) { /* handled below */ }
if (xhr.status >= 200 && xhr.status < 300 && payload.code === 0) {
resolve(normalizeResponse(payload));
} else {
reject(new ImApiError(payload.message || payload.reason || '上传失败', payload.reason, xhr.status, payload.details));
}
};
xhr.onerror = () => reject(new ImApiError('网络错误,上传失败', 'NETWORK_ERROR', 0));
xhr.send(form);
});
}
// Native large-file upload (Ch.6 group avatars & group files). uploadPublicFile() above can't be reused --
// its `purpose` is server-whitelisted to exactly 'enterprise_license'/'tenant_entry_feedback'. The vendored
// SDK's own tinode.getLargeFileHelper().upload() talks to the same underlying native endpoint but gets two
// headers wrong for this backend (verified live against the real server, not just read from source):
// it sends 'X-Tinode-APIKey' (server only accepts 'X-MeetingIM-APIKey', same rename Phase 0 already applied
// everywhere else in this file) and 'X-Tinode-Auth' (server's getHttpAuth() doesn't recognize that header
// name at all, only 'X-MeetingIM-Auth' or plain 'Authorization'). The auth *value* must be the native Tinode
// session token (tinode.getAuthToken().token, scheme 'Token') -- the REST layer's own sessionStorage Bearer
// token is a separate namespace and fails this endpoint's base64 secret decode with a 400. Response is
// Tinode's native {ctrl:{code,text,params:{url,expires}}} envelope, not the {code:0,data} REST envelope --
// there's no separate file_id field; the id is the last path segment of `url`, kept verbatim (including its
// extension suffix) since getFileUrl() already round-trips that exact format unmodified.
export function uploadLargeFile(tinode, file, onProgress) {
const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken();
if (!authToken) {
return Promise.reject(new ImApiError('尚未登录,无法上传文件', 'AUTH_REQUIRED', 401));
}
const form = new FormData();
form.append('file', file);
form.set('id', tinode.getNextUniqueId());
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', fileBase() + '/u/');
xhr.setRequestHeader('X-MeetingIM-APIKey', API_KEY);
xhr.setRequestHeader('Authorization', 'Token ' + authToken.token);
if (onProgress) {
xhr.upload.onprogress = e => onProgress(e.lengthComputable ? e.loaded / e.total : 0);
}
xhr.onload = () => {
let payload = {};
try { payload = JSON.parse(xhr.responseText); } catch (e) { /* handled below */ }
const ctrl = payload.ctrl || {};
if (xhr.status >= 200 && xhr.status < 300 && ctrl.params && ctrl.params.url) {
const url = ctrl.params.url;
resolve({fileId: url.slice(url.lastIndexOf('/') + 1), url, expires: ctrl.params.expires});
} else {
reject(new ImApiError(ctrl.text || '上传失败', 'UPLOAD_FAILED', xhr.status));
}
};
xhr.onerror = () => reject(new ImApiError('网络错误,上传失败', 'NETWORK_ERROR', 0));
xhr.send(form);
});
}
// Fixed-headers drop-in replacement for tinode.getLargeFileHelper(), for sending chat attachments
// (messages-view.jsx). The vendored SDK's LargeFileHelper hardcodes the same two wrong headers
// uploadLargeFile() above works around ('X-Tinode-APIKey' instead of 'X-MeetingIM-APIKey', and
// 'X-Tinode-Auth' instead of 'Authorization') -- confirmed live: every image/video/file/voice message
// too large to send inband was silently failing to upload. Can't just call uploadLargeFile() at each
// call site instead, because messages-view.jsx also depends on LargeFileHelper's cancel-in-flight-send
// support (handleCancelUpload) and its urlPromise contract (Drafty.attachFile/insertImage/insertVideo/
// insertAudio expect the resolved value to be a bare URL string, or for video a [url, prevUrl] pair --
// not the {fileId,url,expires} object uploadLargeFile() returns). This mirrors just that subset of the
// real class's interface (upload() returning a bare-URL promise, onProgress, and cancel() aborting any
// still-in-flight XHRs via the same `this.xhr` array pattern the real LargeFileHelper.cancel() uses).
export function createChatUploader(tinode) {
const xhrs = [];
return {
onProgress: null,
upload(file) {
const self = this;
const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken();
if (!authToken) {
return Promise.reject(new ImApiError('尚未登录,无法上传文件', 'AUTH_REQUIRED', 401));
}
return new Promise((resolve, reject) => {
const form = new FormData();
form.append('file', file);
form.set('id', tinode.getNextUniqueId());
const xhr = new XMLHttpRequest();
xhrs.push(xhr);
xhr.open('POST', fileBase() + '/u/');
xhr.setRequestHeader('X-MeetingIM-APIKey', API_KEY);
xhr.setRequestHeader('Authorization', 'Token ' + authToken.token);
xhr.upload.onprogress = e => {
if (e.lengthComputable && self.onProgress) {
self.onProgress(e.loaded / e.total);
}
};
xhr.onload = () => {
let payload = {};
try { payload = JSON.parse(xhr.responseText); } catch (e) { /* handled below */ }
const ctrl = payload.ctrl || {};
if (xhr.status >= 200 && xhr.status < 300 && ctrl.params && ctrl.params.url) {
resolve(ctrl.params.url);
} else {
reject(new ImApiError(ctrl.text || '上传失败', 'UPLOAD_FAILED', xhr.status));
}
};
xhr.onerror = () => reject(new ImApiError('网络错误,上传失败', 'NETWORK_ERROR', 0));
xhr.send(form);
});
},
cancel() {
xhrs.forEach(req => { if (req.readyState < 4) { req.abort(); } });
}
};
}
// Fixed-headers drop-in replacement for tinode.getLargeFileHelper(), for downloading a chat/favorite
// out-of-band attachment (content-library-view.jsx's DownloadPage). Same wrong-header bug as
// createChatUploader() above -- the vendored SDK's LargeFileHelper.download() also hardcodes
// 'X-Tinode-APIKey'/'X-Tinode-Auth', so every real (non-blob:/data:) download was silently failing.
// Mirrors the real class's download()/cancel() interface exactly (including the onProgress/onError
// callback-style signature DownloadPage already calls it with) so the call site needs no other change.
export function createFileDownloader(tinode) {
const xhrs = [];
return {
download(relativeUrl, filename, mimetype, onProgress, onError) {
const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken();
if (!authToken) {
const err = new ImApiError('尚未登录,无法下载文件', 'AUTH_REQUIRED', 401);
if (onError) { onError(err); }
return Promise.reject(err);
}
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhrs.push(xhr);
xhr.open('GET', relativeUrl, true);
xhr.setRequestHeader('X-MeetingIM-APIKey', API_KEY);
xhr.setRequestHeader('Authorization', 'Token ' + authToken.token);
xhr.responseType = 'blob';
xhr.onprogress = e => { if (onProgress) { onProgress(e.loaded); } };
xhr.onload = () => {
if (xhr.status === 200) {
const link = document.createElement('a');
link.href = window.URL.createObjectURL(new Blob([xhr.response], {type: mimetype}));
link.style.display = 'none';
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(link.href);
resolve();
} else {
const reader = new FileReader();
reader.onload = function() {
let err;
try {
const pkt = JSON.parse(this.result);
err = new ImApiError((pkt.ctrl && pkt.ctrl.text) || '下载失败', 'DOWNLOAD_FAILED', xhr.status);
} catch (e) {
err = new ImApiError('下载失败', 'DOWNLOAD_FAILED', xhr.status);
}
if (onError) { onError(err); }
reject(err);
};
reader.readAsText(xhr.response);
}
};
xhr.onerror = () => {
const err = new ImApiError('网络错误,下载失败', 'NETWORK_ERROR', 0);
if (onError) { onError(err); }
reject(err);
};
xhr.send();
});
},
cancel() {
xhrs.forEach(req => { if (req.readyState < 4) { req.abort(); } });
}
};
}
// -- 1.1 Tenant resolution & entry (pre-login, no Authorization header). --
export function resolveTenant(tenantCode) {
return imRequest('/tenants/resolve', {
method: 'POST', auth: false, tenantCodeOverride: tenantCode,
body: {tenant_code: tenantCode, platform: 'web', app_version: PACKAGE_VERSION || '0.25.3'}
});
}
// 企业注册申请 / 入口反馈 / 意见反馈三个接口后端都定义了 client_context 且会入库
// (imTenantApplicationRequest / imTenantEntryFeedbackRequest / imFeedbackRequest,
// server/im_http.go:255/263/367,落库点 :790/:840/:1478),需求文档和对接文档也都要求带上
// 设备与版本信息。前端一直没发。在这里统一注入,省得三处各写一遍、也不会再漏。
function clientContext() {
return {
platform: 'web',
app_version: PACKAGE_VERSION || '',
user_agent: (typeof navigator !== 'undefined' && navigator.userAgent) || '',
language: (typeof navigator !== 'undefined' && navigator.language) || '',
// 网络类型不是所有浏览器都有(Safari/Firefox 没有 navigator.connection),拿不到就不发这一项
network: (typeof navigator !== 'undefined' && navigator.connection && navigator.connection.effectiveType) || undefined
};
}
export function submitTenantApplication(body) {
return imRequest('/tenant-applications', {method: 'POST', auth: false, headers: idem(),
body: {client_context: clientContext(), ...body}});
}
export function submitTenantEntryFeedback(body) {
return imRequest('/tenant-entry-feedback', {method: 'POST', auth: false, headers: idem(),
body: {client_context: clientContext(), ...body}});
}
// -- 1.4/1.5/1.6 Phone verification codes (register / reset_password / security scenes). --
export function requestVerificationCode(scene, phone) {
return imRequest('/auth/verification-codes', {method: 'POST', auth: false, headers: idem(), body: {scene, phone}});
}
export function verifyRegisterCode(challengeId, code) {
return imRequest('/auth/verification-codes/' + encodeURIComponent(challengeId) + '/verify',
{method: 'POST', auth: false, body: {code}});
}
export function verifyPasswordResetCode(challengeId, code) {
return imRequest('/auth/password-reset/challenges/' + encodeURIComponent(challengeId) + '/verify',
{method: 'POST', auth: false, body: {code}});
}
export function confirmPasswordReset(resetToken, newPassword) {
return imRequest('/auth/password-reset/confirm',
{method: 'POST', auth: false, headers: idem(), body: {reset_token: resetToken, new_password: newPassword}});
}
// -- Bootstrap / home. --
export function getBootstrap() {
return imRequest('/bootstrap');
}
export function getMe() {
return imRequest('/me');
}
// -- Realname (1.8/1.9). --
export function getRealnameStatus() {
return imRequest('/security/realname');
}
export function submitRealname(body) {
return imPost('/security/realname/submissions', body);
}
// -- Security challenge (1.7) & independent password (1.11). --
export function createSecurityChallenge(scene) {
return imPost('/security/challenges', {scene, channel: 'sms'});
}
export function verifySecurityChallenge(challengeId, code) {
return imPost('/security/challenges/' + encodeURIComponent(challengeId) + '/verify', {code});
}
export function setIndependentPassword(password, purpose, actionToken) {
return imPost('/settings/independent-password', {password, purpose, action_token: actionToken});
}
// `action` defaults server-side to 'close_youth_mode' if omitted (server/im_http.go
// imIndependentPasswordVerifyRequest) -- pass 'set_independent_password' to mint a token usable
// with enableYouthMode instead, letting an already-set password unlock re-enabling without
// forcing another SMS round-trip through the identity-check flow.
export function verifyIndependentPassword(password, purpose, action) {
return imPost('/settings/independent-password/verify', {password, purpose, action});
}
// -- Friends / contacts (Ch.5). --
export const getFriends = (query = '', cursor = '', initial = '') => imGet('/friends' + qs({query, cursor, initial}));
export const getFriendRequests = (direction = 'inbound', status = 'all', cursor = '') =>
imGet('/friend-requests' + qs({direction, status, cursor}));
export const createFriendRequest = (targetUserId, message = '', source = 'search', sourceTopic = '') =>
imPost('/friend-requests', {target_user_id: targetUserId, message, source, source_topic: sourceTopic});
export const acceptFriendRequest = (requestId_, version) => imPost('/friend-requests/' + encodeURIComponent(requestId_) + '/accept', version ? {version} : {});
export const rejectFriendRequest = (requestId_, version) => imPost('/friend-requests/' + encodeURIComponent(requestId_) + '/reject', version ? {version} : {});
export const deleteFriend = userId => imDelete('/friends/' + encodeURIComponent(userId));
export const updateFriendRemark = (userId, remark) => imPatch('/friends/' + encodeURIComponent(userId), {remark});
export const searchUsers = (query, type = 'account', cursor = '') => imGet('/users/search' + qs({q: query, type, cursor}));
export const getUserProfile = identifier => imGet('/users/' + encodeURIComponent(identifier) + '/profile');
export const createMyQRCode = (expiresInSeconds = 86400) => imPost('/me/qrcodes', {purpose: 'add_friend', expires_in_seconds: expiresInSeconds});
export const createUserQRCode = (identifier, expiresInSeconds = 86400) =>
imPost('/users/' + encodeURIComponent(identifier) + '/qrcodes', {purpose: 'add_friend', expires_in_seconds: expiresInSeconds});
export const resolveQRCode = (token, purpose = 'add_friend') => imPost('/qrcodes/resolve', {token, purpose});
export const getBlocks = (cursor = '') => imGet('/blocks' + qs({cursor}));
export const blockUser = userId => imPut('/blocks/' + encodeURIComponent(userId), {});
export const unblockUser = userId => imDelete('/blocks/' + encodeURIComponent(userId));
export const batchUnblockUsers = userIds => imPost('/blocks/batch-delete', {user_ids: userIds});
// -- Conversations (Ch.2/3). --
export const searchConversations = (query = '', cursor = '') => imGet('/search/conversations' + qs({q: query, cursor}));
export const getSystemNotifications = (cursor = '', limit = 20) => imGet('/system-notifications' + qs({cursor, limit}));
export const markSystemNotificationRead = id => imPost('/system-notifications/' + encodeURIComponent(id) + '/read', {read_at: new Date().toISOString()});
export const getConversationContext = topic => imGet('/conversations/' + encodeURIComponent(topic) + '/context');
export const createP2PConversation = peerUserId => imPost('/conversations/p2p', {peer_user_id: peerUserId});
export const getConversationSettings = topic => imGet('/conversations/' + encodeURIComponent(topic) + '/settings');
export const updateConversationSettings = (topic, body) => imPatch('/conversations/' + encodeURIComponent(topic) + '/settings', body);
// -- Messages (Ch.4/7). --
// version 只能默认传 1:服务端做的是乐观锁校验(tenant_adapter.go:1963 要求 msg.Version 与传入值
// 完全相等,否则 ErrDuplicate),但**没有任何消息读取接口下发过 version 字段**——messagePayload /
// messageSearchPayload / mediaMessagePayload / favoriteSnapshot 全都没有,冲突时的错误里也不回传
// 真实值。所以前端拿不到正确的版本号,被编辑过(version>1)的消息撤回必然失败。这是后端缺口,
// 已记入《IM-后端问题清单.md》,不在前端绕。
export const recallMessage = (topic, seq, version = 1, reason = 'mistake') =>
imPost('/topics/' + encodeURIComponent(topic) + '/messages/' + encodeURIComponent(seq) + '/recall', {version, reason});
export const reviseMessage = (topic, seq, content, version = 1) =>
imPost('/topics/' + encodeURIComponent(topic) + '/messages/' + encodeURIComponent(seq) + '/revisions', {content, client_mid: requestId(), version});
export const getMessageReceipts = (topic, seq, cursor = '') =>
imGet('/topics/' + encodeURIComponent(topic) + '/messages/' + encodeURIComponent(seq) + '/receipts' + qs({cursor}));
// type='all' searches every message type (text/image/video/voice/file/link) -- the backend's own
// default when the param is omitted/empty is "no filter", so defaulting to 'text' here would have
// silently restricted every unqualified call to text-only messages.
export const searchTopicMessages = (topic, {query = '', type = 'all', cursor = ''} = {}) =>
imGet('/topics/' + encodeURIComponent(topic) + '/messages/search' + qs({q: query, type, cursor}));
export const getMessageContext = (topic, seq, before = 20, after = 20) =>
imGet('/topics/' + encodeURIComponent(topic) + '/messages/' + encodeURIComponent(seq) + '/context' + qs({before, after}));
export const getTopicMedia = (topic, type = 'image', cursor = '') =>
imGet('/topics/' + encodeURIComponent(topic) + '/media' + qs({type, cursor}));
export const getForwardTargets = (query = '', cursor = '') => imGet('/forward-targets' + qs({query, cursor}));
export const forwardMessages = (mode, sources, targetTopics, comment = '') =>
imPost('/messages/forward', {mode, sources, target_topics: targetTopics, comment});
export const getForwardBundle = (bundleId, cursor = '') => imGet('/forward-bundles/' + encodeURIComponent(bundleId) + qs({cursor}));
export const favoriteMessages = messages => imPost('/favorites/batch', {messages: messages.map(m => ({...m, seq: String(m.seq)}))});
export const getFavorites = ({type = 'all', query = '', cursor = ''} = {}) => imGet('/favorites' + qs({type, q: query, cursor}));
export const getFavorite = favoriteId => imGet('/favorites/' + encodeURIComponent(favoriteId));
export const deleteFavorite = favoriteId => imDelete('/favorites/' + encodeURIComponent(favoriteId));
export const batchDeleteFavorites = favoriteIds => imPost('/favorites/batch-delete', {favorite_ids: favoriteIds});
export const forwardFavorites = (favoriteIds, targetTopics, comment = '') =>
imPost('/favorites/forward', {favorite_ids: favoriteIds, target_topics: targetTopics, comment});
export const checkURL = (url, source = 'favorite') => imPost('/security/urls/check', {url, source});
export const confirmRiskChallenge = (riskChallengeId, decision = 'continue') =>
imPost('/security/risk-challenges/' + encodeURIComponent(riskChallengeId) + '/confirm', {decision});
export const getMentionableGroupMembers = (topic, query = '', cursor = '') =>
imGet('/groups/' + encodeURIComponent(topic) + '/members' + qs({mentionable: true, query, cursor}));
// 4.1 跨话题草稿:GET 对不存在的草稿返回 {content:'',entities:[],version:0} 而不是 404,PUT 用
// version 做乐观锁(对接文档描述的样子)。真实验证过用原生话题名(P2P 是对方 user_id,不是
// REST 层内部的 p2pXXX id)可以正常读写——这个接口内部会先查 Subs.Get 校验会话存在/有权限,
// 跟 favorites 那条从不做话题归一化、直接 NOT_FOUND 的坏路径不是同一个问题。
export const getTopicDraft = topic => imGet('/topics/' + encodeURIComponent(topic) + '/draft');
export const updateTopicDraft = (topic, content, entities, version) =>
imPut('/topics/' + encodeURIComponent(topic) + '/draft', {content, entities, version});
export async function fetchFileState(fileId, range = '') {
const headers = {
'X-MeetingIM-APIKey': API_KEY, 'X-IM-Schema': '1',
'X-Tenant-Code': currentTenantCode(), 'X-Request-ID': requestId()
};
const token = authToken();
// GET /v0/file/s/{id} is the native Tinode file-serving endpoint, not the /v1/im REST API --
// it's auth-gated by the vendored SDK's auth-handler registry, which is keyed by the literal
// scheme word in the header and only recognizes 'token' (used by uploadLargeFile above), never
// 'bearer'. Sending 'Bearer' here (as imRequest correctly does for actual /v1/im/* calls) gets a
// flat 400 "malformed" from every single call -- confirmed live against the real backend: same
// token value, only the scheme word differs. authToken() itself already holds the right value
// (tinode-web.jsx's handleLoginSuccessful stores tinode.getAuthToken().token verbatim into
// sessionStorage['im-session-token']), so no plumbing change is needed beyond the scheme word.
if (token) {
headers.Authorization = 'Token ' + token;
}
if (range) {
headers.Range = range;
}
const response = await fetch(fileBase() + '/s/' + encodeURIComponent(fileId), {headers});
return {
ok: response.ok, status: response.status,
contentType: response.headers.get('content-type') || '',
contentLength: response.headers.get('content-length') || '',
acceptRanges: response.headers.get('accept-ranges') || '',
etag: response.headers.get('etag') || ''
};
}
export function publicFileBase() {
return fileBase();
}
// File serving (GET /v0/file/s/{id}) is auth-gated -- the upload endpoints never return a directly
// servable URL (publicUploadPayload only has file_id/purpose/mime_type/size/expires_at), so a plain
// <img src> can't be pointed at it (browsers don't attach custom headers to img/video/audio requests).
// This fetches the bytes with the right headers once per file_id and hands back a blob: object URL,
// cached so avatars/attachments repeated across a list (friends, search results, ...) aren't refetched.
const fileUrlCache = new Map();
export function getFileUrl(fileId) {
if (!fileId) {
return Promise.resolve(null);
}
if (fileUrlCache.has(fileId)) {
return fileUrlCache.get(fileId);
}
const headers = {
'X-MeetingIM-APIKey': API_KEY, 'X-IM-Schema': '1',
'X-Tenant-Code': currentTenantCode(), 'X-Request-ID': requestId()
};
const token = authToken();
// Same native-endpoint scheme requirement as fetchFileState() above -- 'Token', not 'Bearer'.
if (token) {
headers.Authorization = 'Token ' + token;
}
const promise = fetch(fileBase() + '/s/' + encodeURIComponent(fileId), {headers}).then(res => {
if (!res.ok) {
throw new ImApiError('文件加载失败', 'FILE_FETCH_FAILED', res.status);
}
return res.blob();
}).then(blob => URL.createObjectURL(blob)).catch(err => {
fileUrlCache.delete(fileId);
throw err;
});
fileUrlCache.set(fileId, promise);
return promise;
}
// -- Groups (Ch.6). --
export const getGroups = ({category = 'joined', query = '', cursor = ''} = {}) => imGet('/groups' + qs({category, query, cursor}));
export const createGroup = ({name, description = '', memberUserIds = [], avatarFileId = ''}) =>
imPost('/groups', {name, description, member_user_ids: memberUserIds, avatar_file_id: avatarFileId});
export const getGroup = topic => imGet('/groups/' + encodeURIComponent(topic));
export const updateGroup = (topic, body) => imPatch('/groups/' + encodeURIComponent(topic), body);
export const getGroupSettings = topic => imGet('/groups/' + encodeURIComponent(topic) + '/settings');
export const updateGroupMemberSettings = (topic, {pinned, mute_notifications}) =>
imPatch('/groups/' + encodeURIComponent(topic) + '/member-settings', {pinned, mute_notifications});
export const updateMyGroupProfile = (topic, nickname, version) =>
imPut('/groups/' + encodeURIComponent(topic) + '/members/me/profile', {nickname, version});
// GET .../members/me/profile is the only endpoint that actually returns the saved nickname --
// confirmed live that GET .../members (getGroupMembers below) never includes group_nickname on any
// item, so anything reading the nickname from that list (as this file's own callers used to) will
// always see it as unset even right after a successful save.
export const getMyGroupProfile = topic => imGet('/groups/' + encodeURIComponent(topic) + '/members/me/profile');
export const leaveGroup = topic => imPost('/groups/' + encodeURIComponent(topic) + '/leave', {});
export const getGroupMembers = (topic, {query = '', cursor = '', role = '', eligibleRole = '', mentionable, muteEligible} = {}) =>
imGet('/groups/' + encodeURIComponent(topic) + '/members' +
qs({query, cursor, role, eligible_role: eligibleRole, mentionable: mentionable ? 'true' : '', mute_eligible: muteEligible ? 'true' : ''}));
export const createGroupQRCode = (topic, expiresInSeconds = 86400) =>
imPost('/groups/' + encodeURIComponent(topic) + '/qrcodes', {expires_in_seconds: expiresInSeconds});
export const inviteGroupMembers = (topic, userIds, message = '') =>
imPost('/groups/' + encodeURIComponent(topic) + '/invitations', {user_ids: userIds, message});
export const acceptGroupInvitation = invitationId => imPost('/group-invitations/' + encodeURIComponent(invitationId) + '/accept', {});
export const createGroupJoinRequest = (topic, {message = '', source = 'qr', qrToken = ''} = {}) =>
imPost('/groups/' + encodeURIComponent(topic) + '/join-requests', {message, source, qr_token: qrToken});
export const getGroupJoinRequests = (topic, status = 'pending', cursor = '') =>
imGet('/groups/' + encodeURIComponent(topic) + '/join-requests' + qs({status, cursor}));
export const acceptGroupJoinRequest = (topic, requestId_, version) =>
imPost('/groups/' + encodeURIComponent(topic) + '/join-requests/' + encodeURIComponent(requestId_) + '/accept', version ? {version} : {});
// Server struct (imGroupJoinRequestActionRequest) only has `Version` -- no reason field exists, and the
// business layer hardcodes an empty reject-reason even internally, so there's nothing to send here. Every
// JSON body goes through decodeIMJSONBody's DisallowUnknownFields(), so including an extra `reason` key
// (as an earlier draft of this function did) makes every single call 400 regardless of its value.
export const rejectGroupJoinRequest = (topic, requestId_, version) =>
imPost('/groups/' + encodeURIComponent(topic) + '/join-requests/' + encodeURIComponent(requestId_) + '/reject', version ? {version} : {});
export const getGroupSummary = topic => imGet('/groups/' + encodeURIComponent(topic) + '/summary');
export const getLatestGroupAnnouncement = topic => imGet('/groups/' + encodeURIComponent(topic) + '/announcements/latest');
export const updateCurrentGroupAnnouncement = (topic, content, version) =>
imPut('/groups/' + encodeURIComponent(topic) + '/announcements/current', {content, version});
export const getGroupAnnouncement = (topic, announcementId) =>
imGet('/groups/' + encodeURIComponent(topic) + '/announcements/' + encodeURIComponent(announcementId));
export const readGroupAnnouncement = (topic, announcementId) =>
imPost('/groups/' + encodeURIComponent(topic) + '/announcements/' + encodeURIComponent(announcementId) + '/read', {});
export const getGroupGovernance = topic => imGet('/groups/' + encodeURIComponent(topic) + '/governance');
export const updateGroupGovernance = (topic, body) => imPatch('/groups/' + encodeURIComponent(topic) + '/governance', body);
export const getGroupRoleLimits = topic => imGet('/groups/' + encodeURIComponent(topic) + '/role-limits');
export const addGroupAdmins = (topic, userIds, version) => imPost('/groups/' + encodeURIComponent(topic) + '/admins/batch', {user_ids: userIds, version});
// Must merge into idem()'s headers, not replace them (imDelete/imPost's `{headers: idem(), ...opts}`
// lets a same-key `opts.headers` clobber the whole object) -- the same Idempotency-Key-goes-missing
// bug this codebase already found and fixed once in disableYouthMode.
export const deleteGroupAdmin = (topic, userId, version) =>
imDelete('/groups/' + encodeURIComponent(topic) + '/admins/' + encodeURIComponent(userId), version ? {headers: {...idem(), 'If-Match': String(version)}} : {});
export const muteGroupMembers = (topic, members, reason = '') => imPost('/groups/' + encodeURIComponent(topic) + '/mutes', {members, reason});
export const getGroupMutes = (topic, cursor = '') => imGet('/groups/' + encodeURIComponent(topic) + '/mutes' + qs({cursor}));
export const unmuteGroupMember = (topic, userId) => imDelete('/groups/' + encodeURIComponent(topic) + '/mutes/' + encodeURIComponent(userId));
export const transferGroupOwner = (topic, targetUserId, version, actionToken = '') =>
imPost('/groups/' + encodeURIComponent(topic) + '/owner-transfer', {target_user_id: targetUserId, version},
actionToken ? {headers: {...idem(), 'X-Action-Token': actionToken}} : {});
// Real backend reads the search text from `q`, not `query` (confirmed against ListFiles's real
// query-param handling, same bug class as getOrganizationMembers) -- the search box in Group Files
// was silently a no-op.
export const getGroupFiles = (topic, {query = '', type = '', cursor = ''} = {}) =>
imGet('/groups/' + encodeURIComponent(topic) + '/files' + qs({q: query, type, cursor}));
// All fields besides file_id are `omitempty` server-side, so omitting them (as an earlier draft of this
// function did) never errors -- it just registers a file with a blank name and 0 size in the files list.
export const addGroupFile = (topic, {fileId, fileName = '', mimeType = '', fileType = '', fileSize = 0, sourceTopic = '', sourceSeq} = {}) =>
imPost('/groups/' + encodeURIComponent(topic) + '/files', {
file_id: fileId, file_name: fileName, mime_type: mimeType, file_type: fileType, file_size: fileSize,
source_topic: sourceTopic, source_seq: sourceSeq
});
export const deleteGroupFile = (topic, groupFileId) => imDelete('/groups/' + encodeURIComponent(topic) + '/files/' + encodeURIComponent(groupFileId));
export const getGroupMemberProfile = (topic, userId) => imGet('/groups/' + encodeURIComponent(topic) + '/members/' + encodeURIComponent(userId) + '/profile');
// -- Organization / workbench (Ch.9). --
export const getWorkbench = () => imGet('/workbench');
export const getOrganizationDepartments = (parentId = 'root', cursor = '') => imGet('/organization/departments' + qs({parent_id: parentId, cursor}));
export const getOrganizationDepartmentMembers = (departmentId, cursor = '') =>
imGet('/organization/departments/' + encodeURIComponent(departmentId) + '/members' + qs({cursor}));
// Real backend only reads `q` (server/im_http.go), never `query` -- confirmed against
// server/im_http_test.go's own request fixtures. This function used to send `query`, silently making
// every caller's search box a no-op (server ignores the unrecognized param and returns the unfiltered
// list every time).
export const getOrganizationMembers = ({query = '', departmentId = '', cursor = ''} = {}) =>
imGet('/organization/members' + qs({q: query, department_id: departmentId, cursor}));
export const searchOrganization = (query = '', types = 'department,user', cursor = '') =>
imGet('/organization/search' + qs({q: query, types, cursor}));
export const getMeetings = (cursor = '', limit = 20) => imGet('/meetings' + qs({limit, cursor}));
export const createMeeting = body => imPost('/meetings', body);
export const getMeeting = meetingId => imGet('/meetings/' + encodeURIComponent(meetingId));
export const updateMeeting = (meetingId, body) => imPatch('/meetings/' + encodeURIComponent(meetingId), body);
export const cancelMeeting = (meetingId, version, reason = '时间冲突') => imPost('/meetings/' + encodeURIComponent(meetingId) + '/cancel', {version, reason});
export const sendMeetingReminders = meetingId => imPost('/meetings/' + encodeURIComponent(meetingId) + '/reminders', {});
export const respondMeeting = (meetingId, response, version) => imPut('/meetings/' + encodeURIComponent(meetingId) + '/my-response', {response, version});
// Note: the server only reads scope/from/to/status/cursor -- 'date' and 'department_id' are
// silently ignored (confirmed against server/im_http.go), so a single-day filter must be
// expressed as from==to, and there is no way to scope this list to one department.
export const getDailyReports = ({scope = 'mine', from = '', to = '', status = '', cursor = ''} = {}) =>
imGet('/reports/daily' + qs({scope, from, to, status, cursor}));
export const createDailyReport = reportDate => imPost('/reports/daily', {report_date: reportDate});
export const getDailyReport = reportId => imGet('/reports/daily/' + encodeURIComponent(reportId));
export const updateDailyDraft = (reportId, content, version, attachments = []) =>
imPut('/reports/daily/' + encodeURIComponent(reportId) + '/draft', {content, attachments, version});
export const submitDailyReport = (reportId, version) => imPost('/reports/daily/' + encodeURIComponent(reportId) + '/submit', {version});
// department_id is also ignored here -- statistics are always tenant-wide.
export const getDailyStatistics = date => imGet('/reports/daily/statistics' + qs({date}));
export const sendDailyReminders = (date, userIds = []) => imPost('/reports/daily/reminders', {date, user_ids: userIds});
export const getMonthlyReports = ({scope = 'mine', fromMonth = '', toMonth = '', status = '', cursor = ''} = {}) =>
imGet('/reports/monthly' + qs({scope, from_month: fromMonth, to_month: toMonth, status, cursor}));
export const createMonthlyReport = month => imPost('/reports/monthly', {month});
export const getMonthlyReport = reportId => imGet('/reports/monthly/' + encodeURIComponent(reportId));
export const updateMonthlyDraft = (reportId, workSummary, nextPlan, version, attachments = []) =>
imPut('/reports/monthly/' + encodeURIComponent(reportId) + '/draft', {work_summary: workSummary, next_plan: nextPlan, attachments, version});
export const submitMonthlyReport = (reportId, version) => imPost('/reports/monthly/' + encodeURIComponent(reportId) + '/submit', {version});
// server/im_http.go's serveIMMonthlyReportReopen decodes only {version} (DisallowUnknownFields
// rejects anything else) -- an earlier draft of this function sent an extra `reason` field that
// would 400 MALFORMED_REQUEST on every real call. Also: the store layer's UPDATE has
// `WHERE ... AND author_id=?` keyed off the *caller's* uid, so this only ever lets a report's own
// author reopen their own already-submitted report for further editing -- there is no
// manager-reopens-someone-else's-report capability on the backend.
export const reopenMonthlyReport = (reportId, version) =>
imPost('/reports/monthly/' + encodeURIComponent(reportId) + '/reopen', {version});
// department_id is ignored here too -- statistics are always tenant-wide.
export const getMonthlyStatistics = month => imGet('/reports/monthly/statistics' + qs({month}));
export const getExecutiveMailboxConfig = () => imGet('/executive-mailbox/config');
export const submitExecutiveMailboxMessage = body => imPost('/executive-mailbox/submissions', body);
// -- Personal settings (Ch.10). --
export const getMeProfile = () => imGet('/me/profile');
export const updateMeProfile = body => imPatch('/me/profile', body);
export const getMePrivacy = () => imGet('/me/privacy');
export const updateMePrivacy = body => imPatch('/me/privacy', body);
export const getYouthMode = () => imGet('/settings/youth-mode');
export const enableYouthMode = (policyVersion, actionToken) => imPost('/settings/youth-mode/enable', {policy_version: policyVersion, action_token: actionToken});
// imPost spreads `opts` last, so passing `headers` directly would silently replace (not merge
// with) the Idempotency-Key header from idem() -- confirmed live: the server actually enforces
// it ("Idempotency-Key is required", 400), unlike the earlier research pass's assumption that a
// missing key here was harmless. Merge explicitly instead of letting the spread clobber it.
export const disableYouthMode = actionToken => imPost('/settings/youth-mode/disable', {}, {headers: {...idem(), 'X-Action-Token': actionToken}});
export const getLatestAppRelease = ({platform = 'android', currentVersion = '1.0.0', channel = 'stable'} = {}) =>
imGet('/app/releases/latest' + qs({platform, current_version: currentVersion, channel}));
export const updateDevicePushToken = (deviceId, body) => imPut('/devices/' + encodeURIComponent(deviceId) + '/push-token', body);
// -- Reports & feedback (Ch.11). --
export const getAbuseReasons = (targetType = 'user') => imGet('/reports/abuse/reasons' + qs({target_type: targetType}));
export const submitAbuseReport = body => imPost('/reports/abuse', body);
export const getFeedbackConfig = () => imGet('/feedback/config');
export const submitFeedback = body => imPost('/feedback', {client_context: clientContext(), ...body});