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(