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
@@ -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