diff --git a/web/src/app/RequireAuth.tsx b/web/src/app/RequireAuth.tsx
index 16a7b4e1..6a74f565 100644
--- a/web/src/app/RequireAuth.tsx
+++ b/web/src/app/RequireAuth.tsx
@@ -1,13 +1,54 @@
+import { useEffect, useState } from 'react';
+
import { Navigate, Outlet } from 'react-router-dom';
+import { restoreSession } from '@/lib/tinode/connection';
+import { subscribeMeTopic } from '@/lib/tinode/topics';
import { useSessionStore } from '@/stores/session';
-/** Gate for everything inside AppShell: needs a resolved tenant and an authenticated user. */
+/**
+ * Gate for everything inside AppShell: needs a resolved tenant and an authenticated user.
+ *
+ * `tenant` survives a same-tab reload (persisted, see stores/session.ts) but `myUserId` doesn't --
+ * it used to always come back `null` on reload, bouncing straight to /login even though every
+ * login/register already writes a perfectly good reusable token to `sessionStorage['im-session-
+ * token']` for exactly this case. This re-derives `myUserId` from that token via connection.ts's
+ * restoreSession() (deduped against React 19 StrictMode's double-invoke in dev -- see its own
+ * comment) before giving up and redirecting, instead of ignoring the token entirely.
+ */
export function RequireAuth() {
const tenant = useSessionStore((s) => s.tenant);
const myUserId = useSessionStore((s) => s.myUserId);
+ const setMyUserId = useSessionStore((s) => s.setMyUserId);
+ const [hasToken] = useState(() => !!sessionStorage.getItem('im-session-token'));
+ const [restoreFailed, setRestoreFailed] = useState(false);
+
+ useEffect(() => {
+ if (myUserId || !tenant || !hasToken) return undefined;
+ const token = sessionStorage.getItem('im-session-token');
+ if (!token) return undefined;
+ let cancelled = false;
+ restoreSession(token)
+ .then((uid) => {
+ if (cancelled) return;
+ setMyUserId(uid);
+ subscribeMeTopic().catch(() => {});
+ })
+ .catch(() => {
+ if (!cancelled) {
+ sessionStorage.removeItem('im-session-token');
+ setRestoreFailed(true);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [tenant, myUserId, hasToken, setMyUserId]);
if (!tenant) return ;
- if (!myUserId) return ;
- return ;
+ if (myUserId) return ;
+ if (hasToken && !restoreFailed) {
+ return
正在恢复登录状态…
;
+ }
+ return ;
}
diff --git a/web/src/features/auth/components/Toast.tsx b/web/src/features/auth/components/Toast.tsx
deleted file mode 100644
index c98663c9..00000000
--- a/web/src/features/auth/components/Toast.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { useEffect } from 'react';
-
-import { Icon } from '@/app/Icon';
-import { TOAST_DURATION } from '@/lib/config';
-
-interface ToastProps {
- message: string;
- onDone: () => void;
-}
-
-export function Toast({ message, onDone }: ToastProps) {
- useEffect(() => {
- if (!message) return undefined;
- const timer = window.setTimeout(onDone, TOAST_DURATION);
- return () => window.clearTimeout(timer);
- }, [message, onDone]);
-
- if (!message) return null;
-
- return (
-
-
- {message}
-
- );
-}
diff --git a/web/src/lib/api/bootstrap.ts b/web/src/lib/api/bootstrap.ts
index 20e3bc07..6a2217cc 100644
--- a/web/src/lib/api/bootstrap.ts
+++ b/web/src/lib/api/bootstrap.ts
@@ -21,12 +21,19 @@ export interface BootstrapEntry {
route: string;
topic?: string;
unread_count: number;
+ /** e.g. "topic" (file_assistant) vs "business" (system_messages) -- which side actually owns the
+ * unread count above, confirmed live but not consumed by anything yet. */
+ unread_source?: string;
}
export interface BootstrapConfig {
message_policy?: MessagePolicy;
limits?: { max_forward_targets?: number; [key: string]: unknown };
security?: { realname?: RealnameStatus };
+ features?: Record;
+ permissions?: Record;
+ modules?: unknown;
+ workbench_version?: unknown;
entries?: {
new_friends?: BootstrapEntry;
groups?: BootstrapEntry;
@@ -39,6 +46,7 @@ export interface BootstrapConfig {
file_assistant_unread_count?: number;
system_unread_count?: number;
workbench_total_unread?: number;
+ contacts_total_unread?: number;
};
system_topic?: { topic: string; type: string };
[key: string]: unknown;
diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts
index 76a0de63..2e7a912c 100644
--- a/web/src/lib/api/client.ts
+++ b/web/src/lib/api/client.ts
@@ -179,15 +179,17 @@ export async function imRequest(
const idem = () => ({ 'Idempotency-Key': requestId() });
+// `opts.headers` is merged ON TOP of idem() (not replaced by the outer `...opts` spread) so a
+// caller that needs its own extra header can't accidentally drop the Idempotency-Key.
export const imGet = (path: string, opts?: RequestOptions) => imRequest(path, opts);
export const imPost = (path: string, body?: unknown, opts: RequestOptions = {}) =>
- imRequest(path, { method: 'POST', body, headers: idem(), ...opts });
+ imRequest(path, { method: 'POST', body, ...opts, headers: { ...idem(), ...opts.headers } });
export const imPatch = (path: string, body?: unknown, opts: RequestOptions = {}) =>
- imRequest(path, { method: 'PATCH', body, headers: idem(), ...opts });
+ imRequest(path, { method: 'PATCH', body, ...opts, headers: { ...idem(), ...opts.headers } });
export const imPut = (path: string, body?: unknown, opts: RequestOptions = {}) =>
- imRequest(path, { method: 'PUT', body, headers: idem(), ...opts });
+ imRequest(path, { method: 'PUT', body, ...opts, headers: { ...idem(), ...opts.headers } });
export const imDelete = (path: string, opts: RequestOptions = {}) =>
- imRequest(path, { method: 'DELETE', headers: idem(), ...opts });
+ imRequest(path, { method: 'DELETE', ...opts, headers: { ...idem(), ...opts.headers } });
/**
* List endpoints here cap out at 20-50 items per page and it's a real, previously-hit bug class to
diff --git a/web/src/lib/api/friends.ts b/web/src/lib/api/friends.ts
index 78ff2a8d..c40d3d3c 100644
--- a/web/src/lib/api/friends.ts
+++ b/web/src/lib/api/friends.ts
@@ -114,6 +114,6 @@ export function unblockUser(userId: string) {
return imDelete(`/blocks/${encodeURIComponent(userId)}`);
}
-export function getBlocks() {
- return imGet<{ items: Friend[]; next_cursor?: string }>('/blocks');
+export function getBlocks(cursor = '') {
+ return imGet<{ items: Friend[]; next_cursor?: string }>(`/blocks${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`);
}
diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts
index 5cd6001d..50846543 100644
--- a/web/src/lib/config.ts
+++ b/web/src/lib/config.ts
@@ -19,7 +19,10 @@ export const API_KEY = import.meta.env.VITE_TINODE_API_KEY || 'AQEAAAABAAD_rAp4D
export const KNOWN_HOSTS = { hosted: 'web.118im.com', local: 'localhost:6060' } as const;
export const DEFAULT_HOST = KNOWN_HOSTS.hosted;
-export const LOGGING_ENABLED = true;
+// Tinode's own verbose logging prints every WS packet to the console, including the login
+// secret (base64 username:password) and full plaintext message content -- dev-only, never in a
+// production build.
+export const LOGGING_ENABLED = import.meta.env.DEV;
/** Minimum time between two keypress notifications, ms. */
export const KEYPRESS_DELAY = 3_000;
diff --git a/web/src/lib/hooks.ts b/web/src/lib/hooks.ts
index 01ff3d98..88dc9ddb 100644
--- a/web/src/lib/hooks.ts
+++ b/web/src/lib/hooks.ts
@@ -63,3 +63,15 @@ export function useMediaQuery(query: string): boolean {
}, [query]);
return matches;
}
+
+/** 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. */
+export function useEscapeKey(onEscape: () => void): void {
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onEscape();
+ };
+ window.addEventListener('keydown', handler);
+ return () => window.removeEventListener('keydown', handler);
+ }, [onEscape]);
+}
diff --git a/web/src/lib/messages/actions.ts b/web/src/lib/messages/actions.ts
index a2b1a2db..d8e3a5a6 100644
--- a/web/src/lib/messages/actions.ts
+++ b/web/src/lib/messages/actions.ts
@@ -9,6 +9,21 @@ const LOCAL_SEQID = 0xfffffff;
export type MessageAction = 'copy' | 'quote' | 'favorite' | 'forward' | 'recall' | 'delete' | 'select';
+/**
+ * Seconds left in which `msg` can still be recalled, or `null` if it can't be recalled at all
+ * (not the sender, not yet server-confirmed, no window configured, or the window already lapsed).
+ * Split out from availableActions() so the context menu can also render the "1:42 内" countdown
+ * README §3.5 calls for, not just decide whether to show the action.
+ */
+export function recallRemainingSeconds(msg: StoredMessage, myUserId: string | null, messagePolicy: MessagePolicy | undefined): number | null {
+ const mine = !!myUserId && msg.from === myUserId;
+ const isConfirmed = typeof msg.seq === 'number' && msg.seq < LOCAL_SEQID;
+ const recallWindow = messagePolicy?.recall_window_seconds ?? 0;
+ if (!mine || !isConfirmed || recallWindow <= 0 || !msg.ts) return null;
+ const remaining = recallWindow - Math.floor((Date.now() - msg.ts.getTime()) / 1000);
+ return remaining > 0 ? remaining : null;
+}
+
/**
* Which context-menu actions apply to one message. Scoped to P2P for now (no group-admin recall,
* no self-topic hard-delete-always -- those need group role/topic-type knowledge Phase 3 will add).
@@ -19,10 +34,11 @@ export type MessageAction = 'copy' | 'quote' | 'favorite' | 'forward' | 'recall'
export function availableActions(msg: StoredMessage, myUserId: string | null, messagePolicy: MessagePolicy | undefined): MessageAction[] {
if (isServerDeleted(msg) || isRecalled(msg)) return [];
const actions: MessageAction[] = ['quote', 'copy', 'favorite', 'forward'];
- const mine = !!myUserId && msg.from === myUserId;
- const isConfirmed = typeof msg.seq === 'number' && msg.seq < LOCAL_SEQID;
- const recallWindow = messagePolicy?.recall_window_seconds ?? 0;
- if (mine && isConfirmed && recallWindow > 0 && msg.ts && msg.ts.getTime() > Date.now() - recallWindow * 1000) {
+ // Defer to the server's own supported_actions list when it's present, so a server-side policy
+ // change (recall disabled tenant-wide, say) doesn't leave the client offering an action that
+ // will just 400 when tapped -- the time-window check below still applies on top of this.
+ const serverAllowsRecall = !messagePolicy?.supported_actions || messagePolicy.supported_actions.includes('recall');
+ if (serverAllowsRecall && recallRemainingSeconds(msg, myUserId, messagePolicy) !== null) {
actions.push('recall');
}
actions.push('delete', 'select');
diff --git a/web/src/lib/messages/preview.ts b/web/src/lib/messages/preview.ts
index f241a8f8..2a577ac8 100644
--- a/web/src/lib/messages/preview.ts
+++ b/web/src/lib/messages/preview.ts
@@ -27,8 +27,8 @@ export function formatMessagePreview(msg: StoredMessage | undefined, myUserId: s
return `${prefix}[视频]`;
case 'AU': {
const durationMs = typeof firstEnt.data?.duration === 'number' ? firstEnt.data.duration : 0;
- const seconds = Math.round(durationMs / 1000);
- return `${prefix}[语音] 0:${String(seconds).padStart(2, '0')}`;
+ const totalSeconds = Math.round(durationMs / 1000);
+ return `${prefix}[语音] ${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
}
case 'EX':
return `${prefix}[文件] ${typeof firstEnt.data?.name === 'string' ? firstEnt.data.name : ''}`;
diff --git a/web/src/lib/tinode/connection.ts b/web/src/lib/tinode/connection.ts
index 2bf3b8eb..9627fe06 100644
--- a/web/src/lib/tinode/connection.ts
+++ b/web/src/lib/tinode/connection.ts
@@ -9,7 +9,7 @@
// useSyncExternalStore-based hooks actually subscribe to.
import { Tinode, type LoginCtrl } from 'tinode-sdk';
-import { API_KEY, APP_NAME, LOGGING_ENABLED } from '@/lib/config';
+import { API_KEY, APP_NAME, DEFAULT_HOST, LOGGING_ENABLED } from '@/lib/config';
import { useSessionStore } from '@/stores/session';
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'authenticated';
@@ -138,7 +138,7 @@ function installTenantHiPatch(t: Tinode) {
function createTinode(): Tinode {
const tenant = useSessionStore.getState().tenant;
- const host = tenant?.host || 'web.118im.com';
+ const host = tenant?.host || DEFAULT_HOST;
const secure = tenant?.secure ?? true;
const t = new Tinode({ appName: APP_NAME, host, apiKey: API_KEY, transport: 'ws', secure, persist: false });
t.setHumanLanguage('zh');
@@ -198,11 +198,49 @@ export function resetTinode() {
setStatus('disconnected');
}
-export async function connect(): Promise {
+/**
+ * Tracked so two overlapping callers (e.g. React 19 StrictMode running an effect twice in dev, or
+ * RequireAuth's session restore and a LoginPage submit racing) share one in-flight connect() instead
+ * of each calling `t.connect()` -- the SDK doesn't dedupe this itself, and a second `t.connect()`
+ * while the first is still mid-handshake tears down and replaces that first WebSocket outright
+ * (confirmed live: "WebSocket is closed before the connection is established"), which can abort a
+ * `loginToken()` that was riding on the first connection.
+ */
+let connectPromise: Promise | null = null;
+
+export function connect(): Promise {
const t = getTinode();
- if (t.isConnected()) return;
+ if (t.isConnected()) return Promise.resolve();
+ if (connectPromise) return connectPromise;
setStatus('connecting');
- await t.connect();
+ connectPromise = t.connect().finally(() => {
+ connectPromise = null;
+ });
+ return connectPromise;
+}
+
+/**
+ * Restores a session from a token written to sessionStorage by a previous login (RequireAuth.tsx,
+ * on mount, when `myUserId` hasn't survived a same-tab reload). Deduped the same way connect() is
+ * above and for the same reason: `Tinode.loginToken()` isn't idempotent (a second login on an
+ * already-authenticated connection gets rejected with `409 already authenticated`), so React 19
+ * StrictMode running RequireAuth's restore effect twice in dev would otherwise send the SAME token
+ * twice and treat the resulting 409 as "this token is bad" -- discarding a token that had, a moment
+ * earlier on the very same connection, just been accepted.
+ */
+let restorePromise: Promise | null = null;
+
+export function restoreSession(token: string): Promise {
+ if (restorePromise) return restorePromise;
+ restorePromise = (async () => {
+ await connect();
+ const ctrl = await getTinode().loginToken(token);
+ if (ctrl.code >= 300) throw new Error('session token rejected');
+ return getTinode().getCurrentUserID() || null;
+ })().finally(() => {
+ restorePromise = null;
+ });
+ return restorePromise;
}
export function getConnectionStatus(): ConnectionStatus {
diff --git a/web/src/lib/tinode/topics.ts b/web/src/lib/tinode/topics.ts
index c5c839c3..8af5e4db 100644
--- a/web/src/lib/tinode/topics.ts
+++ b/web/src/lib/tinode/topics.ts
@@ -98,6 +98,17 @@ export function prefetchPreview(topicName: string): void {
});
}
+/**
+ * The SDK has no per-topic in-flight tracking of its own: `Topic.subscribe()` only short-circuits
+ * once `_attached` is already true, so two overlapping calls (React 19 StrictMode's mount->cleanup->
+ * remount runs every effect twice in dev, so openConversation() always overlaps with itself once per
+ * open; ChatListColumn's prefetchPreview and this both racing for the same just-deep-linked topic is
+ * the same shape in prod) would otherwise send two `{sub}` packets, and a `leave()` that lands before
+ * the first `subscribe()` resolves just rejects ("Cannot leave inactive topic") and no-ops instead of
+ * detaching. Tracked here so every caller for the same topic name shares one in-flight promise.
+ */
+const inFlightSubscribe = new Map>();
+
/**
* Opens a topic for full conversation view: fetches a page of history and wires live updates.
* Uses withData(undefined, undefined, limit), NOT withLaterData(limit) -- if prefetchPreview already
@@ -108,20 +119,33 @@ export function prefetchPreview(topicName: string): void {
* at all until the user's first send (avoiding server-side subscription state for chats opened but
* never used) -- not yet replicated here since the new-conversation picker flow doesn't exist yet.
*/
-export async function openConversation(topicName: string): Promise {
- const topic = getTinode().getTopic(topicName);
- wireTopicCallbacks(topic);
- if (topic.isSubscribed()) {
- await topic.leave(false);
- }
- const query = topic.startMetaQuery().withLaterDesc().withLaterSub().withAux().withData(undefined, undefined, MESSAGES_PAGE);
- await topic.subscribe(query.build());
- return topic;
+export function openConversation(topicName: string): Promise {
+ const existing = inFlightSubscribe.get(topicName);
+ if (existing) return existing;
+ const promise = (async () => {
+ const topic = getTinode().getTopic(topicName);
+ wireTopicCallbacks(topic);
+ if (topic.isSubscribed()) {
+ await topic.leave(false);
+ }
+ const query = topic.startMetaQuery().withLaterDesc().withLaterSub().withAux().withData(undefined, undefined, MESSAGES_PAGE);
+ await topic.subscribe(query.build());
+ return topic;
+ })();
+ inFlightSubscribe.set(topicName, promise);
+ promise.finally(() => {
+ if (inFlightSubscribe.get(topicName) === promise) inFlightSubscribe.delete(topicName);
+ });
+ return promise;
}
-/** Soft-leave (detach, not unsubscribe) -- cached messages stay in the SDK's local buffer. */
-export function closeConversation(topicName: string): void {
- getTinode().getTopic(topicName).leave(false).catch(() => {});
+/** Soft-leave (detach, not unsubscribe) -- cached messages stay in the SDK's local buffer. Waits out
+ * any in-flight openConversation() for this same topic first, so a cleanup that fires before the
+ * subscribe it's undoing has even resolved doesn't just silently no-op (see inFlightSubscribe above). */
+export async function closeConversation(topicName: string): Promise {
+ const pending = inFlightSubscribe.get(topicName);
+ if (pending) await pending.catch(() => {});
+ await getTinode().getTopic(topicName).leave(false).catch(() => {});
}
let chatListSnapshot: Contact[] | null = null;
diff --git a/web/src/stores/session.ts b/web/src/stores/session.ts
index 4fc9940f..3089166e 100644
--- a/web/src/stores/session.ts
+++ b/web/src/stores/session.ts
@@ -21,9 +21,10 @@ interface SessionState {
/**
* Tenant context persists across browser restarts (localStorage) the same way the old app kept
* it, since a returning user's auto-login still needs it to build the {hi} packet and every REST
- * call's X-Tenant-Code header. `myUserId` is *not* persisted here -- it's re-derived from the
- * Tinode connection's own auth state on reconnect, not something to trust stale from a previous
- * session.
+ * call's X-Tenant-Code header. `myUserId` is *not* persisted here -- trusting a stale uid from a
+ * previous session without re-authenticating would be wrong. Instead, app/RequireAuth.tsx re-derives
+ * it on mount via `Tinode.loginToken()` against the reusable session token LoginPage/RegisterPage
+ * write to `sessionStorage`, which is the actual thing that should survive a same-tab reload.
*/
export const useSessionStore = create()(
persist(