feat(web): complete groups/contacts/favorites/settings/calls/system domains + extensive review-round fixes
Rounds out the full rewrite with the remaining feature domains: group management (admin panel, mute, files, announcements, QR-based invite/join), contacts (friend profile, add-friend, blocklist, new-friends), favorites (real media rendering across text/image/video/voice/file/link), settings (profile/security/privacy/general/about), calls (Agora-backed audio/video signaling + call history), and system messages/file assistant. Also folds in a large batch of live-verified fixes surfaced by repeated fresh code-review passes and hands-on E2E testing against the real backend, including: WS reconnect not resubscribing any topic after a disconnect, missing x-im-client-mid on every send path (protocol compliance for retry dedup), group mute/member-settings/self-profile endpoint corrections, relationship-state type fixes across search/QR/ friend-profile, tenant-switch logout gap, password-policy now read from the real per-tenant config instead of guessed, NavRail highlight/ aria-current desync, several call-state-machine races, and responsive layout fixes across all four breakpoints (mobile/tablet/laptop/desktop) including a full mobile rebuild of favorites, call UI, and the settings home/sub-list split. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
72b7b79caf
commit
5c3f3b7653
@@ -0,0 +1,20 @@
|
||||
import { imGet } from './client';
|
||||
|
||||
export interface AppRelease {
|
||||
latest_version: string;
|
||||
min_supported_version: string;
|
||||
update_type: 'none' | 'optional' | 'required';
|
||||
release_notes?: string;
|
||||
store_url?: string;
|
||||
package_url?: string;
|
||||
checksum?: string;
|
||||
published_at?: string;
|
||||
}
|
||||
|
||||
/** docs/IM-前后端对接文档.md:2366-2367. `platform` here is `web`, matching this rewrite's own
|
||||
* target -- not confirmed against a real response (the doc only shows ios/android in its own
|
||||
* example), so `update_type` may come back differently for an unrecognized platform value. */
|
||||
export function getLatestRelease(currentVersion: string, platform: 'web' | 'ios' | 'android' = 'web') {
|
||||
const qs = new URLSearchParams({ platform, current_version: currentVersion, channel: 'stable' });
|
||||
return imGet<AppRelease>(`/app/releases/latest?${qs.toString()}`);
|
||||
}
|
||||
@@ -6,13 +6,6 @@ export interface MessagePolicy {
|
||||
recall_window_seconds?: number;
|
||||
}
|
||||
|
||||
export interface RealnameStatus {
|
||||
required: boolean;
|
||||
allow_skip: boolean;
|
||||
remind_after?: string;
|
||||
status: 'unverified' | 'pending' | 'verified' | string;
|
||||
}
|
||||
|
||||
/** README §2.1's "固定置顶两项" -- topic names are fixed/well-known on this backend (confirmed:
|
||||
* docs/IM-前后端对接文档.md:909, `file_assistant.topic:"slf"`, `system_messages.topic:"sys"`), not
|
||||
* per-user values the client has to discover another way. */
|
||||
@@ -29,7 +22,6 @@ export interface BootstrapEntry {
|
||||
export interface BootstrapConfig {
|
||||
message_policy?: MessagePolicy;
|
||||
limits?: { max_forward_targets?: number; [key: string]: unknown };
|
||||
security?: { realname?: RealnameStatus };
|
||||
features?: Record<string, boolean>;
|
||||
permissions?: Record<string, boolean>;
|
||||
modules?: unknown;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Call REST endpoints (docs/IM-前后端对接文档.md §8.12.1, lines 2066-2076). Confirmed live
|
||||
// (2026-09-08): `POST /v1/im/calls/rtc-join` is real and processes requests meaningfully (a probe
|
||||
// with a made-up call_id returned `404 CALL_NOT_FOUND`, not a generic route-404 or a
|
||||
// field-validation error) -- this is the Agora/声网 RTC path the doc's own closing paragraph
|
||||
// describes for "新客户端" (§8.12.1), NOT the raw-WebRTC/SDP-over-{note} path §0.5.4.7 and §8.1-8.11
|
||||
// otherwise describe throughout -- the old app's own real, shipped call code corroborates that the
|
||||
// raw-WebRTC path is the one that's actually broken in production (its own comments say the
|
||||
// call-invite pub is gated behind a feature flag that currently rejects it). The initial invite
|
||||
// still goes through the SAME `{pub}` message as the raw-WebRTC story (confirmed by the doc's own
|
||||
// sentence: "新客户端在发起方收到 ctrl.params.seq 后...分别调用 rtc-join" -- i.e. rtc-join happens
|
||||
// AFTER and IN ADDITION TO the pub-based invite, not instead of it) -- see send.ts's call-invite
|
||||
// helper. Ringing/accept/hang-up stay on Tinode `{note}` (lightweight state signals, not media
|
||||
// negotiation); only the SDP/ICE exchange is replaced by Agora's own SDK doing media transport.
|
||||
import { imGet, imPost } from './client';
|
||||
|
||||
export interface RtcJoinResult {
|
||||
provider: string;
|
||||
auth_mode: string;
|
||||
app_id: string;
|
||||
channel_name: string;
|
||||
uid: string;
|
||||
token: string;
|
||||
token_expires_at: string;
|
||||
call_id: string;
|
||||
topic: string;
|
||||
seq: number;
|
||||
call_type: 'audio' | 'video';
|
||||
role: 'caller' | 'callee';
|
||||
}
|
||||
|
||||
export function joinRtcCall(topic: string, callId: string, seq: number, callType: 'audio' | 'video', role: 'caller' | 'callee') {
|
||||
return imPost<RtcJoinResult>('/calls/rtc-join', { topic, call_id: callId, seq, call_type: callType, role });
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmed live (2026-09-08, real call `75728`) -- completely different from the doc's own minimal
|
||||
* example: `direction` is a string enum (`"outgoing"`/presumably `"incoming"`), not an `incoming`
|
||||
* boolean; there's no `peer` object at all, just a flat `peer_user_id` (a real `usr...` id, NOT the
|
||||
* `p2p...`-hashed form `topic` itself comes back as -- so unlike favorites.ts's `source.topic`, THIS
|
||||
* field can be resolved to a display name via the chat list); and there are four separate
|
||||
* timestamps (`created_at`/`started_at`/`finished_at`/`updated_at`), no single `ts`.
|
||||
*/
|
||||
export interface CallHistoryItem {
|
||||
topic: string;
|
||||
seq: number;
|
||||
call_id: string;
|
||||
call_type: 'audio' | 'video';
|
||||
direction: 'incoming' | 'outgoing' | string;
|
||||
status: string;
|
||||
result?: string;
|
||||
end_reason?: string;
|
||||
duration_ms: number;
|
||||
caller_id: string;
|
||||
callee_id: string;
|
||||
peer_user_id: string;
|
||||
created_at: string;
|
||||
started_at?: string;
|
||||
finished_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export function getCallHistory(cursor = '', limit = 20) {
|
||||
const qs = new URLSearchParams({ limit: String(limit) });
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
return imGet<{ items: CallHistoryItem[]; next_cursor?: string }>(`/calls?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export function getTopicCallHistory(topic: string, cursor = '') {
|
||||
return imGet<{ items: CallHistoryItem[]; next_cursor?: string }>(`/calls/${encodeURIComponent(topic)}${cursor ? `?cursor=${cursor}` : ''}`);
|
||||
}
|
||||
@@ -39,6 +39,14 @@ const REASON_MESSAGES: Record<string, string | ((details: unknown) => string | n
|
||||
IDEMPOTENCY_CONFLICT: '内容已发生变化,请刷新后重试',
|
||||
RESOURCE_NOT_FOUND: '内容不存在或已被删除',
|
||||
PERMISSION_DENIED: '没有权限执行此操作',
|
||||
// docs/IM-前后端对接文档.md:2629's own canonical "must implement" list -- distinct from
|
||||
// PERMISSION_DENIED above (a separately-observed live reason, kept as-is). Missing this one meant
|
||||
// imRequest's fallback chain (`payload.message || payload.reason || ...`) leaked the server's raw
|
||||
// internal message or the bare reason string straight to the user for this exact reason code,
|
||||
// which the doc explicitly says must never happen (§0.4/§12.4: "不得把服务端 message 原样展示给用户").
|
||||
FORBIDDEN: '没有权限执行此操作',
|
||||
// docs/IM-前后端对接文档.md:2312 -- ProfileSettingsPage's birth_date save can hit this.
|
||||
PROFILE_FIELD_INVALID: '资料字段格式不正确,请检查后重试',
|
||||
NOT_FOUND: '内容不存在或已被删除',
|
||||
VERSION_CONFLICT: '内容已被修改,请刷新后重试',
|
||||
MALFORMED_REQUEST: '请求内容有误,请稍后重试',
|
||||
@@ -59,22 +67,28 @@ const REASON_MESSAGES: Record<string, string | ((details: unknown) => string | n
|
||||
GROUP_ADMIN_LIMIT_EXCEEDED: '群管理员数量已达上限',
|
||||
INVALID_CREDIT_CODE: '统一社会信用代码格式不正确',
|
||||
INVALID_EMAIL: '邮箱格式不正确',
|
||||
MAILBOX_UNAVAILABLE: '总经理信箱暂不可用',
|
||||
APPLICATION_DUPLICATED: '该企业已有申请正在审核中,请耐心等待',
|
||||
TOO_MANY_FILES: '文件数量超出限制',
|
||||
FRIEND_RELATION_REQUIRED: '需要先添加对方为好友',
|
||||
MESSAGE_NOT_ALLOWED: '当前状态无法发送消息',
|
||||
GROUP_MUTED: '你已被禁言,无法发言',
|
||||
GROUP_MEMBER_LIMIT_EXCEEDED: '群成员数量已达上限',
|
||||
GROUP_VERSION_CONFLICT: '群信息已被修改,请刷新后重试',
|
||||
INVITATION_EXPIRED: '邀请已过期',
|
||||
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: '该操作已被安全策略拦截',
|
||||
QR_CODE_EXPIRED: '二维码已过期,请对方刷新后重新扫描',
|
||||
QR_CODE_INVALID: '二维码无效,请重新扫描',
|
||||
FRIEND_REQUEST_NOT_ALLOWED: '对方暂不允许添加好友',
|
||||
USER_NOT_DISCOVERABLE: '未找到该用户',
|
||||
REQUEST_ALREADY_HANDLED: '该申请已被处理,请刷新查看最新状态',
|
||||
SEARCH_RATE_LIMITED: '搜索过于频繁,请稍后再试',
|
||||
// Shared by two unrelated verify endpoints (independent-password PIN check and plain SMS
|
||||
// verification-code verify) -- keep generic enough to be accurate for both.
|
||||
CODE_INVALID: '验证码或独立密码不正确',
|
||||
@@ -167,6 +181,11 @@ export async function imRequest<T = unknown>(
|
||||
}
|
||||
if (!response.ok || payload?.code) {
|
||||
const known = translateReason(payload.reason, payload.details);
|
||||
// A 401 on a request that DID carry a Bearer token means the server has stopped honoring that
|
||||
// token (expired/revoked while a WS reconnect or a stale tab kept using it) -- nothing anywhere
|
||||
// else in this client uniformly handled this before, so a session invalidated mid-use just made
|
||||
// every subsequent call fail the same way forever with no path back to a real login.
|
||||
if (auth && response.status === 401 && authToken()) forceReauth();
|
||||
throw new ImApiError(
|
||||
known || payload.message || payload.reason || response.statusText || '请求失败',
|
||||
payload.reason,
|
||||
@@ -177,6 +196,30 @@ export async function imRequest<T = unknown>(
|
||||
return normalizeResponse(payload) as T;
|
||||
}
|
||||
|
||||
/** Clears the invalidated session and hard-navigates to /login -- deliberately a full page load
|
||||
* (not an in-SPA redirect) so every piece of in-memory state (the Tinode singleton, TanStack Query
|
||||
* cache, any open modal's local state) resets from scratch rather than needing each of them to
|
||||
* separately react correctly to "you were logged in a second ago, now you're not." Guarded so a
|
||||
* burst of parallel requests failing at once (a realistic case: several queries in flight when the
|
||||
* token expires) only navigates once. */
|
||||
let reauthTriggered = false;
|
||||
function forceReauth() {
|
||||
if (reauthTriggered) return;
|
||||
sessionStorage.removeItem('im-session-token');
|
||||
useSessionStore.getState().setMyUserId(null);
|
||||
// The latch only needs to survive until the hard reload below actually happens -- that reload is
|
||||
// what resets it (a fresh module load starts with `reauthTriggered = false` again). Setting the
|
||||
// latch UNCONDITIONALLY (the previous version did, before this check) meant a stray/background
|
||||
// request 401-ing while already sitting on `/login` (reachable via an in-SPA `<Navigate>`, which
|
||||
// never reloads) burned the latch with no reload to reset it -- silently disabling this function
|
||||
// for the rest of that tab's life, including a genuine expiry after the user logs back in on the
|
||||
// same SPA instance.
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||
reauthTriggered = true;
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
const idem = () => ({ 'Idempotency-Key': requestId() });
|
||||
|
||||
// `opts.headers` is merged ON TOP of idem() (not replaced by the outer `...opts` spread) so a
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// 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 getFavorite(favoriteId: string) {
|
||||
return imGet<Favorite>(`/favorites/${encodeURIComponent(favoriteId)}`);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
@@ -3,16 +3,26 @@
|
||||
// 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 = '') {
|
||||
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}` : ''}`);
|
||||
@@ -26,15 +36,22 @@ export function updateFriendRemark(userId: string, remark: string) {
|
||||
return imPatch<void>(`/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;
|
||||
relationship: 'none' | 'friend' | 'pending' | 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;
|
||||
action: string;
|
||||
}
|
||||
|
||||
export function searchUsers(query: string, type: 'account' = 'account', cursor = '') {
|
||||
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()}`);
|
||||
@@ -49,6 +66,9 @@ export function sendFriendRequest(targetUserId: string, message?: string, source
|
||||
});
|
||||
}
|
||||
|
||||
/** `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;
|
||||
@@ -56,12 +76,22 @@ export interface FriendRequest {
|
||||
direction: 'inbound' | 'outbound';
|
||||
status: string;
|
||||
message?: string;
|
||||
source?: string;
|
||||
created_at: string;
|
||||
handled_at?: string;
|
||||
peer?: { user_id: string; display_name?: string };
|
||||
topic: string;
|
||||
topic?: string;
|
||||
}
|
||||
|
||||
export function getFriendRequests() {
|
||||
return imGet<{ items: FriendRequest[]; next_cursor?: string }>('/friend-requests');
|
||||
/** 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) {
|
||||
@@ -72,6 +102,14 @@ export function rejectFriendRequest(requestId: string, version?: number) {
|
||||
return imPost<void>(`/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;
|
||||
@@ -81,8 +119,11 @@ export interface UserProfile {
|
||||
region?: string;
|
||||
public_account?: string;
|
||||
signature?: string;
|
||||
relationship: 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
|
||||
@@ -114,6 +155,15 @@ export function unblockUser(userId: string) {
|
||||
return imDelete<void>(`/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;
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
// Group REST endpoints (docs/IM-前后端对接文档.md ch.6). Path/field shapes ported from the old app's
|
||||
// src/lib/im-api.js (lines 686-759), whose own header states they were verified directly against the
|
||||
// deployed backend -- its inline comments flag three real doc/server mismatches this file follows
|
||||
// instead of the doc where they conflict (see each function's own comment):
|
||||
// - reject-join-request has no `reason` field (server 400s if sent, DisallowUnknownFields)
|
||||
// - group-files search param is `q`, not `query`
|
||||
// - addGroupFile needs more fields than the doc's minimal example, or it registers blank metadata
|
||||
// Unlike P2P conversation settings, almost the entire group management surface is REST, not native
|
||||
// Tinode WS -- confirmed against the old app's actually-shipped UI (group-management-view.jsx), which
|
||||
// never calls topic.setMeta({desc}) or {set sub private} for groups despite the protocol doc showing
|
||||
// WS equivalents exist on the wire. Only message send/receive/typing/receipts stay on WS (topics.ts).
|
||||
import { fetchAllPages } from './client';
|
||||
import { imDelete, imGet, imPatch, imPost, imPut } from './client';
|
||||
|
||||
export interface GroupPermissions {
|
||||
can_edit_profile?: boolean;
|
||||
can_invite_members?: boolean;
|
||||
can_manage_members?: boolean;
|
||||
can_manage_settings?: boolean;
|
||||
can_leave?: boolean;
|
||||
can_mention_all?: boolean;
|
||||
}
|
||||
|
||||
export interface GroupSummaryItem {
|
||||
topic: string;
|
||||
name: string;
|
||||
avatar_file_id?: string;
|
||||
member_count: number;
|
||||
my_role: GroupRole;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export function getGroups(category: 'owned' | 'managed' | 'joined' | '' = '', query = '', cursor = '') {
|
||||
const qs = new URLSearchParams();
|
||||
if (category) qs.set('category', category);
|
||||
if (query) qs.set('query', query);
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
const suffix = qs.toString();
|
||||
return imGet<{ items: GroupSummaryItem[]; next_cursor?: string; has_more?: boolean }>(`/groups${suffix ? `?${suffix}` : ''}`);
|
||||
}
|
||||
|
||||
export interface GroupProfile {
|
||||
topic: string;
|
||||
name: string;
|
||||
avatar_file_id?: string;
|
||||
description?: string;
|
||||
owner?: string;
|
||||
member_count: number;
|
||||
version: number;
|
||||
permissions?: GroupPermissions;
|
||||
}
|
||||
|
||||
export function createGroup(body: { name: string; description?: string; member_user_ids: string[]; avatar_file_id?: string }) {
|
||||
return imPost<GroupProfile>('/groups', body);
|
||||
}
|
||||
|
||||
export function getGroup(topic: string) {
|
||||
return imGet<GroupProfile>(`/groups/${encodeURIComponent(topic)}`);
|
||||
}
|
||||
|
||||
export function updateGroup(topic: string, patch: { name?: string; avatar_file_id?: string; description?: string; version: number }) {
|
||||
return imPatch<GroupProfile>(`/groups/${encodeURIComponent(topic)}`, patch);
|
||||
}
|
||||
|
||||
/** Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`): each field's "can I edit this" flag is
|
||||
* a flat `{field}_editable` sibling, NOT a nested `editable: {...}` object as the doc's phrasing
|
||||
* ("每个字段返回 editable") could be read either way -- the doc itself never shows a worked
|
||||
* example for this response, so this was a guess until checked against the real payload. */
|
||||
export interface GroupGovernance {
|
||||
join_approval: boolean;
|
||||
join_approval_editable?: boolean;
|
||||
mute_all: boolean;
|
||||
mute_all_editable?: boolean;
|
||||
member_upload_files: boolean;
|
||||
member_upload_files_editable?: boolean;
|
||||
member_private_chat: boolean;
|
||||
member_private_chat_editable?: boolean;
|
||||
member_invite: boolean;
|
||||
member_invite_editable?: boolean;
|
||||
version: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`) against three original guesses in this
|
||||
* type: there is no `announcement` field at all (the summary endpoint carries that instead, see
|
||||
* GroupDetailColumn.tsx/ConversationView.tsx's own comments); profile fields (`name`, `description`,
|
||||
* `avatar_file_id`, `version`) come back FLAT on this object, not nested under a `profile` key; and
|
||||
* `governance` is the FULL `GroupGovernance` shape (every `*_editable` flag and its own `version`),
|
||||
* not the 3-field `Pick` this originally guessed. `owner`/`message_retention` are real fields this
|
||||
* file hadn't typed at all until this pass.
|
||||
*/
|
||||
export interface GroupSettings {
|
||||
topic: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
avatar_file_id?: string;
|
||||
owner: string;
|
||||
my_role: GroupRole;
|
||||
permissions?: GroupPermissions;
|
||||
member_count: number;
|
||||
pinned: boolean;
|
||||
mute_notifications: boolean;
|
||||
message_retention?: string;
|
||||
governance?: GroupGovernance;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export function getGroupSettings(topic: string) {
|
||||
return imGet<GroupSettings>(`/groups/${encodeURIComponent(topic)}/settings`);
|
||||
}
|
||||
|
||||
export interface GroupMemberSettings {
|
||||
pinned: boolean;
|
||||
mute_notifications: boolean;
|
||||
version: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** No `version` in the request body -- confirmed live 2026-09-09: the backend struct for this
|
||||
* endpoint only declares `pinned`/`mute_notifications`, and strict unknown-field JSON decoding
|
||||
* 400s MALFORMED_REQUEST on anything else, `version` included. This endpoint doesn't do optimistic
|
||||
* locking at all (the server bumps its own stored version unconditionally on every call); the
|
||||
* `version` in the RESPONSE is read-only bookkeeping, not something the client should echo back.
|
||||
* This one bug made every group's 置顶聊天/消息免打扰 toggle unusable, on every account/role tested. */
|
||||
export function updateGroupMemberSettings(topic: string, patch: { pinned?: boolean; mute_notifications?: boolean }) {
|
||||
return imPatch<GroupMemberSettings>(`/groups/${encodeURIComponent(topic)}/member-settings`, patch);
|
||||
}
|
||||
|
||||
export function leaveGroup(topic: string) {
|
||||
return imPost<{ topic: string; status: string }>(`/groups/${encodeURIComponent(topic)}/leave`, {});
|
||||
}
|
||||
|
||||
/** doc-flagged 待实现 (not yet implemented server-side) -- kept typed and ready for when it lands. */
|
||||
export function disbandGroup(topic: string) {
|
||||
return imDelete<{ topic: string; deleted: boolean }>(`/groups/${encodeURIComponent(topic)}`);
|
||||
}
|
||||
|
||||
export type GroupRole = 'owner' | 'admin' | 'member';
|
||||
|
||||
export interface GroupMember {
|
||||
user_id: string;
|
||||
display_name?: string;
|
||||
avatar_file_id?: string;
|
||||
role: GroupRole;
|
||||
mentionable?: boolean;
|
||||
eligible?: boolean;
|
||||
/** Confirmed live (2026-09-08): present when `eligible:false`, e.g. `"owner"` -- not in the
|
||||
* protocol doc's worked examples at all. Not yet surfaced in any UI (the admin/mute/owner-
|
||||
* transfer pickers just filter ineligible candidates out rather than showing them grayed out
|
||||
* with a reason), but real and worth keeping typed since it's cheap and already observed. */
|
||||
ineligible_reason?: string;
|
||||
acs?: { want?: string; given?: string; mode?: string };
|
||||
}
|
||||
|
||||
/** `GET .../members` never returns `group_nickname` on any item (confirmed live by the old app) --
|
||||
* only getGroupMemberProfile() does. Don't read nickname off this list. */
|
||||
export function getGroupMembers(
|
||||
topic: string,
|
||||
opts: { query?: string; cursor?: string; role?: GroupRole; eligibleRole?: 'admin' | 'owner'; mentionable?: boolean; muteEligible?: boolean } = {},
|
||||
) {
|
||||
const qs = new URLSearchParams();
|
||||
if (opts.query) qs.set('query', opts.query);
|
||||
if (opts.cursor) qs.set('cursor', opts.cursor);
|
||||
if (opts.role) qs.set('role', opts.role);
|
||||
if (opts.eligibleRole) qs.set('eligible_role', opts.eligibleRole);
|
||||
if (opts.mentionable) qs.set('mentionable', 'true');
|
||||
if (opts.muteEligible) qs.set('mute_eligible', 'true');
|
||||
const suffix = qs.toString();
|
||||
return imGet<{ items: GroupMember[]; next_cursor?: string; permissions?: { can_mention_all?: boolean } }>(
|
||||
`/groups/${encodeURIComponent(topic)}/members${suffix ? `?${suffix}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getAllGroupMembers(
|
||||
topic: string,
|
||||
opts: { role?: GroupRole; eligibleRole?: 'admin' | 'owner'; mentionable?: boolean; muteEligible?: boolean } = {},
|
||||
) {
|
||||
return fetchAllPages((cursor) => getGroupMembers(topic, { ...opts, cursor }));
|
||||
}
|
||||
|
||||
export interface GroupMemberProfile {
|
||||
topic: string;
|
||||
user_id: string;
|
||||
display_name?: string;
|
||||
group_nickname?: string;
|
||||
role: GroupRole;
|
||||
relationship?: string;
|
||||
capabilities?: { can_send?: boolean; can_call?: boolean };
|
||||
}
|
||||
|
||||
/** `topic` must be the `grp...` id, never a `usr...` id -- confirmed by the protocol doc's own warning.
|
||||
* Do NOT call this with your own user_id: the backend computes a self/other relationship context
|
||||
* for this generic path that errors out when both sides are the same uid (confirmed live 2026-09-09,
|
||||
* stable 400 MALFORMED_REQUEST). Use `getMyGroupProfile()` for your own group nickname instead --
|
||||
* the backend added a dedicated `members/me/profile` route that sidesteps this entirely. */
|
||||
export function getGroupMemberProfile(topic: string, userId: string) {
|
||||
return imGet<GroupMemberProfile>(`/groups/${encodeURIComponent(topic)}/members/${encodeURIComponent(userId)}/profile`);
|
||||
}
|
||||
|
||||
/** Dedicated self-profile route -- unlike getGroupMemberProfile(topic, myUserId), this one actually
|
||||
* works (see that function's own warning) and, as a bonus, returns the real optimistic-lock `version`
|
||||
* on every read, so callers no longer need to remember the last save's version between edits. */
|
||||
export function getMyGroupProfile(topic: string) {
|
||||
return imGet<{ nickname: string; version: number; updated_at: string }>(`/groups/${encodeURIComponent(topic)}/members/me/profile`);
|
||||
}
|
||||
|
||||
export function updateMyGroupNickname(topic: string, nickname: string, version: number) {
|
||||
return imPut<{ nickname: string; version: number; updated_at: string }>(`/groups/${encodeURIComponent(topic)}/members/me/profile`, {
|
||||
nickname,
|
||||
version,
|
||||
});
|
||||
}
|
||||
|
||||
export function getGroupRoleLimits(topic: string) {
|
||||
return imGet<{ max_admin_count: number; current_admin_count: number }>(`/groups/${encodeURIComponent(topic)}/role-limits`);
|
||||
}
|
||||
|
||||
export function addGroupAdmins(topic: string, userIds: string[], version: number) {
|
||||
return imPost<{
|
||||
succeeded: Array<{ user_id: string; role: GroupRole }>;
|
||||
failed?: Array<{ user_id: string; reason: string }>;
|
||||
admins: GroupMember[];
|
||||
version: number;
|
||||
}>(`/groups/${encodeURIComponent(topic)}/admins/batch`, { user_ids: userIds, version });
|
||||
}
|
||||
|
||||
export function removeGroupAdmin(topic: string, userId: string, version: number) {
|
||||
return imDelete<{ user_id: string; role: GroupRole; updated_at: string }>(
|
||||
`/groups/${encodeURIComponent(topic)}/admins/${encodeURIComponent(userId)}`,
|
||||
{ headers: { 'If-Match': String(version) } },
|
||||
);
|
||||
}
|
||||
|
||||
export interface GroupMute {
|
||||
user: { user_id: string; display_name?: string };
|
||||
muted_by?: string;
|
||||
muted_at: string;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export function muteGroupMembers(topic: string, members: Array<{ user_id: string; expires_at: string | null }>, reason?: string) {
|
||||
// NOT `/mutes/batch` -- confirmed against the real backend (im_be/server/im_http.go's routing
|
||||
// switch has no such case, so that path 405s unconditionally) -- the real batch-mute endpoint is
|
||||
// plain `POST .../mutes` (the same base path `getGroupMutes` GETs from), body shape unchanged.
|
||||
return imPost<{ items: Array<{ user_id: string; muted_by: string; muted_at: string; expires_at?: string; reason?: string; version: number }> }>(
|
||||
`/groups/${encodeURIComponent(topic)}/mutes`,
|
||||
{ members, reason },
|
||||
);
|
||||
}
|
||||
|
||||
export function getGroupMutes(topic: string, cursor = '') {
|
||||
return imGet<{ items: GroupMute[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/mutes${cursor ? `?cursor=${cursor}` : ''}`);
|
||||
}
|
||||
|
||||
export function unmuteGroupMember(topic: string, userId: string) {
|
||||
return imDelete<{ user_id: string; muted: boolean }>(`/groups/${encodeURIComponent(topic)}/mutes/${encodeURIComponent(userId)}`);
|
||||
}
|
||||
|
||||
/** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.26) -- not yet
|
||||
* wired since no risk-challenge flow is built anywhere in this app yet; calling this will 409 until
|
||||
* that's added. Kept typed so the group-management UI can be built ahead of that. */
|
||||
export function transferGroupOwner(topic: string, targetUserId: string, version: number) {
|
||||
return imPost<{ owner: string; previous_owner: string; version: number }>(`/groups/${encodeURIComponent(topic)}/owner-transfer`, {
|
||||
target_user_id: targetUserId,
|
||||
version,
|
||||
});
|
||||
}
|
||||
|
||||
export function getGroupGovernance(topic: string) {
|
||||
return imGet<GroupGovernance>(`/groups/${encodeURIComponent(topic)}/governance`);
|
||||
}
|
||||
|
||||
export function updateGroupGovernance(
|
||||
topic: string,
|
||||
patch: Partial<Pick<GroupGovernance, 'join_approval' | 'mute_all' | 'member_upload_files' | 'member_private_chat' | 'member_invite'>> & { version: number },
|
||||
) {
|
||||
return imPatch<GroupGovernance>(`/groups/${encodeURIComponent(topic)}/governance`, patch);
|
||||
}
|
||||
|
||||
export function inviteGroupMembers(topic: string, userIds: string[], message?: string) {
|
||||
return imPost<{ items: Array<{ user_id: string; status: string; invitation_id?: string }> }>(`/groups/${encodeURIComponent(topic)}/invitations`, {
|
||||
user_ids: userIds,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
/** Confirmed LIVE (2026-09-09) this does NOT match either the protocol doc's own worked example
|
||||
* (`docs/IM-前后端对接文档.md:1741`, which shows a nested `applicant:{user_id,display_name}`
|
||||
* object) or this file's own previous type -- the real response is a FLAT `user_id` string with no
|
||||
* display name anywhere on it at all, plus `inviter_id`/`message`/`handled_by` fields the doc's
|
||||
* example doesn't show. Reading `req.applicant.user_id` against this real shape would throw
|
||||
* (`applicant` is `undefined`) the first time this modal ever rendered a real request -- caught by
|
||||
* live-calling `getGroupJoinRequests` directly, not by reading the doc or the mock. */
|
||||
export interface GroupJoinRequest {
|
||||
request_id: string;
|
||||
user_id: string;
|
||||
/** Non-empty only when `source` reflects an invite (mock's "王工 邀请" rows) -- empty string for
|
||||
* a self-initiated request (search/QR). */
|
||||
inviter_id: string;
|
||||
/** The applicant's own join message, if any (mock: `"我是新来的测试同学"`). */
|
||||
message?: string;
|
||||
status: string;
|
||||
source: string;
|
||||
created_at: string;
|
||||
handled_at?: string;
|
||||
handled_by?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
/** docs/IM-前后端对接文档.md:1591,1740 -- submitting a join request (the requester's own side; every
|
||||
* other function in this block is the admin's reviewing side). `source` and `qr_token` are only
|
||||
* relevant for the QR-scan path (GroupQRCodeModal/QRScannerModal's `join_group` purpose) -- a plain
|
||||
* "request to join" with no QR code involved would omit both, though this app doesn't have another
|
||||
* UI entry point that reaches a group's join-requests without already being a member (no public
|
||||
* group directory/search exists anywhere in this codebase or its design handoff). */
|
||||
export function submitGroupJoinRequest(topic: string, body: { message?: string; source?: 'qr' | string; qr_token?: string } = {}) {
|
||||
return imPost<{ request_id: string; topic: string; status: string; created_at: string; version: number }>(
|
||||
`/groups/${encodeURIComponent(topic)}/join-requests`,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
export function getGroupJoinRequests(topic: string, status: 'pending' | 'all' = 'pending', cursor = '') {
|
||||
const qs = new URLSearchParams({ status });
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
return imGet<{ items: GroupJoinRequest[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/join-requests?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export function acceptGroupJoinRequest(topic: string, requestId: string, version: number) {
|
||||
return imPost<{ request_id: string; status: string; version: number }>(
|
||||
`/groups/${encodeURIComponent(topic)}/join-requests/${encodeURIComponent(requestId)}/accept`,
|
||||
{ version },
|
||||
);
|
||||
}
|
||||
|
||||
/** Body is `{version}` ONLY -- no `reason` field. The protocol doc's own example shows a `reason`
|
||||
* key, but the old app's im-api.js confirmed live that the real server struct doesn't have one and
|
||||
* every request body is validated with DisallowUnknownFields(), so sending `reason` 400s the call. */
|
||||
export function rejectGroupJoinRequest(topic: string, requestId: string, version: number) {
|
||||
return imPost<{ request_id: string; status: string; version: number }>(
|
||||
`/groups/${encodeURIComponent(topic)}/join-requests/${encodeURIComponent(requestId)}/reject`,
|
||||
{ version },
|
||||
);
|
||||
}
|
||||
|
||||
/** Confirmed live: `announcement` is always a present object, even with nothing published --
|
||||
* every field just comes back zero-valued (`content:""` etc), never `null`. Check `content` for
|
||||
* emptiness, not object existence -- same gotcha as `getLatestGroupAnnouncement` below. */
|
||||
export function getGroupSummary(topic: string) {
|
||||
return imGet<{
|
||||
pending_join_request_count: number;
|
||||
announcement?: { announcement_id: string; content: string; author: string; published_at: string; version: number } | null;
|
||||
permissions?: GroupPermissions;
|
||||
}>(`/groups/${encodeURIComponent(topic)}/summary`);
|
||||
}
|
||||
|
||||
/** Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`): `author` is a flat `usr...` id, not
|
||||
* the `published_by` object this file originally guessed -- resolve it against the group's member
|
||||
* list for a display name/role, same as any other bare user id in this API. `read_count`/
|
||||
* `member_count` are confirmed ABSENT from the real response (not just unconfirmed), and when
|
||||
* nothing has been published yet the endpoint still returns `200` with every field zero-valued
|
||||
* (`announcement_id:"", author:"", content:"", published_at:"", version:0`), never `404`/`null` --
|
||||
* callers must check `content` for emptiness, not object existence. */
|
||||
export interface GroupAnnouncement {
|
||||
announcement_id: string;
|
||||
topic: string;
|
||||
content: string;
|
||||
version: number;
|
||||
published_at: string;
|
||||
author: string;
|
||||
permissions?: { can_edit?: boolean };
|
||||
}
|
||||
|
||||
export function getLatestGroupAnnouncement(topic: string) {
|
||||
return imGet<GroupAnnouncement>(`/groups/${encodeURIComponent(topic)}/announcements/latest`);
|
||||
}
|
||||
|
||||
export function updateCurrentGroupAnnouncement(topic: string, content: string, version: number) {
|
||||
return imPut<GroupAnnouncement>(`/groups/${encodeURIComponent(topic)}/announcements/current`, { content, version });
|
||||
}
|
||||
|
||||
export function readGroupAnnouncement(topic: string, announcementId: string) {
|
||||
return imPost<{ announcement_id: string; read_at: string }>(
|
||||
`/groups/${encodeURIComponent(topic)}/announcements/${encodeURIComponent(announcementId)}/read`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
/** Confirmed live (2026-09-08, real registered file `gfile_PNof3SOlWz0`): fields are FLAT
|
||||
* `file_name`/`file_size`/`mime_type` (not the doc-adjacent `name`/`size`/`mime` this type
|
||||
* previously declared -- undetected until now because this list only ever rendered its own
|
||||
* `暂无群文件` empty state, never a real row), `uploader_id` is a bare string (no nested `uploader`
|
||||
* object, no `display_name` alongside it), and there is no `permissions` object at all -- gating a
|
||||
* delete affordance needs a different signal (see GroupFilesModal's own comment on how it copes). */
|
||||
export interface GroupFile {
|
||||
group_file_id: string;
|
||||
file_id: string;
|
||||
file_name: string;
|
||||
file_size: number;
|
||||
mime_type: string;
|
||||
uploader_id?: string;
|
||||
created_at: string;
|
||||
download_state?: string;
|
||||
}
|
||||
|
||||
/** Search param is `q`, not `query` as the protocol doc's own example URL shows -- confirmed live by
|
||||
* the old app that the real server reads `q` and silently no-ops a `query` param instead. */
|
||||
export function getGroupFiles(topic: string, q = '', type = '', cursor = '') {
|
||||
const qs = new URLSearchParams();
|
||||
if (q) qs.set('q', q);
|
||||
if (type) qs.set('type', type);
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
const suffix = qs.toString();
|
||||
return imGet<{ items: GroupFile[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/files${suffix ? `?${suffix}` : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The doc's minimal `{file_id}` body registers a file with blank name/0 size, since every other
|
||||
* field is server-side `omitempty` -- confirmed live by the old app, which always sends the full
|
||||
* set below.
|
||||
*/
|
||||
export function addGroupFile(
|
||||
topic: string,
|
||||
file: { file_id: string; file_name: string; mime_type: string; file_type: string; file_size: number; source_topic?: string; source_seq?: number },
|
||||
) {
|
||||
return imPost<GroupFile>(`/groups/${encodeURIComponent(topic)}/files`, file);
|
||||
}
|
||||
|
||||
export function deleteGroupFile(topic: string, groupFileId: string) {
|
||||
return imDelete<void>(`/groups/${encodeURIComponent(topic)}/files/${encodeURIComponent(groupFileId)}`);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
// 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 } from './client';
|
||||
|
||||
export interface RecallResult {
|
||||
@@ -57,7 +59,12 @@ export interface SearchHit {
|
||||
matches?: Array<{ start: number; end: number }>;
|
||||
snippet?: string;
|
||||
snapshot?: {
|
||||
content?: { txt?: string; fmt?: unknown[]; ent?: DraftyEntity[] };
|
||||
// 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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// System notifications REST endpoints (docs/IM-前后端对接文档.md:890-912). Confirmed live field
|
||||
// facts (via a research pass over docs/IM-后端问题清单*.md, 问题30/新增32/33) that diverge from
|
||||
// this doc's own worked example -- see each item's comment below.
|
||||
import { imGet, imPost } from './client';
|
||||
|
||||
export interface SystemNotification {
|
||||
id: string;
|
||||
/** Real values seen include the `friend.*`/`group.*`/`message.*`/`meeting.*`/`security.risk.*`
|
||||
* family from the WS business-event table (doc lines 408-420) -- route on this, not `action.type`
|
||||
* (see below). */
|
||||
event_type: string;
|
||||
title: string;
|
||||
/** Confirmed live (问题30): this is sometimes a raw machine code (`"accepted"`,
|
||||
* `"security.risk.cancel"`) instead of human text, and can be empty. Never render blindly --
|
||||
* always run through NOTIFICATION_TEXT first. */
|
||||
summary: string;
|
||||
/**
|
||||
* Confirmed live (问题30) the shape varies by source and does NOT reliably match this doc's own
|
||||
* worked example (`{"type":"friend_request","request_id":"frq_1"}`) -- a real friend-request
|
||||
* notification's `action` was just `{"user_id":"usr..."}`, no `type` key at all. Group events:
|
||||
* `{topic}`. Meeting events: inconsistently either `{module,resource_id}` or `{meeting_id}`.
|
||||
* New-device-login's documented `{device_name,platform,ip_masked,login_at,security_action}`
|
||||
* shape (doc line 891) is confirmed NOT IMPLEMENTED server-side at all (新增12, still open) --
|
||||
* never assume these fields are populated.
|
||||
*/
|
||||
action?: {
|
||||
type?: string;
|
||||
request_id?: string;
|
||||
topic?: string;
|
||||
user_id?: string;
|
||||
device?: string;
|
||||
ip?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
read_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function getSystemNotifications(cursor = '', limit = 20) {
|
||||
const qs = new URLSearchParams({ limit: String(limit) });
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
return imGet<{ items: SystemNotification[]; next_cursor?: string }>(`/system-notifications?${qs.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmed live (新增32, still open): marking an ALREADY-read notification echoes back whatever
|
||||
* `read_at` you send as if it succeeded, but the stored value stays frozen at the first real write
|
||||
* -- the response lies about updating it. Never treat this response's `read_at` as authoritative;
|
||||
* only use it to flip local `read_at` truthy/falsy, matching the old app's own workaround.
|
||||
*/
|
||||
export function markSystemNotificationRead(id: string, readAt = new Date().toISOString()) {
|
||||
return imPost<{ status: string; id: string; read_at: string }>(`/system-notifications/${encodeURIComponent(id)}/read`, { read_at: readAt });
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Own-profile/privacy REST endpoints (docs/IM-前后端对接文档.md ch.10, lines 2281-2397). Confirmed
|
||||
// against the doc's own worked examples (table lines 2490-2494) and, where the doc has no example
|
||||
// at all, against the rewritten old app's real working code (im_webapp/src/views/personal-settings-
|
||||
// view.jsx) -- flagged per-field below.
|
||||
import { imGet, imPatch } from './client';
|
||||
|
||||
/** `GET /v1/im/me` -- thin summary for a home/header card. Distinct from `getMyProfile` below (the
|
||||
* full profile-edit source) -- confirmed live these are two separate endpoints with different
|
||||
* field sets, not one response read two ways. */
|
||||
export interface MeSummary {
|
||||
user_id: string;
|
||||
profile: { public_account: string; nickname: string; avatar_file_id?: string; signature?: string; version: number };
|
||||
profile_version: number;
|
||||
}
|
||||
|
||||
export function getMe() {
|
||||
return imGet<MeSummary>('/me');
|
||||
}
|
||||
|
||||
export interface MyProfile {
|
||||
avatar_file_id?: string;
|
||||
nickname: string;
|
||||
birth_date?: string;
|
||||
signature?: string;
|
||||
gender?: 'male' | 'female' | 'other' | string;
|
||||
region?: string;
|
||||
email?: string;
|
||||
public_account: string;
|
||||
version: number;
|
||||
field_rules?: { nickname?: { min_length: number; max_length: number }; signature?: { max_length: number } };
|
||||
}
|
||||
|
||||
export function getMyProfile() {
|
||||
return imGet<MyProfile>('/me/profile');
|
||||
}
|
||||
|
||||
/** doc.md:2492 only shows nickname/signature in its worked example; gender/region have no doc
|
||||
* example at all but the old app's real, working code PATCHes them the identical way (`gender`
|
||||
* enum confirmed `male|female|other`, personal-settings-view.jsx:214). */
|
||||
export function updateMyProfile(patch: Partial<Pick<MyProfile, 'nickname' | 'birth_date' | 'signature' | 'gender' | 'region' | 'avatar_file_id' | 'email'>> & { version: number }) {
|
||||
return imPatch<MyProfile>('/me/profile', patch);
|
||||
}
|
||||
|
||||
export interface AddMeMethods {
|
||||
account: boolean;
|
||||
phone: boolean;
|
||||
qr: boolean;
|
||||
group: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* `profile_visibility`/`last_seen_visibility` enum confirmed as `everyone|friends|nobody` only via
|
||||
* the old app's own code comment (the doc's worked examples only ever show `"friends"`) -- the
|
||||
* server's validator 400s `contacts`/`hidden` outright, don't offer those as options.
|
||||
*
|
||||
* There is NO "加我为好友时需要验证" field anywhere in this response -- confirmed absent both in
|
||||
* the doc and in the old app's own `UserPrivacySettings` research (personal-settings-view.jsx:534-
|
||||
* 538: the design reference shows a control for it, but neither the response shape nor the group-
|
||||
* invite backend logic has anything to gate). Don't build that toggle.
|
||||
*/
|
||||
export interface PrivacySettings {
|
||||
add_me_methods: AddMeMethods;
|
||||
profile_visibility: 'everyone' | 'friends' | 'nobody' | string;
|
||||
last_seen_visibility: 'everyone' | 'friends' | 'nobody' | string;
|
||||
read_receipts: boolean;
|
||||
version: number;
|
||||
editable?: Partial<Record<keyof PrivacySettings, boolean>>;
|
||||
}
|
||||
|
||||
export function getMyPrivacy() {
|
||||
return imGet<PrivacySettings>('/me/privacy');
|
||||
}
|
||||
|
||||
export function updateMyPrivacy(
|
||||
patch: Partial<{ add_me_methods: Partial<AddMeMethods>; profile_visibility: string; last_seen_visibility: string; read_receipts: boolean }> & {
|
||||
version: number;
|
||||
},
|
||||
) {
|
||||
return imPatch<PrivacySettings>('/me/privacy', patch);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// QR code REST endpoints (docs/IM-前后端对接文档.md §5.15.1). Confirmed LIVE against the real
|
||||
// deployment (2026-09-08, two fresh test accounts) -- `resolveQRCode`'s real response shape nests
|
||||
// `relationship`/`can_add`/`action`/`public_account` all under `user`, unlike the doc's own worked
|
||||
// example which shows them as siblings of `user` at the top level. Also confirmed live: resolving a
|
||||
// code against the SAME account that minted it is rejected (`400 MALFORMED_REQUEST`, `field:"token"`,
|
||||
// message "malformed request" -- distinct from a genuinely garbage token, which instead gets message
|
||||
// "qr code invalid" with no `details.field` at all) -- cross-account resolution works correctly.
|
||||
//
|
||||
// The security rationale for why this exists at all instead of just encoding a raw user id (doc line
|
||||
// 1299): "二维码只含不透明、签名、带租户和用途约束的短期 Token,不直接放手机号、用户 ID 或登录
|
||||
// Token" -- the QR image payload is `qr_payload` (an `im://qr?token=...&purpose=...` URI), not
|
||||
// `qr_token` alone, matching the old app's own `im://qr` scheme (im_webapp/src/views/tinode-web.jsx
|
||||
// confirmed this exact prefix check before ever sending a scanned string to `resolve`).
|
||||
import { imPost } from './client';
|
||||
|
||||
export interface QRCodeDisplayProfile {
|
||||
user_id: string;
|
||||
display_name: string;
|
||||
public_account?: string;
|
||||
}
|
||||
|
||||
export interface MyQRCode {
|
||||
qr_token: string;
|
||||
/** What actually gets encoded into the QR image -- `im://qr?token=...&purpose=...`. */
|
||||
qr_payload: string;
|
||||
expires_at: string;
|
||||
display_profile: QRCodeDisplayProfile;
|
||||
}
|
||||
|
||||
export function createMyQRCode(expiresInSeconds = 86400) {
|
||||
return imPost<MyQRCode>('/me/qrcodes', { purpose: 'add_friend', expires_in_seconds: expiresInSeconds });
|
||||
}
|
||||
|
||||
/** `identifier`: user_id/username/public_account -- viewing a specific friend's own QR card. */
|
||||
export function createUserQRCode(identifier: string, expiresInSeconds = 86400) {
|
||||
return imPost<MyQRCode>(`/users/${encodeURIComponent(identifier)}/qrcodes`, { purpose: 'add_friend', expires_in_seconds: expiresInSeconds });
|
||||
}
|
||||
|
||||
export interface QRResolveUser {
|
||||
user_id: string;
|
||||
display_name: string;
|
||||
public_account?: string;
|
||||
// docs/IM-前后端对接文档.md:1289 -- same real direction-specific enum as friends.ts's
|
||||
// UserSearchResult/UserProfile, never a bare "pending".
|
||||
relationship: 'none' | 'friend' | 'pending_inbound' | 'pending_outbound' | string;
|
||||
can_add: boolean;
|
||||
action: string;
|
||||
}
|
||||
|
||||
/** Confirmed LIVE (2026-09-08, same account that minted the code -- a real cross-account "scan a
|
||||
* stranger's group code" resolve hasn't been exercised, this session only had one account free):
|
||||
* a `purpose:"join_group"` resolve nests the group under `group` (NOT `group_snapshot`, despite
|
||||
* the mint response using that key -- resolve and mint use different field names for the same
|
||||
* shape), and carries a `status` this specific case's doc example (line 1737, mint-only) never
|
||||
* showed: resolving your own group's code (i.e. already a member) comes back `status:"joined"`.
|
||||
* What a genuinely-not-yet-a-member resolve returns for `status` is unconfirmed -- treated as
|
||||
* "anything other than 'joined'" in QRScannerModal/AddFriendModal, i.e. the general case that still
|
||||
* needs a real join-request submission. */
|
||||
export interface QRResolveGroup {
|
||||
topic: string;
|
||||
name: string;
|
||||
member_count: number;
|
||||
}
|
||||
|
||||
export interface QRResolveResult {
|
||||
purpose: 'add_friend' | 'join_group' | string;
|
||||
source: string;
|
||||
source_topic: string;
|
||||
expires_at: string;
|
||||
user?: QRResolveUser;
|
||||
group?: QRResolveGroup;
|
||||
group_snapshot?: QRResolveGroup;
|
||||
/** join_group only -- `"joined"` means the scanning account is already a member (confirmed live);
|
||||
* any other value (including absent) is treated as "not a member yet, submit a join request". */
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export function resolveQRCode(token: string, purpose: 'add_friend' | 'join_group' | string = 'add_friend') {
|
||||
return imPost<QRResolveResult>('/qrcodes/resolve', { token, purpose });
|
||||
}
|
||||
|
||||
/** docs/IM-前后端对接文档.md:1590 -- 群管理员/群主展示的入群二维码 (mirrors createMyQRCode/
|
||||
* createUserQRCode's shape, just scoped to a group and carrying `group_snapshot` instead of a
|
||||
* personal `display_profile`). */
|
||||
export interface GroupQRCode {
|
||||
qr_token: string;
|
||||
qr_payload: string;
|
||||
expires_at: string;
|
||||
group_snapshot: QRResolveGroup;
|
||||
}
|
||||
|
||||
export function createGroupQRCode(topic: string, expiresInSeconds = 86400) {
|
||||
return imPost<GroupQRCode>(`/groups/${encodeURIComponent(topic)}/qrcodes`, { expires_in_seconds: expiresInSeconds });
|
||||
}
|
||||
@@ -12,11 +12,16 @@ import { API_KEY } from '@/lib/config';
|
||||
import { authToken, fileBase } from './client';
|
||||
import { getTinode } from '../tinode/connection';
|
||||
|
||||
/** Confirmed live: the real `ctrl.params` shape is `{file_id, url, expires}` -- no `name`/`size`/
|
||||
* `mime` at all. Every existing caller (avatar upload, chat attachments) only ever reads `.url`, so
|
||||
* this went unnoticed until a caller needing the other three (GroupFilesModal's addGroupFile
|
||||
* registration) actually tried to read them and got `undefined` silently sent as `file_name`. Callers
|
||||
* that need name/size/mime must read them off the original `File` object they already have, not
|
||||
* expect the upload response to echo them back. */
|
||||
export interface UploadedFile {
|
||||
url: string;
|
||||
name: string;
|
||||
size: number;
|
||||
mime: string;
|
||||
file_id: string;
|
||||
expires: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { IAgoraRTCClient, ICameraVideoTrack, IMicrophoneAudioTrack, IRemoteVideoTrack } from 'agora-rtc-sdk-ng';
|
||||
|
||||
import type { RtcJoinResult } from '@/lib/api/calls';
|
||||
|
||||
/**
|
||||
* Thin wrapper around the Agora Web SDK -- this app's actual media transport (see lib/api/calls.ts's
|
||||
* header comment for why: confirmed live that `POST /v1/im/calls/rtc-join` is the real, working
|
||||
* path, not raw WebRTC/SDP-over-Tinode-`{note}`). Kept as module-level state, not Zustand -- an
|
||||
* `IAgoraRTCClient`/media-track instance isn't meaningful React state (nothing should re-render
|
||||
* because a client object's internal fields changed), and playing a video track is an imperative
|
||||
* `track.play(domElement)` call, not a declarative prop.
|
||||
*
|
||||
* The SDK itself (~1.6MB) is dynamically imported inside `joinCall`, not at module top level --
|
||||
* loading it eagerly would put a large media SDK in every page's initial bundle even though most
|
||||
* sessions never place a call.
|
||||
*/
|
||||
let client: IAgoraRTCClient | null = null;
|
||||
let localAudioTrack: IMicrophoneAudioTrack | null = null;
|
||||
let localVideoTrack: ICameraVideoTrack | null = null;
|
||||
let remoteVideoTrack: IRemoteVideoTrack | null = null;
|
||||
// `createCameraVideoTrack()` (see joinCall below) opens whatever camera the browser/OS defaults to
|
||||
// without asking -- normally the front ("user") one, so this starts in sync with that assumption
|
||||
// rather than guessing wrong on the very first flip.
|
||||
let usingBackCamera = false;
|
||||
|
||||
export interface JoinedCall {
|
||||
localVideoTrack: ICameraVideoTrack | null;
|
||||
}
|
||||
|
||||
/** Joins the Agora channel named by the rtc-join response and publishes local mic (+ camera, for a
|
||||
* video call) tracks. Requesting `getUserMedia`-equivalent access here, not earlier -- matches the
|
||||
* old app's own real timing (captures media once the call is actually about to carry it, not on
|
||||
* page load). */
|
||||
export async function joinCall(join: RtcJoinResult, callType: 'audio' | 'video', onRemoteUser: (hasVideo: boolean) => void): Promise<JoinedCall> {
|
||||
const { default: AgoraRTC } = await import('agora-rtc-sdk-ng');
|
||||
client = AgoraRTC.createClient({ mode: 'rtc', codec: 'vp8' });
|
||||
client.on('user-published', async (user, mediaType) => {
|
||||
await client!.subscribe(user, mediaType);
|
||||
if (mediaType === 'video' && user.videoTrack) {
|
||||
remoteVideoTrack = user.videoTrack;
|
||||
onRemoteUser(true);
|
||||
}
|
||||
if (mediaType === 'audio' && user.audioTrack) {
|
||||
user.audioTrack.play();
|
||||
}
|
||||
});
|
||||
client.on('user-unpublished', (_user, mediaType) => {
|
||||
if (mediaType === 'video') {
|
||||
remoteVideoTrack = null;
|
||||
onRemoteUser(false);
|
||||
}
|
||||
});
|
||||
|
||||
await client.join(join.app_id, join.channel_name, join.token, join.uid);
|
||||
|
||||
localAudioTrack = await AgoraRTC.createMicrophoneAudioTrack();
|
||||
const tracks: Array<IMicrophoneAudioTrack | ICameraVideoTrack> = [localAudioTrack];
|
||||
if (callType === 'video') {
|
||||
localVideoTrack = await AgoraRTC.createCameraVideoTrack();
|
||||
tracks.push(localVideoTrack);
|
||||
}
|
||||
await client.publish(tracks);
|
||||
|
||||
return { localVideoTrack };
|
||||
}
|
||||
|
||||
export function playRemoteVideo(el: HTMLElement) {
|
||||
remoteVideoTrack?.play(el);
|
||||
}
|
||||
|
||||
export function playLocalVideo(el: HTMLElement) {
|
||||
localVideoTrack?.play(el);
|
||||
}
|
||||
|
||||
// Was fire-and-forget (`.setEnabled()`'s own promise never returned) -- a rejection (track already
|
||||
// closed, permission revoked mid-call) had nowhere to go but an unhandled rejection, and the caller
|
||||
// (stores/calls.ts's toggleMute) had no way to know it failed and roll the button back. Made async +
|
||||
// re-thrown so it can.
|
||||
export async function setMuted(muted: boolean) {
|
||||
if (!localAudioTrack) return;
|
||||
await localAudioTrack.setEnabled(!muted);
|
||||
}
|
||||
|
||||
export async function setCameraOff(off: boolean) {
|
||||
if (!localVideoTrack) return;
|
||||
await localVideoTrack.setEnabled(!off);
|
||||
}
|
||||
|
||||
/** Front/back camera switch (06-calls.dc.html #1f's `flip_camera_ios` mobile header icon --
|
||||
* previously a `notImplemented()` placeholder, see Part G's requirements-diff findings). Uses
|
||||
* `setDevice({facingMode})` rather than enumerating `AgoraRTC.getCameras()` and guessing which
|
||||
* device is "the other one" by its (unstandardized, often-empty-until-permission-granted) label --
|
||||
* `facingMode` is the browser's own signal for which physical camera to use, no guessing needed.
|
||||
* NOT verified against a real device: this sandboxed environment has no camera, the same
|
||||
* documented limitation that's kept full 2-way video verification out of reach for this whole
|
||||
* feature (see the plan file's calls section) -- correct per the SDK's own typed API, unverified
|
||||
* live. */
|
||||
export async function switchCamera(): Promise<void> {
|
||||
if (!localVideoTrack) return;
|
||||
const next = !usingBackCamera;
|
||||
usingBackCamera = next;
|
||||
try {
|
||||
await localVideoTrack.setDevice({ facingMode: next ? 'environment' : 'user' });
|
||||
} catch (err) {
|
||||
// Roll the optimistic flip back and rethrow -- matches setMuted/setCameraOff's own contract
|
||||
// (this file stays a thin, rethrowing wrapper; stores/calls.ts's own toggleMute/toggleCamera
|
||||
// are what catch and surface `mediaError`, and switchCamera's caller needs the same treatment,
|
||||
// not a second, inconsistent error-handling convention just for this one control).
|
||||
usingBackCamera = !next;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasRemoteVideo(): boolean {
|
||||
return !!remoteVideoTrack;
|
||||
}
|
||||
|
||||
/** Tears down everything -- call on hang-up from either side, or on error. Safe to call more than
|
||||
* once (every step is guarded by the ref still being set). */
|
||||
export async function leaveCall() {
|
||||
localAudioTrack?.close();
|
||||
localAudioTrack = null;
|
||||
localVideoTrack?.close();
|
||||
localVideoTrack = null;
|
||||
remoteVideoTrack = null;
|
||||
usingBackCamera = false;
|
||||
// Capture and null out the module-level ref BEFORE awaiting -- a rapid hang-up-then-redial can let
|
||||
// a new joinCall() reassign `client` to a fresh instance while this leave() is still in flight;
|
||||
// without capturing it first, the two lines after the await would operate on the NEW call's
|
||||
// client instead of the one actually being left.
|
||||
const c = client;
|
||||
client = null;
|
||||
if (c) {
|
||||
await c.leave().catch(() => {});
|
||||
c.removeAllListeners();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// must match the backend (server/*.go) -- they are not stylistic choices, so the numbers/strings
|
||||
// themselves carry over unchanged even though this is a from-scratch rewrite.
|
||||
|
||||
/** Version-independent product name (Firebase app name, youth-mode copy, etc.). */
|
||||
/** Version-independent product name (Firebase app name, in-app copy, etc.). */
|
||||
export const BASE_APP_NAME = 'MeetingIM';
|
||||
|
||||
/** Name used in the Tinode client's User-Agent string. */
|
||||
@@ -62,7 +62,8 @@ export const VIDEO_THUMBNAIL_WIDTH = 48;
|
||||
|
||||
export const MAX_ONLINE_IN_TOPIC = 4;
|
||||
export const MAX_TITLE_LENGTH = 60;
|
||||
export const MAX_TOPIC_DESCRIPTION_LENGTH = 360;
|
||||
/** docs/IM-前后端对接文档.md:1445 -- "description 可省略或传空字符串,最长 200 字符". */
|
||||
export const MAX_TOPIC_DESCRIPTION_LENGTH = 200;
|
||||
export const MAX_PEER_TITLE_LENGTH = 24;
|
||||
|
||||
export const MESSAGE_PREVIEW_LENGTH = 80;
|
||||
@@ -82,4 +83,4 @@ export const QRCODE_SIZE = 128;
|
||||
export const WAKE_UP_TIMEOUT = 80_000;
|
||||
export const WAKE_UP_TICK = 1_000;
|
||||
|
||||
export const TOAST_DURATION = 3_000;
|
||||
export const TOAST_DURATION = 2_500;
|
||||
|
||||
+60
-7
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { fileAuthHeaders, fileDownloadUrl } from '@/lib/api/upload';
|
||||
|
||||
@@ -65,14 +65,67 @@ export function useMediaQuery(query: string): boolean {
|
||||
return matches;
|
||||
}
|
||||
|
||||
interface EscapeStackEntry {
|
||||
cb: () => void;
|
||||
}
|
||||
const escapeStack: EscapeStackEntry[] = [];
|
||||
let escapeListenerAttached = false;
|
||||
|
||||
function handleGlobalEscape(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape') return;
|
||||
// Only the TOPMOST (most-recently-mounted) overlay closes -- without this, opening a QR scanner
|
||||
// modal from inside AddFriendModal meant BOTH modals' independent useEscapeKey listeners fired on
|
||||
// the same keydown, so pressing Escape to back out of just the scanner closed the whole add-friend
|
||||
// flow too (the backdrop-click equivalent of this was already fixed via Fragment-sibling nesting,
|
||||
// but that DOM-structural fix does nothing for a global keydown listener with no concept of nesting).
|
||||
escapeStack[escapeStack.length - 1]?.cb();
|
||||
}
|
||||
|
||||
/** Closes an overlay (modal/menu/popover) on Escape -- every one of this app's own overlays already
|
||||
* closes on backdrop click, but none had a keyboard equivalent. */
|
||||
* closes on backdrop click, but none had a keyboard equivalent. Stack-aware: when overlays are
|
||||
* nested (a modal opened from inside another modal), Escape closes only the innermost one. */
|
||||
export function useEscapeKey(onEscape: () => void): void {
|
||||
const entryRef = useRef<EscapeStackEntry>({ cb: onEscape });
|
||||
// Keeps the stack entry's callback fresh without re-running the mount/unmount effect below on
|
||||
// every render -- an effect (not a render-time ref write) so it runs after commit, not during render.
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onEscape();
|
||||
entryRef.current.cb = onEscape;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const entry = entryRef.current;
|
||||
escapeStack.push(entry);
|
||||
if (!escapeListenerAttached) {
|
||||
window.addEventListener('keydown', handleGlobalEscape);
|
||||
escapeListenerAttached = true;
|
||||
}
|
||||
return () => {
|
||||
const idx = escapeStack.indexOf(entry);
|
||||
if (idx !== -1) escapeStack.splice(idx, 1);
|
||||
if (escapeStack.length === 0) {
|
||||
window.removeEventListener('keydown', handleGlobalEscape);
|
||||
escapeListenerAttached = false;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes a toggle-button-triggered popover (emoji panel, @mention picker, ...) on any click outside
|
||||
* `ref`'s subtree -- `ref` must wrap BOTH the toggle button and the popover itself, so a click on the
|
||||
* toggle button counts as "inside" and is left to the button's own onClick (which already flips the
|
||||
* open state); only listens while `active` so re-clicking the toggle to open doesn't immediately
|
||||
* attach a listener that fires for that same click's bubble. Only active-state gating (not `onClose`)
|
||||
* is in the deps list, matching MessageContextMenu.tsx's own click-outside pattern -- an inline
|
||||
* `onClose` arrow function is expected to be a new reference every render.
|
||||
*/
|
||||
export function useClickOutside(ref: React.RefObject<HTMLElement | null>, onClose: () => void, active: boolean): void {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [onEscape]);
|
||||
window.addEventListener('click', handler);
|
||||
return () => window.removeEventListener('click', handler);
|
||||
}, [active]);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { StoredMessage } from 'tinode-sdk';
|
||||
|
||||
import { callContentOf, callDurationMsOf, callStateOf } from '@/lib/tinode/calls';
|
||||
|
||||
/**
|
||||
* Two different "message is gone" protocol paths render differently (confirmed against the old
|
||||
* app's chat-message.jsx, a real, previously-hit gap): `msg.hi` is the server's own soft-delete
|
||||
@@ -35,3 +37,71 @@ export function systemNoticeText(msg: StoredMessage): string | null {
|
||||
if (headType !== 'system_notice' && headType !== 'system_action') return null;
|
||||
return isSystemNoticeContent(msg.content) ? msg.content.text : null;
|
||||
}
|
||||
|
||||
export interface ContactCardContent {
|
||||
user_id: string;
|
||||
display_name?: string;
|
||||
avatar_file_id?: string;
|
||||
public_account?: string;
|
||||
}
|
||||
|
||||
function isContactCardContent(content: unknown): content is ContactCardContent {
|
||||
return !!content && typeof content === 'object' && typeof (content as ContactCardContent).user_id === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* README §3.3's 名片 composer button has no send path anywhere in this app (no client here ever
|
||||
* constructs one -- see AddFriendModal/ConversationView's own `notImplemented()` for that button),
|
||||
* but a contact-card message CAN still arrive from another client. Confirmed real shape via
|
||||
* docs/IM-前后端对接文档.md:311's worked example -- a backend-custom `head['x-im-type']:
|
||||
* "contact_card"` with a FLAT `{user_id,display_name,avatar_file_id,public_account}` content, NOT a
|
||||
* Drafty entity. This app's own `drafty.tsx` used to assume (per a stale header comment inherited
|
||||
* from the SDK's generic ambient type list) that contact cards would show up as a Drafty `TC`-tagged
|
||||
* entity the same way images/audio do -- they don't, so `renderDrafty` was silently fed a
|
||||
* non-Drafty-shaped object for this message type and rendered nothing. This is dispatched the same
|
||||
* way `systemNoticeText`/`callResultDisplay` are: by `head['x-im-type']`, checked BEFORE ever calling
|
||||
* `renderDrafty` on `msg.content`.
|
||||
*/
|
||||
export function contactCardContentOf(msg: StoredMessage): ContactCardContent | null {
|
||||
if (msg.head?.['x-im-type'] !== 'contact_card') return null;
|
||||
return isContactCardContent(msg.content) ? msg.content : null;
|
||||
}
|
||||
|
||||
export function formatCallDuration(ms: number): string {
|
||||
const totalSeconds = Math.round(ms / 1000);
|
||||
return `${String(Math.floor(totalSeconds / 60)).padStart(2, '0')}:${String(totalSeconds % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* README §7's "通话结果消息" -- icon + text mapped from `head.webrtc`, confirmed live (doc line
|
||||
* 1953/1954): `missed` alone covers both "未接听" and "已取消" (a callee who let it ring out and a
|
||||
* caller who cancelled before pickup both replace to the SAME wire value) -- direction picks the
|
||||
* text, not the wire value, using `mine` (was the ORIGINAL invite sent by me, i.e. was I the
|
||||
* caller) exactly the way this app already threads that prop through MessageRow. No `busy` value
|
||||
* exists in the confirmed real enum (only accepted/finished/disconnected/missed/declined) despite
|
||||
* the old app's own UI vocabulary having one -- not rendered here since nothing legitimate can
|
||||
* produce it yet.
|
||||
*/
|
||||
export function callResultDisplay(msg: StoredMessage, mine: boolean): { icon: string; text: string; danger: boolean } | null {
|
||||
const content = callContentOf(msg);
|
||||
const state = callStateOf(msg);
|
||||
if (!content || !state) return null;
|
||||
const icon = content.callType === 'video' ? 'videocam' : 'call';
|
||||
switch (state) {
|
||||
case 'finished': {
|
||||
const duration = callDurationMsOf(msg);
|
||||
return { icon, danger: false, text: duration > 0 ? `通话时长 ${formatCallDuration(duration)}` : '已结束' };
|
||||
}
|
||||
case 'missed':
|
||||
return { icon: 'phone_missed', danger: true, text: mine ? '已取消' : '未接听' };
|
||||
case 'declined':
|
||||
return { icon: 'phone_missed', danger: true, text: '已拒绝' };
|
||||
case 'disconnected':
|
||||
return { icon: 'phone_missed', danger: true, text: '异常结束' };
|
||||
case 'accepted':
|
||||
case 'started':
|
||||
return { icon, danger: false, text: '通话中' };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+111
-63
@@ -2,7 +2,8 @@ import { Fragment, type ReactNode } from 'react';
|
||||
|
||||
import { Drafty, type DraftyContent } from 'tinode-sdk';
|
||||
|
||||
import { AudioAttachment, FileAttachment, ImageAttachment } from '@/features/messages/AttachmentPreview';
|
||||
import { AudioAttachment, FileAttachment, ImageAttachment, VideoAttachment } from '@/features/messages/AttachmentPreview';
|
||||
import { SafeLink } from '@/features/messages/SafeLink';
|
||||
|
||||
/**
|
||||
* Bubble content renderer for the Drafty style tags this app currently sends/receives. Grown
|
||||
@@ -15,70 +16,117 @@ import { AudioAttachment, FileAttachment, ImageAttachment } from '@/features/mes
|
||||
* not an oversight (confirmed against the old app's formatters.js:122-128, which does hash-color
|
||||
* mentions the same way avatars are colored).
|
||||
*/
|
||||
function formatNode(
|
||||
style: string | undefined,
|
||||
data: Record<string, unknown> | undefined,
|
||||
values: ReactNode[] | null,
|
||||
key: number | string,
|
||||
): ReactNode {
|
||||
const children = values ?? undefined;
|
||||
switch (style) {
|
||||
case 'ST':
|
||||
return <b key={key}>{children}</b>;
|
||||
case 'EM':
|
||||
return <i key={key}>{children}</i>;
|
||||
case 'DL':
|
||||
return <s key={key}>{children}</s>;
|
||||
case 'CO':
|
||||
return (
|
||||
<code key={key} className="rounded bg-surface-input px-1 py-0.5 font-mono text-[0.9em]">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
case 'BR':
|
||||
return <br key={key} />;
|
||||
case 'MN':
|
||||
return (
|
||||
<span key={key} className="text-accent">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
case 'HL':
|
||||
return (
|
||||
<mark key={key} className="bg-warn-bg">
|
||||
{children}
|
||||
</mark>
|
||||
);
|
||||
case 'LN': {
|
||||
const url = typeof data?.url === 'string' ? data.url : undefined;
|
||||
return (
|
||||
<a key={key} href={url} target="_blank" rel="noreferrer" className="text-accent underline">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
case 'IM':
|
||||
return <ImageAttachment key={key} data={data || {}} />;
|
||||
case 'EX':
|
||||
return <FileAttachment key={key} data={data || {}} />;
|
||||
case 'AU':
|
||||
return <AudioAttachment key={key} data={data || {}} />;
|
||||
case 'QQ':
|
||||
// Drafty.quote() has no `key`/`data` of its own (tinode.dev.js:2160-2168) -- it's a plain style
|
||||
// span wrapping mention(header,uid) + linebreak + body, all already rendered into `children`.
|
||||
return (
|
||||
<div key={key} className="mb-1.5 rounded border-l-2 border-accent bg-white/50 px-2 py-0.5 text-xs text-[#4a4f57]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return <Fragment key={key}>{children}</Fragment>;
|
||||
/** README §6.3 (mock `05/1e`/`1f`): search-result keyword matches render as `<mark style="background:
|
||||
* transparent; color:#1677ff; font-weight:500">`, not a yellow highlight -- a plain-text style, not
|
||||
* the SDK's own unrelated `HL` Drafty tag (that one is `bg-warn-bg`, a different, currently-unused
|
||||
* feature for user-authored highlight formatting, not search-match highlighting). Global,
|
||||
* case-insensitive, one `<mark>` per match. */
|
||||
function highlightMatches(text: string, query: string, keyPrefix: string | number): ReactNode[] {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return [text];
|
||||
const haystack = text.toLowerCase();
|
||||
const parts: ReactNode[] = [];
|
||||
let cursor = 0;
|
||||
let idx = haystack.indexOf(needle, cursor);
|
||||
if (idx === -1) return [text];
|
||||
let n = 0;
|
||||
while (idx !== -1) {
|
||||
if (idx > cursor) parts.push(text.slice(cursor, idx));
|
||||
parts.push(
|
||||
<mark key={`${keyPrefix}-hl-${n++}`} className="bg-transparent font-medium text-accent">
|
||||
{text.slice(idx, idx + query.trim().length)}
|
||||
</mark>,
|
||||
);
|
||||
cursor = idx + query.trim().length;
|
||||
idx = haystack.indexOf(needle, cursor);
|
||||
}
|
||||
if (cursor < text.length) parts.push(text.slice(cursor));
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function renderDrafty(content: DraftyContent | string | null | undefined): ReactNode {
|
||||
/** Curried on `highlightQuery` rather than taking it as a plain 5th parameter -- `Drafty.format`'s
|
||||
* own callback type has a REAL 5th parameter (`stack: unknown[]`, see treeBottomUp's actual call),
|
||||
* so a `formatNode` typed to accept `highlightQuery` in that same slot would stop structurally
|
||||
* matching the formatter type `Drafty.format` expects wherever it's passed directly by reference. */
|
||||
function makeFormatNode(highlightQuery: string | undefined) {
|
||||
return function formatNode(
|
||||
style: string | undefined,
|
||||
data: Record<string, unknown> | undefined,
|
||||
values: ReactNode[] | null,
|
||||
key: number | string,
|
||||
): ReactNode {
|
||||
const children = values ?? undefined;
|
||||
switch (style) {
|
||||
case 'ST':
|
||||
return <b key={key}>{children}</b>;
|
||||
case 'EM':
|
||||
return <i key={key}>{children}</i>;
|
||||
case 'DL':
|
||||
return <s key={key}>{children}</s>;
|
||||
case 'CO':
|
||||
return (
|
||||
<code key={key} className="rounded bg-surface-input px-1 py-0.5 font-mono text-[0.9em]">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
case 'BR':
|
||||
return <br key={key} />;
|
||||
case 'MN':
|
||||
return (
|
||||
<span key={key} className="text-accent">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
case 'HL':
|
||||
return (
|
||||
<mark key={key} className="bg-warn-bg">
|
||||
{children}
|
||||
</mark>
|
||||
);
|
||||
case 'LN': {
|
||||
const url = typeof data?.url === 'string' ? data.url : undefined;
|
||||
return (
|
||||
<SafeLink key={key} href={url}>
|
||||
{children}
|
||||
</SafeLink>
|
||||
);
|
||||
}
|
||||
case 'IM':
|
||||
return <ImageAttachment key={key} data={data || {}} />;
|
||||
case 'EX':
|
||||
return <FileAttachment key={key} data={data || {}} />;
|
||||
case 'AU':
|
||||
return <AudioAttachment key={key} data={data || {}} />;
|
||||
case 'VD':
|
||||
return <VideoAttachment key={key} data={data || {}} />;
|
||||
case 'QQ':
|
||||
// Drafty.quote() has no `key`/`data` of its own (tinode.dev.js:2160-2168) -- it's a plain
|
||||
// style span wrapping mention(header,uid) + linebreak + body, already in `children`.
|
||||
return (
|
||||
<div key={key} className="mb-1.5 rounded border-l-2 border-accent bg-white/50 px-2 py-0.5 text-xs text-[#4a4f57]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
// A genuine plain-text leaf (per treeBottomUp: no children, so `values` collapses to the
|
||||
// node's own raw `[text]`) is the only shape safe to slice up for highlighting -- anything
|
||||
// else (an already-formatted child array from a styled ancestor) is left exactly as before.
|
||||
if (highlightQuery && values?.length === 1 && typeof values[0] === 'string') {
|
||||
return <Fragment key={key}>{highlightMatches(values[0], highlightQuery, key)}</Fragment>;
|
||||
}
|
||||
return <Fragment key={key}>{children}</Fragment>;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** `highlightQuery` is opt-in and additive -- every existing call site that omits it renders exactly
|
||||
* as before; only HistorySearchModal's search results pass one. */
|
||||
export function renderDrafty(content: DraftyContent | string | null | undefined, highlightQuery?: string): ReactNode {
|
||||
if (content == null) return null;
|
||||
if (typeof content === 'string') return content;
|
||||
if (Drafty.isPlainText(content)) return Drafty.toPlainText(content);
|
||||
return Drafty.format(content, formatNode);
|
||||
if (typeof content === 'string') return highlightQuery ? <>{highlightMatches(content, highlightQuery, 'plain')}</> : content;
|
||||
if (Drafty.isPlainText(content)) {
|
||||
const text = Drafty.toPlainText(content);
|
||||
return highlightQuery ? <>{highlightMatches(text, highlightQuery, 'plain')}</> : text;
|
||||
}
|
||||
return Drafty.format(content, makeFormatNode(highlightQuery));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Drafty, type DraftyContent } from 'tinode-sdk';
|
||||
|
||||
export interface PendingMention {
|
||||
userId: string;
|
||||
/** Display text WITHOUT the leading '@' -- what actually appears in the composed plain text
|
||||
* (e.g. "张三", or "所有人" for the mention-all virtual entry). */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `Drafty.mention(name, uid)` (tinode.dev.js:2169) never validates `uid` -- it's just stored as
|
||||
* the MN entity's `data.val`, opaque to the SDK and to this app's own renderer (drafty.tsx's `MN`
|
||||
* case renders `children` regardless of who it is). There's no real per-tenant user id for
|
||||
* "everyone", so this is this client's own made-up placeholder, not a protocol value -- fine since
|
||||
* nothing on the receive side needs to resolve it back to an actual user.
|
||||
*/
|
||||
export const MENTION_ALL_SENTINEL = 'all';
|
||||
|
||||
/**
|
||||
* Composes the final Drafty content for a group message that may contain @ mentions. The composer
|
||||
* is a plain `<textarea>` (not rich/contenteditable), so a picked mention is inserted as literal
|
||||
* `@label ` text in the draft and tracked separately in `mentions` -- this walks the final text
|
||||
* once at send time, finds each tracked mention's `@label` occurrence (left to right, never
|
||||
* re-matching a range an earlier mention already claimed, so pinging the same person twice doesn't
|
||||
* collapse onto one occurrence), and splices in real `Drafty.mention()` entities around plain-text
|
||||
* runs via `Drafty.append` -- the same stitching pattern send.ts's `sendReply` already uses for
|
||||
* quote blocks. Falls back to the plain `sendText` parse path when there's nothing to splice
|
||||
* (no mentions, or none of them still appear in the edited text).
|
||||
*/
|
||||
export function buildMentionedContent(text: string, mentions: PendingMention[]): DraftyContent | string {
|
||||
const toPlain = () => {
|
||||
const parsed = Drafty.parse(text);
|
||||
return Drafty.isPlainText(parsed) ? text : parsed;
|
||||
};
|
||||
if (mentions.length === 0) return toPlain();
|
||||
|
||||
const matches: Array<{ start: number; end: number; mention: PendingMention }> = [];
|
||||
let searchFrom = 0;
|
||||
for (const mention of mentions) {
|
||||
const needle = `@${mention.label}`;
|
||||
const idx = text.indexOf(needle, searchFrom);
|
||||
if (idx === -1) continue;
|
||||
matches.push({ start: idx, end: idx + needle.length, mention });
|
||||
searchFrom = idx + needle.length;
|
||||
}
|
||||
if (matches.length === 0) return toPlain();
|
||||
matches.sort((a, b) => a.start - b.start);
|
||||
|
||||
let content: DraftyContent | undefined;
|
||||
const appendPlain = (slice: string) => {
|
||||
if (!slice) return;
|
||||
const plain = Drafty.parse(slice);
|
||||
content = content ? Drafty.append(content, plain) : plain;
|
||||
};
|
||||
let cursor = 0;
|
||||
for (const m of matches) {
|
||||
appendPlain(text.slice(cursor, m.start));
|
||||
// `Drafty.mention(name, uid)` (tinode.dev.js:2169-2176) stores `name` VERBATIM as the run's
|
||||
// plain text with no automatic '@' -- passing the bare label rendered as "ixp355087" instead
|
||||
// of "@ixp355087" (confirmed live: a sent mention's `MN` span text had no '@' at all), so the
|
||||
// '@' has to be included here to end up inside the styled span, matching the design's own
|
||||
// `<span style="color:#1677ff;">@我</span>`.
|
||||
const mn = Drafty.mention(`@${m.mention.label}`, m.mention.userId);
|
||||
content = content ? Drafty.append(content, mn) : mn;
|
||||
cursor = m.end;
|
||||
}
|
||||
appendPlain(text.slice(cursor));
|
||||
return content ?? toPlain();
|
||||
}
|
||||
|
||||
export interface MentionTrigger {
|
||||
/** Index of the triggering '@' itself within `text`. */
|
||||
start: number;
|
||||
/** Text typed so far between the '@' and the cursor, used to filter the picker's candidate list. */
|
||||
query: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects an in-progress `@mention` at the cursor: the nearest unclosed '@' before `cursorPos`,
|
||||
* closed by any whitespace typed between it and the cursor (same as most chat composers). Doesn't
|
||||
* require the '@' itself to be preceded by whitespace -- CJK sentences routinely run straight into
|
||||
* it ("大家注意@张三", no space before the '@', matching the design reference image) unlike English
|
||||
* "foo@bar", so gating on that would silently break the exact reference case this is built for.
|
||||
* A stray "name@example.com" typed in a group chat will float the picker uninvasively until the
|
||||
* next space closes it -- an acceptable trade for not breaking the primary CJK use case.
|
||||
*/
|
||||
export function detectMentionTrigger(text: string, cursorPos: number): MentionTrigger | null {
|
||||
const uptoCursor = text.slice(0, cursorPos);
|
||||
const at = uptoCursor.lastIndexOf('@');
|
||||
if (at === -1) return null;
|
||||
const query = uptoCursor.slice(at + 1);
|
||||
if (/\s/.test(query)) return null;
|
||||
return { start: at, query };
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Drafty, type DraftyContent, type StoredMessage } from 'tinode-sdk';
|
||||
|
||||
import { isServerDeleted } from '@/lib/messages/dispatch';
|
||||
import { callResultDisplay, contactCardContentOf, isServerDeleted, systemNoticeText } from '@/lib/messages/dispatch';
|
||||
|
||||
/**
|
||||
* One-line chat-list preview text (README §2.1): plain text renders as-is, everything else falls
|
||||
* back to a bracketed type label. Own messages get a "我:" prefix. Grown incrementally -- VC (call)
|
||||
* duration/answered-state isn't modeled yet (Phase 6 territory), so call messages get a plain label
|
||||
* for now rather than guessing at a content shape no research has confirmed.
|
||||
* back to a bracketed type label. Own messages get a "我:" prefix.
|
||||
*/
|
||||
export function formatMessagePreview(msg: StoredMessage | undefined, myUserId: string | null): string {
|
||||
if (!msg) return '';
|
||||
@@ -16,6 +14,18 @@ export function formatMessagePreview(msg: StoredMessage | undefined, myUserId: s
|
||||
// app's own equivalent renderer does the same, since a hi-flagged message can still carry
|
||||
// non-null content that must NOT leak into this preview.
|
||||
if (isServerDeleted(msg) || content == null) return `${prefix}[消息已删除]`;
|
||||
// System notices/contact cards/call-result messages are dispatched by `head` (`x-im-type`/
|
||||
// `webrtc`), not by Drafty content shape -- confirmed in dispatch.ts's own header comments, same
|
||||
// order MessageBubble.tsx already checks these in. Their `content` is a flat, non-Drafty object
|
||||
// (`{text}` / `{user_id,...}` / `{callId,...}`), so skipping this check falls through to the
|
||||
// Drafty-entity branches below, which read a `.ent` array none of these objects have -- this is
|
||||
// exactly how the chat list's "undefined" preview bug happened (`Drafty.preview()` fed an object
|
||||
// it doesn't recognize, `Drafty.toPlainText()` on its result stringifying to "undefined").
|
||||
const noticeText = systemNoticeText(msg);
|
||||
if (noticeText != null) return noticeText;
|
||||
if (contactCardContentOf(msg)) return `${prefix}[名片]`;
|
||||
const callResult = callResultDisplay(msg, msg.from === myUserId);
|
||||
if (callResult) return `${prefix}${callResult.text}`;
|
||||
if (typeof content === 'string' || Drafty.isPlainText(content)) {
|
||||
return prefix + Drafty.toPlainText(content);
|
||||
}
|
||||
@@ -32,10 +42,6 @@ export function formatMessagePreview(msg: StoredMessage | undefined, myUserId: s
|
||||
}
|
||||
case 'EX':
|
||||
return `${prefix}[文件] ${typeof firstEnt.data?.name === 'string' ? firstEnt.data.name : ''}`;
|
||||
case 'TC':
|
||||
return `${prefix}[名片]`;
|
||||
case 'VC':
|
||||
return `${prefix}[语音/视频通话]`;
|
||||
default:
|
||||
return prefix + Drafty.toPlainText(Drafty.preview(content, 40));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Drafty, type Topic } from 'tinode-sdk';
|
||||
|
||||
import { uploadFile } from '@/lib/api/upload';
|
||||
import { MAX_INBAND_ATTACHMENT_SIZE } from '@/lib/config';
|
||||
import { newClientMid } from '@/lib/tinode/send';
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -49,7 +50,7 @@ export async function sendImage(topic: Topic, file: File): Promise<unknown> {
|
||||
size: file.size,
|
||||
});
|
||||
const msg = topic.createMessage(content, false);
|
||||
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'image' });
|
||||
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'image', 'x-im-client-mid': newClientMid() });
|
||||
return topic.publishDraft(msg, Promise.resolve());
|
||||
}
|
||||
|
||||
@@ -59,7 +60,7 @@ export async function sendFile(topic: Topic, file: File): Promise<unknown> {
|
||||
? Drafty.attachFile(undefined, { mime: file.type, data: await fileToBase64(file), filename: file.name, size: file.size })
|
||||
: Drafty.attachFile(undefined, { mime: file.type, refurl: (await uploadFile(file, topic.name)).url, filename: file.name, size: file.size });
|
||||
const msg = topic.createMessage(content, false);
|
||||
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'file' });
|
||||
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'file', 'x-im-client-mid': newClientMid() });
|
||||
return topic.publishDraft(msg, Promise.resolve());
|
||||
}
|
||||
|
||||
@@ -84,6 +85,6 @@ export async function sendVoice(topic: Topic, blob: Blob, durationMs: number): P
|
||||
filename: file.name,
|
||||
});
|
||||
const msg = topic.createMessage(content, false);
|
||||
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'voice' });
|
||||
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'voice', 'x-im-client-mid': newClientMid() });
|
||||
return topic.publishDraft(msg, Promise.resolve());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { DraftyContent, StoredMessage, Topic } from 'tinode-sdk';
|
||||
|
||||
import { newClientMid } from '@/lib/tinode/send';
|
||||
|
||||
/**
|
||||
* Call signaling send-side helpers. Confirmed shape (docs/IM-前后端对接文档.md §0.5.4.7/§8, lines
|
||||
* 424-435/1945-2076): the invite is a plain-JSON `{pub}` (NOT Drafty -- `topic.createMessage` only
|
||||
* auto-Drafty-wraps string/Drafty-shaped content, a plain `{callId,...}` object passes through as
|
||||
* literal `content`), `head:{mime:"application/json", webrtc:"started", "x-im-type":"call"}`.
|
||||
* Ringing/accept/hang-up then go over `topic.videoCall()` (thin wrapper around the SDK's native
|
||||
* `{note what:"call"}`, see tinode-sdk.d.ts's own comment) -- NOT offer/answer/ice-candidate, which
|
||||
* this app doesn't use (see calls.ts's own header comment in lib/api on the Agora/rtc-join media
|
||||
* path this backend actually implements instead of raw SDP/ICE over the IM WebSocket).
|
||||
*/
|
||||
export interface CallContent {
|
||||
callId: string;
|
||||
callType: 'audio' | 'video';
|
||||
audio: boolean;
|
||||
video: boolean;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/** `head.webrtc` values a call message cycles through, replacing the SAME `seq` in place (doc line
|
||||
* 1953) -- `started` is the live invite; the rest are terminal results the server writes over it. */
|
||||
export type CallWebrtcState = 'started' | 'accepted' | 'finished' | 'disconnected' | 'missed' | 'declined';
|
||||
|
||||
export function callContentOf(msg: StoredMessage): CallContent | null {
|
||||
const webrtc = msg.head?.webrtc;
|
||||
if (typeof webrtc !== 'string' || !msg.content || typeof msg.content !== 'object') return null;
|
||||
const content = msg.content as unknown as CallContent;
|
||||
if (typeof content.callId !== 'string') return null;
|
||||
return content;
|
||||
}
|
||||
|
||||
export function callStateOf(msg: StoredMessage): CallWebrtcState | null {
|
||||
const webrtc = msg.head?.webrtc;
|
||||
return typeof webrtc === 'string' ? (webrtc as CallWebrtcState) : null;
|
||||
}
|
||||
|
||||
export function callDurationMsOf(msg: StoredMessage): number {
|
||||
const duration = msg.head?.['webrtc-duration'];
|
||||
return typeof duration === 'number' ? duration : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the call invite. `noEcho: false` (matches send.ts's sendText) so the caller's own UI gets an
|
||||
* immediate optimistic bubble via `publishDraft`, same as any other message.
|
||||
*
|
||||
* Confirmed live: an audio call's outbound packet never actually carries `video:false` on the wire
|
||||
* -- the SDK's own `#send()` runs every outbound packet through a generic `simplify()` (tinode.dev.
|
||||
* js:5881-5903) that recursively deletes any falsy field, with no way to mark one as intentional.
|
||||
* This isn't fixable from the call site (it strips ANY false-valued content field this app might
|
||||
* ever send this way, not just this one) and doesn't matter in practice here -- neither this file's
|
||||
* own `callContentOf` nor CallRoot.tsx reads `content.audio`/`content.video`, only `content.
|
||||
* callType` (always a non-empty string, never stripped), so a missing boolean never surfaces as a
|
||||
* real bug -- but a future call site that DID need to trust an explicit `false` over "field absent"
|
||||
* would silently lose that distinction.
|
||||
*/
|
||||
export function sendCallInvite(topic: Topic, callId: string, callType: 'audio' | 'video'): Promise<{ code: number; params?: { seq: number } }> {
|
||||
const content: CallContent = { callId, callType, audio: true, video: callType === 'video' };
|
||||
// `createMessage`'s own type only admits Drafty content, but its real implementation (tinode.dev.
|
||||
// js:7170-7182) passes any non-string content through untouched -- verified reading that source,
|
||||
// not guessed. This cast is deliberately lying to the type system for that one confirmed reason.
|
||||
const msg = topic.createMessage(content as unknown as DraftyContent, false);
|
||||
msg.head = Object.assign(msg.head || {}, { mime: 'application/json', webrtc: 'started', 'x-im-type': 'call', 'x-im-client-mid': newClientMid() });
|
||||
return topic.publishDraft(msg, Promise.resolve()) as Promise<{ code: number; params?: { seq: number } }>;
|
||||
}
|
||||
|
||||
export function sendCallRinging(topic: Topic, seq: number, callId: string) {
|
||||
return topic.videoCall('ringing', seq, { callId });
|
||||
}
|
||||
|
||||
export function sendCallAccept(topic: Topic, seq: number, callId: string) {
|
||||
return topic.videoCall('accept', seq, { callId });
|
||||
}
|
||||
|
||||
export function sendCallHangup(topic: Topic, seq: number, callId: string, reason?: string) {
|
||||
return topic.videoCall('hang-up', seq, reason ? { callId, reason } : { callId });
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
import { Tinode, type LoginCtrl } from 'tinode-sdk';
|
||||
|
||||
import { API_KEY, APP_NAME, DEFAULT_HOST, LOGGING_ENABLED } from '@/lib/config';
|
||||
import { isTenantMismatch } from '@/lib/tinode/errors';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'authenticated';
|
||||
@@ -17,9 +18,30 @@ export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'au
|
||||
let tinode: Tinode | null = null;
|
||||
let status: ConnectionStatus = 'disconnected';
|
||||
const listeners = new Set<() => void>();
|
||||
// Whether this connection has EVER reached 'authenticated' (survives the 'disconnected' state a
|
||||
// reconnect passes through, so onConnect below can tell "first connect, nothing to restore yet"
|
||||
// apart from "was logged in, the socket just dropped and came back"). Reset in resetTinode() only.
|
||||
let wasAuthenticated = false;
|
||||
|
||||
/**
|
||||
* Fired once a post-reconnect restoreSession() succeeds (see onConnect below). A plain listener
|
||||
* set rather than this module importing topics.ts directly -- confirmed by reading tinode.dev.js
|
||||
* that Tinode's own #disconnected() handler resets EVERY cached topic's `_attached` flag to false
|
||||
* on any drop (including the `me` topic and every contact's one-shot preview subscription, not just
|
||||
* the one topic a user happens to have open), so a bare reconnect+relogin leaves the whole app's
|
||||
* subscriptions dead even though the "已重连同步" banner says otherwise -- but deciding what
|
||||
* "resume subscriptions" means is topics.ts's job, not this connection-layer module's.
|
||||
*/
|
||||
const reconnectAuthListeners = new Set<() => void>();
|
||||
|
||||
export function onReauthenticated(callback: () => void): () => void {
|
||||
reconnectAuthListeners.add(callback);
|
||||
return () => reconnectAuthListeners.delete(callback);
|
||||
}
|
||||
|
||||
function setStatus(next: ConnectionStatus) {
|
||||
if (status === next) return;
|
||||
if (next === 'authenticated') wasAuthenticated = true;
|
||||
status = next;
|
||||
listeners.forEach((cb) => cb());
|
||||
}
|
||||
@@ -149,6 +171,30 @@ function createTinode(): Tinode {
|
||||
const wasReconnecting = reconnectState.retryInSeconds > 0 || reconnectState.attemptInFlight;
|
||||
setStatus('connected');
|
||||
stopReconnectRetry();
|
||||
// The SDK's own auto-backoff (and the manual "立即重试") only re-establish the WebSocket --
|
||||
// confirmed by reading the SDK source that reconnecting never resends {login}. The server
|
||||
// treats every fresh connection as unauthenticated until told otherwise, so without this, a
|
||||
// reconnect after a transient drop (WiFi handoff, laptop sleep/wake, VPN blip -- routine on the
|
||||
// kind of enterprise network this app runs on) would leave every subsequent request silently
|
||||
// failing while the UI's "已重连同步" banner tells the user everything is fine.
|
||||
if (wasAuthenticated) {
|
||||
const token = sessionStorage.getItem('im-session-token');
|
||||
if (token) {
|
||||
restoreSession(token)
|
||||
.then(() => reconnectAuthListeners.forEach((cb) => cb()))
|
||||
.catch((err) => {
|
||||
// Token no longer valid (revoked/expired server-side while we were disconnected) -- can't
|
||||
// silently recover, so fall back to the same "force a real login" outcome a failed
|
||||
// restore-on-page-load already gets via RequireAuth.
|
||||
sessionStorage.removeItem('im-session-token');
|
||||
useSessionStore.getState().setMyUserId(null);
|
||||
// Same doc-prescribed recovery RequireAuth's own restore-failure path applies: a token
|
||||
// that's valid for a DIFFERENT tenant than the one this connection is against must also
|
||||
// drop the tenant, or the next /login mount reuses it and repeats the same mismatch.
|
||||
if (isTenantMismatch(err)) useSessionStore.getState().clearTenant();
|
||||
});
|
||||
}
|
||||
}
|
||||
if (wasReconnecting) {
|
||||
setReconnectState({ justReconnected: true });
|
||||
if (justReconnectedTimer) clearTimeout(justReconnectedTimer);
|
||||
@@ -203,6 +249,7 @@ export function resetTinode() {
|
||||
tinode = null;
|
||||
connectPromise = null;
|
||||
restorePromise = null;
|
||||
wasAuthenticated = false;
|
||||
setStatus('disconnected');
|
||||
}
|
||||
|
||||
@@ -251,6 +298,27 @@ export function restoreSession(token: string): Promise<string | null> {
|
||||
return restorePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduped the same way connect()/restoreSession() are above, for the identical reason: LoginPage's
|
||||
* own `submitting` state guard only takes effect once React re-renders with the submit button
|
||||
* disabled, which is a moment too late for a genuine fast double-click/double-Enter to be stopped by
|
||||
* -- every OTHER non-idempotent SDK call in this module (connect/restoreSession/registerAccount) was
|
||||
* already deduped against exactly this race, `loginBasic` was the one left calling straight through.
|
||||
*/
|
||||
let loginPromise: Promise<LoginCtrl> | null = null;
|
||||
|
||||
export function loginBasic(login: string, password: string): Promise<LoginCtrl> {
|
||||
if (loginPromise) return loginPromise;
|
||||
loginPromise = (async () => {
|
||||
const t = getTinode();
|
||||
if (!t.isConnected()) await connect();
|
||||
return t.loginBasic(login, password);
|
||||
})().finally(() => {
|
||||
loginPromise = null;
|
||||
});
|
||||
return loginPromise;
|
||||
}
|
||||
|
||||
export function getConnectionStatus(): ConnectionStatus {
|
||||
return status;
|
||||
}
|
||||
@@ -274,9 +342,10 @@ export interface RegisterParams {
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified username-or-phone registration (product Ch.1.4). Two DIFFERENT phone representations are
|
||||
* required and mixing them up throws a 422 "policy violation" with no further detail (confirmed live
|
||||
* against the real backend, then traced to server/user.go's resolveRegistrationAccount):
|
||||
* Unified username-or-phone registration (product Ch.1.4). THREE DIFFERENT phone representations
|
||||
* are involved in one `{acc}` packet and mixing them up throws a 422 "policy violation" with no
|
||||
* further detail (confirmed live against the real backend, then traced to server/user.go's
|
||||
* resolveRegistrationAccount):
|
||||
* - `login_` (createAccountBasic's first arg, becomes the secret's account half) must equal what
|
||||
* resolveRegistrationAccount computes as secretAccount -- for phone registration (and no
|
||||
* username typed) that's the E.164-normalized phone, e.g. "+8613900005232"; if a username was
|
||||
@@ -286,6 +355,12 @@ export interface RegisterParams {
|
||||
* - the injected `acc.phone` extra field is checked *before* any E.164 normalization happens
|
||||
* (`len(phone) != 11 || !asciiRegistrationDigits(phone)`) -- it must be the bare 11-digit number
|
||||
* with no "+86" prefix.
|
||||
* - `cred[].val` (the `tel` credential itself) is ALSO the bare 11-digit number, not E.164 -- every
|
||||
* one of the doc's own worked examples (docs/IM-前后端对接文档.md, both the 手机号验证方式 and
|
||||
* 仅手机号注册方式 blocks) shows `cred.val` identical to the bare `phone` field even in the exact
|
||||
* same JSON object where `secret` DOES carry the `+86`-prefixed form -- the doc is deliberately
|
||||
* inconsistent between the two, not sloppy. A missed 3rd case: an earlier version of this
|
||||
* function got the first two representations right but still E.164-prefixed this one.
|
||||
*
|
||||
* On the username-only path (no phone at all) `cred` must be left `undefined`, never a literal
|
||||
* `null` -- tinode-sdk's `Tinode.credential()` does `typeof meth == 'object'` to sniff its argument
|
||||
@@ -293,7 +368,21 @@ export interface RegisterParams {
|
||||
* "Cannot destructure property 'val' of 'meth' as it is null" instead of sending the {acc} message
|
||||
* at all (confirmed by reading the old app's own history of this exact bug).
|
||||
*/
|
||||
export async function registerAccount({
|
||||
let registerPromise: Promise<LoginCtrl> | null = null;
|
||||
|
||||
/** Deduped the same way connect()/restoreSession() are above, for the same class of reason:
|
||||
* createAccountBasic() is non-idempotent (creates a new account per call), so two overlapping
|
||||
* invocations -- e.g. a slow network letting a double-click on "注册" land before the submit
|
||||
* button's own pending-state disables it -- would otherwise send two `{acc}` packets. */
|
||||
export function registerAccount(params: RegisterParams): Promise<LoginCtrl> {
|
||||
if (registerPromise) return registerPromise;
|
||||
registerPromise = registerAccountImpl(params).finally(() => {
|
||||
registerPromise = null;
|
||||
});
|
||||
return registerPromise;
|
||||
}
|
||||
|
||||
async function registerAccountImpl({
|
||||
username,
|
||||
phone,
|
||||
password,
|
||||
@@ -311,7 +400,7 @@ export async function registerAccount({
|
||||
const secretAccount = usingPhone ? username || `+86${phone}` : username;
|
||||
const cred =
|
||||
usingPhone && verificationToken && challengeId
|
||||
? Tinode.credential({ meth: 'tel', val: `+86${phone}`, resp: verificationToken, params: { challenge_id: challengeId } })
|
||||
? Tinode.credential({ meth: 'tel', val: phone, resp: verificationToken, params: { challenge_id: challengeId } })
|
||||
: undefined;
|
||||
|
||||
const trimmedName = username.trim();
|
||||
|
||||
@@ -11,6 +11,15 @@ const CTRL_ERROR_TEXT_CN: Record<string, string> = {
|
||||
'policy violation': '当前操作不符合安全策略要求',
|
||||
'permission denied': '没有权限执行此操作,请重新登录',
|
||||
'internal error': '服务器繁忙,请稍后重试',
|
||||
// Confirmed live 2026-09-09: registering with a username or phone number that's already taken
|
||||
// returns this exact raw SDK text ("duplicate credential (409)") -- without this entry,
|
||||
// registerErrorMessage()'s fallback showed the raw English straight to a Chinese-language user.
|
||||
'duplicate credential': '该用户名或手机号已被注册,请更换后重试',
|
||||
// docs/IM-前后端对接文档.md's own worked example (`{"login":...,"secret":"其他租户 token"}` ->
|
||||
// `403 tenant mismatch`) -- without this entry, loginErrorMessage's generic `code===403` fallback
|
||||
// below shows "账号或密码错误" for what's actually a stale-token-from-a-different-tenant case, which
|
||||
// is a materially different, more confusing message (the password wasn't wrong at all).
|
||||
'tenant mismatch': '登录状态异常,请重新进入企业码',
|
||||
};
|
||||
|
||||
function translateCtrlError(err: unknown): string | null {
|
||||
@@ -20,6 +29,16 @@ function translateCtrlError(err: unknown): string | null {
|
||||
return CTRL_ERROR_TEXT_CN[normalized] || null;
|
||||
}
|
||||
|
||||
/** docs/IM-前后端对接文档.md: this specific reason means the client is holding a session token
|
||||
* issued by a DIFFERENT tenant than the one it's currently connected to -- the doc's own prescribed
|
||||
* recovery is "清 Token 并回企业码页" (clear the token and return to the tenant-code entry screen),
|
||||
* not just show an error and let the user retry the same broken state. */
|
||||
export function isTenantMismatch(err: unknown): boolean {
|
||||
const raw = (err as Error)?.message;
|
||||
if (!raw) return false;
|
||||
return raw.replace(/\s*\(\d+\)\s*$/, '').trim().toLowerCase() === 'tenant mismatch';
|
||||
}
|
||||
|
||||
export function loginErrorMessage(err: unknown): string {
|
||||
const known = translateCtrlError(err);
|
||||
if (known) return known;
|
||||
|
||||
@@ -7,6 +7,27 @@ function toDraftyContent(content: DraftyContent | string | null | undefined): Dr
|
||||
return typeof content === 'string' ? Drafty.init(content) : content;
|
||||
}
|
||||
|
||||
/**
|
||||
* docs/IM-前后端对接文档.md:486/952/1128 -- every `{pub}` must carry a client-generated
|
||||
* `x-im-client-mid` (scoped to "tenant + sender"), and a disconnect/failure retry MUST resend the
|
||||
* SAME value rather than mint a new one -- that's the field's entire purpose (line 315's own worked
|
||||
* example: resending an identical client_mid is expected to return the FIRST send's seq, not create
|
||||
* a second message). Every send helper below was missing this entirely, meaning a retry after an
|
||||
* ambiguous failure (the `{pub}` reached the server and was applied, but the `{ctrl}` ack itself
|
||||
* got lost on the way back) had no way to be deduplicated server-side and could double-post.
|
||||
* `crypto.randomUUID()` (the same primitive client.ts's own requestId() already relies on for
|
||||
* REST's Idempotency-Key) satisfies the doc's "ULID/UUID" requirement; kept local to this file
|
||||
* rather than importing requestId() across the REST/WS module boundary for what's a WS-protocol
|
||||
* concern here. Exported for attachments.ts/calls.ts's own `{pub}` sends (image/file/voice/call
|
||||
* invite), which need the identical field for the identical reason -- one source of truth for how
|
||||
* this app generates the id, not a copy in every file that happens to publish a message.
|
||||
*/
|
||||
export function newClientMid(): string {
|
||||
return typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `mid_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-text send: createMessage + publishDraft (not the simpler `.publish()`), matching the old
|
||||
* app's own choice -- publishDraft inserts the message into the local cache and fires onData
|
||||
@@ -16,7 +37,20 @@ export function sendText(topic: Topic, text: string, head?: Record<string, unkno
|
||||
const parsed = Drafty.parse(text);
|
||||
const content: DraftyContent | string = Drafty.isPlainText(parsed) ? text : parsed;
|
||||
const msg = topic.createMessage(content, false);
|
||||
if (head) msg.head = Object.assign(msg.head || {}, head);
|
||||
msg.head = Object.assign(msg.head || {}, head, { 'x-im-client-mid': newClientMid() });
|
||||
return topic.publishDraft(msg, Promise.resolve());
|
||||
}
|
||||
|
||||
/**
|
||||
* Same createMessage+publishDraft mechanics as sendText, but for content already assembled by the
|
||||
* caller (mentions.ts's `buildMentionedContent`, which splices real `Drafty.mention()` entities
|
||||
* into the plain-text draft) rather than a fresh `Drafty.parse` of raw text. Kept separate from
|
||||
* sendText rather than adding an optional param there, so every other existing call site (plain
|
||||
* P2P/group sends) is untouched.
|
||||
*/
|
||||
export function sendPrebuiltContent(topic: Topic, content: DraftyContent | string, head?: Record<string, unknown>): Promise<unknown> {
|
||||
const msg = topic.createMessage(content, false);
|
||||
msg.head = Object.assign(msg.head || {}, head, { 'x-im-client-mid': newClientMid() });
|
||||
return topic.publishDraft(msg, Promise.resolve());
|
||||
}
|
||||
|
||||
@@ -25,12 +59,20 @@ export function sendText(topic: Topic, text: string, head?: Record<string, unkno
|
||||
* the same content/head -- ported from the old app's context-menu "重试" handler (context-menu.jsx),
|
||||
* not a simple re-publish of the same object. Returns undefined if `seq` is no longer in the local
|
||||
* cache (already flushed, or never existed).
|
||||
*
|
||||
* Reuses the ORIGINAL failed message's own `x-im-client-mid` rather than minting a fresh one -- see
|
||||
* newClientMid()'s own comment above for why a retry specifically must not get a new id. Falls back
|
||||
* to a fresh one only for a locally-cached failed message from before this field existed at all
|
||||
* (there's no original id to recover in that one case).
|
||||
*/
|
||||
export function retryMessage(topic: Topic, seq: number): Promise<unknown> | undefined {
|
||||
const failed = topic.flushMessage(seq);
|
||||
if (!failed) return undefined;
|
||||
const msg = topic.createMessage((failed.content as DraftyContent | string) ?? '', false);
|
||||
if (failed.head) msg.head = Object.assign(msg.head || {}, failed.head);
|
||||
const existingMid = failed.head?.['x-im-client-mid'];
|
||||
msg.head = Object.assign(msg.head || {}, failed.head, {
|
||||
'x-im-client-mid': typeof existingMid === 'string' ? existingMid : newClientMid(),
|
||||
});
|
||||
return topic.publishDraft(msg, Promise.resolve());
|
||||
}
|
||||
|
||||
@@ -52,7 +94,7 @@ export function sendReply(topic: Topic, text: string, quoted: StoredMessage, quo
|
||||
const sanitized = Drafty.sanitizeEntities(JSON.parse(JSON.stringify(quoteBlock)) as DraftyContent);
|
||||
const finalContent = Drafty.append(sanitized, Drafty.parse(text));
|
||||
const msg = topic.createMessage(finalContent, false);
|
||||
msg.head = Object.assign(msg.head || {}, { reply: String(quoted.seq) });
|
||||
msg.head = Object.assign(msg.head || {}, { reply: String(quoted.seq), 'x-im-client-mid': newClientMid() });
|
||||
return topic.publishDraft(msg, Promise.resolve());
|
||||
}
|
||||
|
||||
@@ -66,6 +108,6 @@ export function sendReply(topic: Topic, text: string, quoted: StoredMessage, quo
|
||||
export function forwardMessage(sourceTopic: string, msg: StoredMessage, target: Topic): Promise<unknown> {
|
||||
const content = Drafty.forwardedContent(toDraftyContent(msg.content));
|
||||
const newMsg = target.createMessage(content, false);
|
||||
newMsg.head = Object.assign(newMsg.head || {}, { forwarded: `${sourceTopic}:${msg.seq}` });
|
||||
newMsg.head = Object.assign(newMsg.head || {}, { forwarded: `${sourceTopic}:${msg.seq}`, 'x-im-client-mid': newClientMid() });
|
||||
return target.publishDraft(newMsg, Promise.resolve());
|
||||
}
|
||||
|
||||
Vendored
+36
-1
@@ -73,6 +73,11 @@ declare module 'tinode-sdk' {
|
||||
export interface TopicDescPrivate {
|
||||
comment?: string;
|
||||
pinned?: boolean;
|
||||
/** Mirrored onto this same `private` object by the same `{set sub private:{...}}` write that
|
||||
* sets `pinned` (docs/IM-前后端对接文档.md:366's own worked example sets both fields together
|
||||
* in one packet) -- free to read off `me.contacts()` for the same reason `pinned` already is,
|
||||
* no extra per-topic settings fetch needed. */
|
||||
mute_notifications?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -222,10 +227,24 @@ declare module 'tinode-sdk' {
|
||||
delMessagesAll(hardDel?: boolean): Promise<unknown>;
|
||||
delMessagesList(list: number[], hardDel?: boolean): Promise<unknown>;
|
||||
|
||||
/**
|
||||
* Thin wrapper around `Tinode.videoCall` (tinode.dev.js:5074-5079, 7343-7350) -- builds and sends
|
||||
* a `{note topic, seq, what:"call", event, payload}` packet. Guards `!this._attached` client-side
|
||||
* EXCEPT for `ringing`/`hang-up`, which the SDK allows even before the topic is subscribed (a
|
||||
* callee receiving a fresh invite hasn't necessarily subscribed to the caller's P2P topic yet).
|
||||
* `event` is the doc's confirmed whitelist (§0.5.4.7): `ringing|accept|hang-up` for this app
|
||||
* (offer/answer/ice-candidate are NOT used -- see calls.ts's own header comment on why this app
|
||||
* uses the Agora/rtc-join media path instead of raw SDP/ICE over this channel). Always pass a
|
||||
* real `{callId, ...}` payload -- confirmed live the doc's protocol requires it on every note,
|
||||
* unlike the old app's real code (which the SDK lets send `undefined`, but the new backend
|
||||
* doesn't accept that).
|
||||
*/
|
||||
videoCall(event: 'ringing' | 'accept' | 'hang-up', seq: number, payload: Record<string, unknown>): Promise<unknown> | undefined;
|
||||
|
||||
/** Single-slot callbacks the app wires fresh per "currently open" topic and tears down on leave. */
|
||||
onData?: (data?: StoredMessage) => void;
|
||||
onAllMessagesReceived?: (count: number) => void;
|
||||
onInfo?: (info: { what: 'read' | 'recv' | 'kp' | 'call'; seq?: number; from?: string }) => void;
|
||||
onInfo?: (info: { what: 'read' | 'recv' | 'kp' | 'call'; seq?: number; from?: string; event?: string; payload?: Record<string, unknown> }) => void;
|
||||
onMetaDesc?: (desc: unknown) => void;
|
||||
onSubsUpdated?: (subs: string[], count?: number) => void;
|
||||
onPres?: (pres: unknown) => void;
|
||||
@@ -263,6 +282,10 @@ declare module 'tinode-sdk' {
|
||||
isConnected(): boolean;
|
||||
isAuthenticated(): boolean;
|
||||
getCurrentUserID(): string;
|
||||
/** The login credential username (not the display nickname) -- needed as the unchanged
|
||||
* passthrough value when calling `updateAccountBasic` to change ONLY the password (confirmed
|
||||
* against the old app's real working code, tinode-web.jsx:1964). */
|
||||
getCurrentLogin(): string;
|
||||
|
||||
login(scheme: string, secret: string, cred?: unknown): Promise<LoginCtrl>;
|
||||
loginBasic(username: string, password: string, cred?: unknown): Promise<LoginCtrl>;
|
||||
@@ -278,6 +301,18 @@ declare module 'tinode-sdk' {
|
||||
password: string,
|
||||
params: { public?: TopicDescPublic; tags?: string[]; cred?: CredentialParam[]; attachments?: string[] },
|
||||
): Promise<LoginCtrl>;
|
||||
/** Native `{acc}` account update -- there is no REST endpoint for changing password while
|
||||
* logged in (confirmed: doc only has the logged-out SMS reset flow), so this is the only real
|
||||
* path. Pass `uid: null` for "myself" and `username` unchanged (`getCurrentLogin()`) to change
|
||||
* only the password -- confirmed against the old app's real working call site. Server does NOT
|
||||
* verify any "current password" (confirmed: personal-settings-view.jsx's own research comment;
|
||||
* a client-side "原密码" field is a fat-finger speed bump only, not a real credential check). */
|
||||
updateAccountBasic(uid: string | null, username: string | null, password: string, params?: unknown): Promise<unknown>;
|
||||
/** Native `{del what:"user" hard:true}` -- there is no REST endpoint for account deletion
|
||||
* (confirmed absent from both doc copies and the backend problem list). No cooldown/soft-
|
||||
* delete and no password re-check server-side (confirmed against server/user.go per the old
|
||||
* app's own research comment) -- the client-side confirm dialog is the only safety net. */
|
||||
delCurrentUser(hard: boolean): Promise<void>;
|
||||
|
||||
/** Wraps a single {meth,val,resp,params} credential into the array shape {acc}/createAccountBasic expects. */
|
||||
static credential(cred: CredentialInput): CredentialParam[] | null;
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { Contact, StoredMessage, Topic } from 'tinode-sdk';
|
||||
|
||||
import { MESSAGES_PAGE } from '@/lib/config';
|
||||
|
||||
import { getTinode } from './connection';
|
||||
import { getTinode, onReauthenticated } from './connection';
|
||||
|
||||
const topicListeners = new Map<string, Set<() => void>>();
|
||||
const anyTopicListeners = new Set<() => void>();
|
||||
@@ -45,11 +45,33 @@ export function subscribeAnyTopicChange(callback: () => void): () => void {
|
||||
return () => anyTopicListeners.delete(callback);
|
||||
}
|
||||
|
||||
export interface RawTopicInfo {
|
||||
what: 'read' | 'recv' | 'kp' | 'call';
|
||||
seq?: number;
|
||||
from?: string;
|
||||
event?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
const infoListeners = new Set<(topicName: string, info: RawTopicInfo) => void>();
|
||||
|
||||
/** Raw `{info}` payloads (read/recv/kp/call), forwarded verbatim -- unlike `subscribeAnyTopicChange`/
|
||||
* `subscribeTopicChanges`, which only signal "something changed, re-read the snapshot" for the
|
||||
* chat-list/message-list hooks, calls.ts needs the actual `event`/`payload` fields off a `what:
|
||||
* "call"` info to drive its own ringing/accept/hang-up state machine -- those aren't reconstructable
|
||||
* from a message snapshot re-read the way a new chat message is. */
|
||||
export function subscribeTopicInfo(callback: (topicName: string, info: RawTopicInfo) => void): () => void {
|
||||
infoListeners.add(callback);
|
||||
return () => infoListeners.delete(callback);
|
||||
}
|
||||
|
||||
function wireTopicCallbacks(topic: Topic) {
|
||||
if (wiredTopics.has(topic)) return;
|
||||
const notify = () => notifyTopic(topic.name);
|
||||
topic.onData = notify;
|
||||
topic.onInfo = notify;
|
||||
topic.onInfo = (info) => {
|
||||
notify();
|
||||
if (info) infoListeners.forEach((cb) => cb(topic.name, info));
|
||||
};
|
||||
topic.onMetaDesc = notify;
|
||||
topic.onSubsUpdated = notify;
|
||||
topic.onPres = notify;
|
||||
@@ -220,3 +242,34 @@ function invalidateChatListSnapshot() {
|
||||
chatListSnapshot = null;
|
||||
}
|
||||
anyTopicListeners.add(invalidateChatListSnapshot);
|
||||
|
||||
/**
|
||||
* Re-fires the `me` topic subscribe and every contact's preview subscribe once a WS reconnect has
|
||||
* successfully re-authenticated (connection.ts's onReauthenticated). Bypasses subscribeMeTopic's/
|
||||
* prefetchPreview's own "already done" guards on purpose -- those guards exist to stop a second
|
||||
* *concurrent* call from firing a redundant `{sub}` while one is already in flight, not to reflect
|
||||
* whether the topic is still genuinely attached, and after any disconnect it never is (the SDK
|
||||
* resets `_attached` on every cached topic, confirmed by reading tinode.dev.js's #disconnected()).
|
||||
* `Topic.subscribe()` itself only short-circuits when `_attached` is still true, so calling it again
|
||||
* here for an already-detached topic genuinely resends `{sub}`, not a silent no-op.
|
||||
*
|
||||
* Deliberately scoped to topics this module already knows about (the `me` topic + everything
|
||||
* prefetchPreview has ever touched) rather than tracking "the one conversation currently open" as
|
||||
* separate state -- in practice the chat list and an open conversation are mounted together in this
|
||||
* app's layout, so the open topic's contact row has already gone through prefetchPreview by the time
|
||||
* anyone could be looking at it. A conversation reached by a deep link that never rendered the chat
|
||||
* list at all is the one case this doesn't cover; also out of scope: backfilling any messages that
|
||||
* were sent by the peer during the drop itself -- this only resumes *live* updates going forward,
|
||||
* it doesn't gap-fill what was missed while disconnected.
|
||||
*/
|
||||
onReauthenticated(() => {
|
||||
const me = getTinode().getMeTopic();
|
||||
wireTopicCallbacks(me);
|
||||
me.onContactUpdate = () => notifyTopic(me.name);
|
||||
me.subscribe(me.startMetaQuery().withLaterSub().withDesc().withTags().withCred().build()).catch(() => {});
|
||||
for (const topicName of previewPrefetched) {
|
||||
const topic = getTinode().getTopic(topicName);
|
||||
wireTopicCallbacks(topic);
|
||||
topic.subscribe(topic.startMetaQuery().withLaterData(1).build()).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user