fix(web): code review round 2 -- critical session/connection races
- RequireAuth: a page reload lost the session even with a valid token sitting in sessionStorage -- myUserId is never persisted (by design) but nothing ever re-derived it from the reusable login token. Added connection.ts's restoreSession(), which also fixes the race the first attempt at this ran into live: React 19 StrictMode double-invokes the restore effect in dev, and Tinode.loginToken() isn't idempotent (second call on an already-authenticated connection gets 409'd), so both the connect() and the login itself needed module-level in-flight dedup, not just a per-effect cancelled flag - topics.ts: same dedup pattern for openConversation/closeConversation -- StrictMode's mount->cleanup->remount always overlapped an open with itself, and a leave() landing before its own subscribe() resolved was silently no-op'ing instead of detaching - LOGGING_ENABLED was unconditionally on, including prod builds -- Tinode's verbose logging prints the raw login secret and full message content to the console - imPost/imPatch/imPut/imDelete could silently drop the Idempotency-Key header for a caller that passes its own headers (object-spread order bug, dormant today since no call site does this yet) - DetailColumn's getFriends()/getBlocks() only ever read page 1 despite fetchAllPages existing specifically to prevent this bug class - availableActions() now defers to the server's message_policy. supported_actions when present instead of only a client-side time window, and exposes recallRemainingSeconds for the context menu's countdown label Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
1bd8d7f804
commit
d3a0eb0dbc
@@ -1,13 +1,54 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Navigate, Outlet } from 'react-router-dom';
|
import { Navigate, Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { restoreSession } from '@/lib/tinode/connection';
|
||||||
|
import { subscribeMeTopic } from '@/lib/tinode/topics';
|
||||||
import { useSessionStore } from '@/stores/session';
|
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() {
|
export function RequireAuth() {
|
||||||
const tenant = useSessionStore((s) => s.tenant);
|
const tenant = useSessionStore((s) => s.tenant);
|
||||||
const myUserId = useSessionStore((s) => s.myUserId);
|
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 <Navigate to="/tenant" replace />;
|
if (!tenant) return <Navigate to="/tenant" replace />;
|
||||||
if (!myUserId) return <Navigate to="/login" replace />;
|
if (myUserId) return <Outlet />;
|
||||||
return <Outlet />;
|
if (hasToken && !restoreFailed) {
|
||||||
|
return <div className="grid h-dvh place-items-center bg-surface-page text-sm text-text-tertiary-3">正在恢复登录状态…</div>;
|
||||||
|
}
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<div
|
|
||||||
role="status"
|
|
||||||
className="fixed bottom-8 left-1/2 flex -translate-x-1/2 items-center gap-2 rounded-lg bg-[#1f2329] px-4 py-3 text-sm text-white shadow-modal"
|
|
||||||
>
|
|
||||||
<Icon name="check_circle" className="text-[18px] text-success" />
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -21,12 +21,19 @@ export interface BootstrapEntry {
|
|||||||
route: string;
|
route: string;
|
||||||
topic?: string;
|
topic?: string;
|
||||||
unread_count: number;
|
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 {
|
export interface BootstrapConfig {
|
||||||
message_policy?: MessagePolicy;
|
message_policy?: MessagePolicy;
|
||||||
limits?: { max_forward_targets?: number; [key: string]: unknown };
|
limits?: { max_forward_targets?: number; [key: string]: unknown };
|
||||||
security?: { realname?: RealnameStatus };
|
security?: { realname?: RealnameStatus };
|
||||||
|
features?: Record<string, boolean>;
|
||||||
|
permissions?: Record<string, boolean>;
|
||||||
|
modules?: unknown;
|
||||||
|
workbench_version?: unknown;
|
||||||
entries?: {
|
entries?: {
|
||||||
new_friends?: BootstrapEntry;
|
new_friends?: BootstrapEntry;
|
||||||
groups?: BootstrapEntry;
|
groups?: BootstrapEntry;
|
||||||
@@ -39,6 +46,7 @@ export interface BootstrapConfig {
|
|||||||
file_assistant_unread_count?: number;
|
file_assistant_unread_count?: number;
|
||||||
system_unread_count?: number;
|
system_unread_count?: number;
|
||||||
workbench_total_unread?: number;
|
workbench_total_unread?: number;
|
||||||
|
contacts_total_unread?: number;
|
||||||
};
|
};
|
||||||
system_topic?: { topic: string; type: string };
|
system_topic?: { topic: string; type: string };
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
|
|||||||
@@ -179,15 +179,17 @@ export async function imRequest<T = unknown>(
|
|||||||
|
|
||||||
const idem = () => ({ 'Idempotency-Key': requestId() });
|
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 = <T = unknown>(path: string, opts?: RequestOptions) => imRequest<T>(path, opts);
|
export const imGet = <T = unknown>(path: string, opts?: RequestOptions) => imRequest<T>(path, opts);
|
||||||
export const imPost = <T = unknown>(path: string, body?: unknown, opts: RequestOptions = {}) =>
|
export const imPost = <T = unknown>(path: string, body?: unknown, opts: RequestOptions = {}) =>
|
||||||
imRequest<T>(path, { method: 'POST', body, headers: idem(), ...opts });
|
imRequest<T>(path, { method: 'POST', body, ...opts, headers: { ...idem(), ...opts.headers } });
|
||||||
export const imPatch = <T = unknown>(path: string, body?: unknown, opts: RequestOptions = {}) =>
|
export const imPatch = <T = unknown>(path: string, body?: unknown, opts: RequestOptions = {}) =>
|
||||||
imRequest<T>(path, { method: 'PATCH', body, headers: idem(), ...opts });
|
imRequest<T>(path, { method: 'PATCH', body, ...opts, headers: { ...idem(), ...opts.headers } });
|
||||||
export const imPut = <T = unknown>(path: string, body?: unknown, opts: RequestOptions = {}) =>
|
export const imPut = <T = unknown>(path: string, body?: unknown, opts: RequestOptions = {}) =>
|
||||||
imRequest<T>(path, { method: 'PUT', body, headers: idem(), ...opts });
|
imRequest<T>(path, { method: 'PUT', body, ...opts, headers: { ...idem(), ...opts.headers } });
|
||||||
export const imDelete = <T = unknown>(path: string, opts: RequestOptions = {}) =>
|
export const imDelete = <T = unknown>(path: string, opts: RequestOptions = {}) =>
|
||||||
imRequest<T>(path, { method: 'DELETE', headers: idem(), ...opts });
|
imRequest<T>(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
|
* List endpoints here cap out at 20-50 items per page and it's a real, previously-hit bug class to
|
||||||
|
|||||||
@@ -114,6 +114,6 @@ export function unblockUser(userId: string) {
|
|||||||
return imDelete<void>(`/blocks/${encodeURIComponent(userId)}`);
|
return imDelete<void>(`/blocks/${encodeURIComponent(userId)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBlocks() {
|
export function getBlocks(cursor = '') {
|
||||||
return imGet<{ items: Friend[]; next_cursor?: string }>('/blocks');
|
return imGet<{ items: Friend[]; next_cursor?: string }>(`/blocks${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 KNOWN_HOSTS = { hosted: 'web.118im.com', local: 'localhost:6060' } as const;
|
||||||
export const DEFAULT_HOST = KNOWN_HOSTS.hosted;
|
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. */
|
/** Minimum time between two keypress notifications, ms. */
|
||||||
export const KEYPRESS_DELAY = 3_000;
|
export const KEYPRESS_DELAY = 3_000;
|
||||||
|
|||||||
@@ -63,3 +63,15 @@ export function useMediaQuery(query: string): boolean {
|
|||||||
}, [query]);
|
}, [query]);
|
||||||
return matches;
|
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]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ const LOCAL_SEQID = 0xfffffff;
|
|||||||
|
|
||||||
export type MessageAction = 'copy' | 'quote' | 'favorite' | 'forward' | 'recall' | 'delete' | 'select';
|
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,
|
* 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).
|
* 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[] {
|
export function availableActions(msg: StoredMessage, myUserId: string | null, messagePolicy: MessagePolicy | undefined): MessageAction[] {
|
||||||
if (isServerDeleted(msg) || isRecalled(msg)) return [];
|
if (isServerDeleted(msg) || isRecalled(msg)) return [];
|
||||||
const actions: MessageAction[] = ['quote', 'copy', 'favorite', 'forward'];
|
const actions: MessageAction[] = ['quote', 'copy', 'favorite', 'forward'];
|
||||||
const mine = !!myUserId && msg.from === myUserId;
|
// Defer to the server's own supported_actions list when it's present, so a server-side policy
|
||||||
const isConfirmed = typeof msg.seq === 'number' && msg.seq < LOCAL_SEQID;
|
// change (recall disabled tenant-wide, say) doesn't leave the client offering an action that
|
||||||
const recallWindow = messagePolicy?.recall_window_seconds ?? 0;
|
// will just 400 when tapped -- the time-window check below still applies on top of this.
|
||||||
if (mine && isConfirmed && recallWindow > 0 && msg.ts && msg.ts.getTime() > Date.now() - recallWindow * 1000) {
|
const serverAllowsRecall = !messagePolicy?.supported_actions || messagePolicy.supported_actions.includes('recall');
|
||||||
|
if (serverAllowsRecall && recallRemainingSeconds(msg, myUserId, messagePolicy) !== null) {
|
||||||
actions.push('recall');
|
actions.push('recall');
|
||||||
}
|
}
|
||||||
actions.push('delete', 'select');
|
actions.push('delete', 'select');
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ export function formatMessagePreview(msg: StoredMessage | undefined, myUserId: s
|
|||||||
return `${prefix}[视频]`;
|
return `${prefix}[视频]`;
|
||||||
case 'AU': {
|
case 'AU': {
|
||||||
const durationMs = typeof firstEnt.data?.duration === 'number' ? firstEnt.data.duration : 0;
|
const durationMs = typeof firstEnt.data?.duration === 'number' ? firstEnt.data.duration : 0;
|
||||||
const seconds = Math.round(durationMs / 1000);
|
const totalSeconds = Math.round(durationMs / 1000);
|
||||||
return `${prefix}[语音] 0:${String(seconds).padStart(2, '0')}`;
|
return `${prefix}[语音] ${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
case 'EX':
|
case 'EX':
|
||||||
return `${prefix}[文件] ${typeof firstEnt.data?.name === 'string' ? firstEnt.data.name : ''}`;
|
return `${prefix}[文件] ${typeof firstEnt.data?.name === 'string' ? firstEnt.data.name : ''}`;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
// useSyncExternalStore-based hooks actually subscribe to.
|
// useSyncExternalStore-based hooks actually subscribe to.
|
||||||
import { Tinode, type LoginCtrl } from 'tinode-sdk';
|
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';
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
|
||||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'authenticated';
|
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'authenticated';
|
||||||
@@ -138,7 +138,7 @@ function installTenantHiPatch(t: Tinode) {
|
|||||||
|
|
||||||
function createTinode(): Tinode {
|
function createTinode(): Tinode {
|
||||||
const tenant = useSessionStore.getState().tenant;
|
const tenant = useSessionStore.getState().tenant;
|
||||||
const host = tenant?.host || 'web.118im.com';
|
const host = tenant?.host || DEFAULT_HOST;
|
||||||
const secure = tenant?.secure ?? true;
|
const secure = tenant?.secure ?? true;
|
||||||
const t = new Tinode({ appName: APP_NAME, host, apiKey: API_KEY, transport: 'ws', secure, persist: false });
|
const t = new Tinode({ appName: APP_NAME, host, apiKey: API_KEY, transport: 'ws', secure, persist: false });
|
||||||
t.setHumanLanguage('zh');
|
t.setHumanLanguage('zh');
|
||||||
@@ -198,11 +198,49 @@ export function resetTinode() {
|
|||||||
setStatus('disconnected');
|
setStatus('disconnected');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function connect(): Promise<void> {
|
/**
|
||||||
|
* 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<void> | null = null;
|
||||||
|
|
||||||
|
export function connect(): Promise<void> {
|
||||||
const t = getTinode();
|
const t = getTinode();
|
||||||
if (t.isConnected()) return;
|
if (t.isConnected()) return Promise.resolve();
|
||||||
|
if (connectPromise) return connectPromise;
|
||||||
setStatus('connecting');
|
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<string | null> | null = null;
|
||||||
|
|
||||||
|
export function restoreSession(token: string): Promise<string | null> {
|
||||||
|
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 {
|
export function getConnectionStatus(): ConnectionStatus {
|
||||||
|
|||||||
@@ -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<string, Promise<Topic>>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens a topic for full conversation view: fetches a page of history and wires live updates.
|
* 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
|
* 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
|
* 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.
|
* never used) -- not yet replicated here since the new-conversation picker flow doesn't exist yet.
|
||||||
*/
|
*/
|
||||||
export async function openConversation(topicName: string): Promise<Topic> {
|
export function openConversation(topicName: string): Promise<Topic> {
|
||||||
const topic = getTinode().getTopic(topicName);
|
const existing = inFlightSubscribe.get(topicName);
|
||||||
wireTopicCallbacks(topic);
|
if (existing) return existing;
|
||||||
if (topic.isSubscribed()) {
|
const promise = (async () => {
|
||||||
await topic.leave(false);
|
const topic = getTinode().getTopic(topicName);
|
||||||
}
|
wireTopicCallbacks(topic);
|
||||||
const query = topic.startMetaQuery().withLaterDesc().withLaterSub().withAux().withData(undefined, undefined, MESSAGES_PAGE);
|
if (topic.isSubscribed()) {
|
||||||
await topic.subscribe(query.build());
|
await topic.leave(false);
|
||||||
return topic;
|
}
|
||||||
|
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. */
|
/** Soft-leave (detach, not unsubscribe) -- cached messages stay in the SDK's local buffer. Waits out
|
||||||
export function closeConversation(topicName: string): void {
|
* any in-flight openConversation() for this same topic first, so a cleanup that fires before the
|
||||||
getTinode().getTopic(topicName).leave(false).catch(() => {});
|
* subscribe it's undoing has even resolved doesn't just silently no-op (see inFlightSubscribe above). */
|
||||||
|
export async function closeConversation(topicName: string): Promise<void> {
|
||||||
|
const pending = inFlightSubscribe.get(topicName);
|
||||||
|
if (pending) await pending.catch(() => {});
|
||||||
|
await getTinode().getTopic(topicName).leave(false).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
let chatListSnapshot: Contact[] | null = null;
|
let chatListSnapshot: Contact[] | null = null;
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ interface SessionState {
|
|||||||
/**
|
/**
|
||||||
* Tenant context persists across browser restarts (localStorage) the same way the old app kept
|
* 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
|
* 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
|
* call's X-Tenant-Code header. `myUserId` is *not* persisted here -- trusting a stale uid from a
|
||||||
* Tinode connection's own auth state on reconnect, not something to trust stale from a previous
|
* previous session without re-authenticating would be wrong. Instead, app/RequireAuth.tsx re-derives
|
||||||
* session.
|
* 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<SessionState>()(
|
export const useSessionStore = create<SessionState>()(
|
||||||
persist(
|
persist(
|
||||||
|
|||||||
Reference in New Issue
Block a user