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:
Your Name
2026-09-09 20:16:33 +08:00
co-authored by Claude Sonnet 5
parent 72b7b79caf
commit 5c3f3b7653
89 changed files with 10158 additions and 442 deletions
+4 -3
View File
@@ -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());
}
+79
View File
@@ -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 });
}
+94 -5
View File
@@ -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();
+19
View File
@@ -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;
+46 -4
View File
@@ -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());
}
+36 -1
View File
@@ -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;
+55 -2
View File
@@ -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(() => {});
}
});