diff --git a/Client/tauri-client/src/components/MessageList.ts b/Client/tauri-client/src/components/MessageList.ts index 63fd5584..cd08a67b 100644 --- a/Client/tauri-client/src/components/MessageList.ts +++ b/Client/tauri-client/src/components/MessageList.ts @@ -6,7 +6,12 @@ import { createElement, clearChildren } from "@lib/dom"; import { createLogger } from "@lib/logger"; import type { MountableComponent } from "@lib/safe-render"; -import { messagesStore, getChannelMessages, hasMoreMessages } from "@stores/messages.store"; +import { + messagesStore, + getChannelMessages, + hasMoreMessages, + getHistoryLoadState, +} from "@stores/messages.store"; import type { Message } from "@stores/messages.store"; import { membersStore } from "@stores/members.store"; @@ -31,6 +36,8 @@ export interface MessageListOptions { readonly onRetry?: (correlationId: string) => void; /** Discard a failed optimistic send without retrying. */ readonly onDeleteDraft?: (correlationId: string) => void; + /** Retry a failed first-page history fetch. */ + readonly onRetryLoad?: () => void; } // -- Constants ---------------------------------------------------------------- @@ -134,6 +141,32 @@ function renderEmptyState(channelName: string, channelType?: string): HTMLDivEle return wrapper; } +/** In-region placeholder while the first page of history is loading. */ +function renderLoadingState(): HTMLDivElement { + const wrapper = createElement("div", { class: "messages-loading" }); + wrapper.appendChild(createElement("div", { class: "spinner" })); + const text = createElement("p", { class: "messages-loading-text" }); + text.textContent = "Loading messages…"; + wrapper.appendChild(text); + return wrapper; +} + +/** In-region inline error + Retry when the first-page history fetch failed. */ +function renderLoadErrorState(onRetryLoad?: () => void): HTMLDivElement { + const wrapper = createElement("div", { class: "messages-load-error" }); + const text = createElement("p", { class: "messages-load-error-text" }); + text.textContent = "Couldn't load messages"; + wrapper.appendChild(text); + const retry = createElement("button", { + class: "messages-retry-btn", + "data-testid": "messages-retry", + }); + retry.textContent = "Retry"; + retry.addEventListener("click", () => onRetryLoad?.()); + wrapper.appendChild(retry); + return wrapper; +} + // -- Factory ------------------------------------------------------------------ export type MessageListComponent = MountableComponent & { @@ -298,7 +331,17 @@ export function createMessageList(options: MessageListOptions): MessageListCompo if (virtualItems.length === 0) { clearChildren(contentContainer); - contentContainer.appendChild(renderEmptyState(options.channelName, options.channelType)); + // With no rows, the region shows the fetch state: an in-region loading + // placeholder, an inline error + Retry, or the welcome/empty state once + // the channel is actually loaded and empty (UX spec §1/§2). + const loadState = getHistoryLoadState(options.channelId); + if (loadState === "loading") { + contentContainer.appendChild(renderLoadingState()); + } else if (loadState === "error") { + contentContainer.appendChild(renderLoadErrorState(options.onRetryLoad)); + } else { + contentContainer.appendChild(renderEmptyState(options.channelName, options.channelType)); + } topSpacer.style.height = "0px"; bottomSpacer.style.height = "0px"; renderedStart = 0; @@ -582,6 +625,17 @@ export function createMessageList(options: MessageListOptions): MessageListCompo ), ); + // Re-render the (empty) region when the first-page fetch transitions + // between loading / error / idle. + unsubscribers.push( + messagesStore.subscribeSelector( + (s) => s.historyLoadState.get(options.channelId), + () => { + renderAll(); + }, + ), + ); + // Only re-render when member roles change, not on presence/typing updates. // Extract a role-only map so shallowEqual ignores status changes. unsubscribers.push( diff --git a/Client/tauri-client/src/components/ServerBanner.ts b/Client/tauri-client/src/components/ServerBanner.ts index c2652396..84767376 100644 --- a/Client/tauri-client/src/components/ServerBanner.ts +++ b/Client/tauri-client/src/components/ServerBanner.ts @@ -9,6 +9,7 @@ export interface ServerBannerControl { readonly element: HTMLDivElement; showRestart(seconds: number): void; showReconnecting(): void; + showDisconnected(): void; hide(): void; destroy(): void; } @@ -48,6 +49,12 @@ export function createServerBanner(): ServerBannerControl { setText(root, "Reconnecting..."); } + function showDisconnected(): void { + clearCountdown(); + root.classList.add("visible"); + setText(root, "Disconnected"); + } + function hide(): void { clearCountdown(); root.classList.remove("visible"); @@ -58,5 +65,5 @@ export function createServerBanner(): ServerBannerControl { root.remove(); } - return { element: root, showRestart, showReconnecting, hide, destroy }; + return { element: root, showRestart, showReconnecting, showDisconnected, hide, destroy }; } diff --git a/Client/tauri-client/src/components/UserBar.ts b/Client/tauri-client/src/components/UserBar.ts index 647cc8de..47341f33 100644 --- a/Client/tauri-client/src/components/UserBar.ts +++ b/Client/tauri-client/src/components/UserBar.ts @@ -8,7 +8,7 @@ import { createIcon } from "@lib/icons"; import type { MountableComponent } from "@lib/safe-render"; import { Disposable } from "@lib/disposable"; import { authStore } from "@stores/auth.store"; -import { openSettings } from "@stores/ui.store"; +import { openSettings, uiStore } from "@stores/ui.store"; import { createStatusPicker, type StatusPickerComponent } from "@components/StatusPicker"; import type { UserStatus } from "@lib/types"; import type { WsClient } from "@lib/ws"; @@ -73,28 +73,30 @@ export function createUserBar(options?: UserBarOptions): MountableComponent { "data-testid": "status-picker-wrap", }); - const isWsConnected = (): boolean => { + // The picker is usable only when the socket is live (store-backed status, + // docs/architecture/ux §3) AND a ws client was provided to send through — + // without a send path, selecting a status would be a silent no-op. + const canSetStatus = (): boolean => { const ws = options?.ws; - return ws !== undefined && ws !== null && ws.getState() === "connected"; + return ws !== undefined && ws !== null && uiStore.getState().connectionStatus === "connected"; }; statusPicker = createStatusPicker({ currentStatus: "online" as UserStatus, onStatusChange: (status: UserStatus) => { const ws = options?.ws; - if (ws !== null && ws !== undefined && isWsConnected()) { + if (ws !== null && ws !== undefined && canSetStatus()) { ws.send({ type: "presence_update", payload: { status } } as never); } }, }); statusPicker.mount(statusPickerWrap); - // Disable picker when WS is disconnected + // Disable picker (with a reason) when the connection is down const updatePickerDisabled = (): void => { - const ws = options?.ws; - const connected = ws !== undefined && ws !== null && ws.getState() === "connected"; - statusPickerWrap.classList.toggle("ub-status-picker--disabled", !connected); - if (!connected) { + const enabled = canSetStatus(); + statusPickerWrap.classList.toggle("ub-status-picker--disabled", !enabled); + if (!enabled) { statusPickerWrap.title = "Offline"; } else { statusPickerWrap.title = ""; @@ -102,11 +104,11 @@ export function createUserBar(options?: UserBarOptions): MountableComponent { }; updatePickerDisabled(); - // Subscribe to WS state changes if ws is provided - if (options?.ws !== undefined && options?.ws !== null) { - const unsub = options.ws.onStateChange(() => updatePickerDisabled()); - disposable.addCleanup(unsub); - } + disposable.onStoreChange( + uiStore, + (s) => s.connectionStatus, + () => updatePickerDisabled(), + ); info.appendChild(statusPickerWrap); diff --git a/Client/tauri-client/src/components/message-list/renderers.ts b/Client/tauri-client/src/components/message-list/renderers.ts index acfdc195..e638aa1c 100644 --- a/Client/tauri-client/src/components/message-list/renderers.ts +++ b/Client/tauri-client/src/components/message-list/renderers.ts @@ -140,6 +140,8 @@ function sendErrorReason(code: string | null): string { return "You don't have permission to post here"; case "OFFLINE": return "Not connected — message not sent"; + case "NETWORK": + return "Connection problem — message not sent"; case "BAD_REQUEST": return "Message rejected"; default: diff --git a/Client/tauri-client/src/lib/api.ts b/Client/tauri-client/src/lib/api.ts index 46c364c4..f46437ba 100644 --- a/Client/tauri-client/src/lib/api.ts +++ b/Client/tauri-client/src/lib/api.ts @@ -100,7 +100,7 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?: let res: Response; try { - res = await fetch(url, init as RequestInit); + res = await fetch(url, init); } catch (fetchErr) { log.error(`${label} fetch failed`, { method, path, error: String(fetchErr) }); if (fetchErr instanceof Error) { @@ -231,7 +231,7 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?: let res: Response; try { - res = await fetch(url, init as RequestInit); + res = await fetch(url, init); } catch (fetchErr) { log.error("API fetch failed", { method: "POST", @@ -377,6 +377,12 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?: signal, }); + if (res.status === 401) { + onUnauthorized?.(); + const err = await parseError(res); + throw new ApiClientError(401, err.error, err.message); + } + if (!res.ok) { const err = await parseError(res); throw new ApiClientError(res.status, err.error, err.message); diff --git a/Client/tauri-client/src/lib/dispatcher.ts b/Client/tauri-client/src/lib/dispatcher.ts index 8e636f23..2cf46da5 100644 --- a/Client/tauri-client/src/lib/dispatcher.ts +++ b/Client/tauri-client/src/lib/dispatcher.ts @@ -420,6 +420,18 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup { }), ); + // Local transport failures (proxy not open, outbound channel full/closed): + // fail the matching optimistic row exactly like a server error reply would. + // Fire-and-forget sends (typing, presence, voice) have no pendingSends entry + // and stay logged-only. + unsubs.push( + ws.onSendFailure((id, code) => { + if (messagesStore.getState().pendingSends.has(id)) { + markSendFailed(id, code); + } + }), + ); + unsubs.push( ws.on(S.ERROR, (payload, id) => { log.error("Server error", { diff --git a/Client/tauri-client/src/lib/ws.ts b/Client/tauri-client/src/lib/ws.ts index a2e45348..5e6dcb8b 100644 --- a/Client/tauri-client/src/lib/ws.ts +++ b/Client/tauri-client/src/lib/ws.ts @@ -37,6 +37,26 @@ export type ConnectionState = | "connected" | "reconnecting"; +/** The UX-facing 3-state status stored in ui.store.connectionStatus. */ +export type ConnectionStatus = "connected" | "reconnecting" | "disconnected"; + +/** + * Collapse the internal 5-state machine into the UX-facing status. + * "connecting"/"authenticating" map to "reconnecting" because a reconnect + * cycle passes through them (reconnecting → connecting → authenticating → + * connected); mapping them to "disconnected" would flap the banner mid-retry. + */ +export function toConnectionStatus(state: ConnectionState): ConnectionStatus { + switch (state) { + case "connected": + return "connected"; + case "disconnected": + return "disconnected"; + default: + return "reconnecting"; + } +} + export type WsListener = ( payload: Extract["payload"], id?: string, @@ -101,6 +121,11 @@ export function createWsClient() { // State change listeners const stateListeners = new Set<(state: ConnectionState) => void>(); + // Local send-failure listeners (transport level: proxy not open, outbound + // channel full/closed). Notified with the envelope id so the dispatcher can + // fail the matching optimistic row instead of dropping the send silently. + const sendFailureListeners = new Set<(id: string, code: string) => void>(); + // TOFU cert mismatch listeners const certMismatchListeners = new Set(); @@ -422,21 +447,38 @@ export function createWsClient() { } } - function sendRaw(json: string): void { + function notifySendFailure(id: string | undefined, code: string): void { + if (id === undefined) return; + for (const listener of sendFailureListeners) { + try { + listener(id, code); + } catch (err) { + log.error("Send-failure listener error", err); + } + } + } + + function sendRaw(json: string, id?: string): void { if (tauriInvoke === null || !proxyOpen) { log.warn("Cannot send, WebSocket not open"); + // Deferred so a caller that registers the envelope id right after send() + // returns (the optimistic-row flow) sees the failure after registration. + queueMicrotask(() => notifySendFailure(id, "OFFLINE")); return; } tauriInvoke("ws_send", { message: json }).catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("channel full")) { - // Outbound channel is saturated — log a prominent warning so callers - // can detect backpressure rather than silently losing messages. + // Outbound channel is saturated — surface the drop to listeners so an + // optimistic row fails with retry instead of silently losing the send. log.warn("ws_send: outbound channel full, message dropped (backpressure)", { messagePreview: json.slice(0, 120), }); + notifySendFailure(id, "NETWORK"); } else { log.error("ws_send failed", err); + const offline = msg.includes("channel closed") || msg.includes("not connected"); + notifySendFailure(id, offline ? "OFFLINE" : "NETWORK"); } }); } @@ -445,7 +487,7 @@ export function createWsClient() { const id = uuid(); const envelope = { ...msg, id }; log.debug("WS →", { type: msg.type, id }); - sendRaw(JSON.stringify(envelope)); + sendRaw(JSON.stringify(envelope), id); return id; } @@ -503,6 +545,17 @@ export function createWsClient() { return () => stateListeners.delete(listener); }, + /** + * Register a listener for local transport send failures (proxy not open, + * outbound channel full/closed). Called with the envelope id returned by + * send() and an error code ("OFFLINE" | "NETWORK"). Heartbeat pings and + * other id-less raw sends never fire it. + */ + onSendFailure(listener: (id: string, code: string) => void): () => void { + sendFailureListeners.add(listener); + return () => sendFailureListeners.delete(listener); + }, + /** Register a listener for TOFU first-trust events (BUG-133). */ onCertFirstTrust(listener: CertFirstTrustListener): () => void { certFirstTrustListeners.add(listener); diff --git a/Client/tauri-client/src/main.ts b/Client/tauri-client/src/main.ts index 850a034f..a31cfdd6 100644 --- a/Client/tauri-client/src/main.ts +++ b/Client/tauri-client/src/main.ts @@ -9,10 +9,10 @@ import "@styles/theme-neon-glow.css"; import { installGlobalErrorHandlers, safeMount } from "@lib/safe-render"; import { createRouter } from "@lib/router"; import { createApiClient } from "@lib/api"; -import { createWsClient } from "@lib/ws"; +import { createWsClient, toConnectionStatus } from "@lib/ws"; import { wireDispatcher } from "@lib/dispatcher"; import { authStore, clearAuth } from "@stores/auth.store"; -import { setTransientError } from "@stores/ui.store"; +import { setTransientError, setConnectionStatus } from "@stores/ui.store"; import { voiceStore, leaveVoiceChannel } from "@stores/voice.store"; import { leaveVoice as voiceSessionLeave } from "@lib/livekitSession"; import { createConnectPage } from "@pages/ConnectPage"; @@ -91,9 +91,15 @@ const router = createRouter("connect"); // accepted; the bearer token never rides an unpinned TLS connection. const api = createApiClient({ host: "" }, () => { log.warn("Session expired (401), clearing auth"); + setTransientError("Your session expired — sign in again."); clearAuth(); }); const ws = createWsClient(); +// Single writer for the UX-facing connection status (docs/architecture/ux §3): +// live controls read ui.store.connectionStatus reactively instead of wiring +// their own ws.onStateChange. Lifecycle plumbing that needs the exact internal +// transition (the connected overlay below) stays on ws.onStateChange. +ws.onStateChange((s) => setConnectionStatus(toConnectionStatus(s))); const profileManager = createProfileManager(createTauriBackend()); let dispatcherCleanup: (() => void) | null = null; let connectedOverlay: ConnectedOverlayControl | null = null; diff --git a/Client/tauri-client/src/pages/MainPage.ts b/Client/tauri-client/src/pages/MainPage.ts index 5acd2cac..36ce5698 100644 --- a/Client/tauri-client/src/pages/MainPage.ts +++ b/Client/tauri-client/src/pages/MainPage.ts @@ -16,7 +16,7 @@ import { createToastContainer } from "@components/Toast"; import type { ToastContainer } from "@components/Toast"; import { initToast, teardownToast, showToast } from "@lib/toast"; import { authStore, clearAuth, updateUser } from "@stores/auth.store"; -import { closeSettings } from "@stores/ui.store"; +import { closeSettings, uiStore } from "@stores/ui.store"; import { updatePresence } from "@stores/members.store"; import { channelsStore, getActiveChannel } from "@stores/channels.store"; import { dmStore } from "@stores/dm.store"; @@ -195,19 +195,28 @@ export function createMainPage(options: MainPageOptions): MountableComponent { banner = createServerBanner(); root.appendChild(banner.element); + // Banner reacts to the store-backed connection status (single source of + // truth, docs/architecture/ux §3). "disconnected" keeps the banner visible + // — a fatal drop navigates away via clearAuth, and anything short of that + // must not leave a stale "Reconnecting..." on screen. unsubscribers.push( - ws.onStateChange((wsState) => { - try { - if (banner === null) return; - if (wsState === "reconnecting") { - banner.showReconnecting(); - } else if (wsState === "connected") { - banner.hide(); + uiStore.subscribeSelector( + (s) => s.connectionStatus, + (status) => { + try { + if (banner === null) return; + if (status === "reconnecting") { + banner.showReconnecting(); + } else if (status === "disconnected") { + banner.showDisconnected(); + } else { + banner.hide(); + } + } catch (err) { + log.error("Connection status handler error", err); } - } catch (err) { - log.error("State change handler error", err); - } - }), + }, + ), ); unsubscribers.push( diff --git a/Client/tauri-client/src/pages/main-page/ChannelController.ts b/Client/tauri-client/src/pages/main-page/ChannelController.ts index 5e94b962..5aea690c 100644 --- a/Client/tauri-client/src/pages/main-page/ChannelController.ts +++ b/Client/tauri-client/src/pages/main-page/ChannelController.ts @@ -32,6 +32,7 @@ import type { ChatHeaderRefs } from "./ChatHeader"; import { dmStore } from "@stores/dm.store"; import { membersStore } from "@stores/members.store"; import { channelsStore } from "@stores/channels.store"; +import { uiStore } from "@stores/ui.store"; const log = createLogger("channel-ctrl"); @@ -164,7 +165,7 @@ export function createChannelController(opts: ChannelControllerOptions): Channel const user = currentMessageUser(); if (user === null) return; const timestamp = new Date().toISOString(); - if (ws.getState() !== "connected") { + if (uiStore.getState().connectionStatus !== "connected") { // Composer gating normally prevents this, but stay consistent: show a // failed row with retry rather than silently dropping the message. const cid = crypto.randomUUID(); @@ -208,6 +209,11 @@ export function createChannelController(opts: ChannelControllerOptions): Channel void msgCtrl.loadOlderMessages(channelId, channelAbort.signal); } }, + onRetryLoad: () => { + if (channelAbort !== null) { + void msgCtrl.loadMessages(channelId, channelAbort.signal); + } + }, onReplyClick: (msgId: number) => { const msgs = getChannelMessages(channelId); const msg = msgs.find((m) => m.id === msgId); @@ -313,7 +319,7 @@ export function createChannelController(opts: ChannelControllerOptions): Channel // server still enforces block/permission and a refused send shows as a // failed row. const computeComposerReason = (): string | null => { - if (ws.getState() !== "connected") return "Reconnecting…"; + if (uiStore.getState().connectionStatus !== "connected") return "Reconnecting…"; const ch = channelsStore.getState().channels.get(channelId); if (ch === undefined) return null; if (!ch.canSend) { @@ -327,7 +333,12 @@ export function createChannelController(opts: ChannelControllerOptions): Channel messageInput?.setDisabled(computeComposerReason()); }; refreshComposerState(); - composerGatingUnsubs.push(ws.onStateChange(() => refreshComposerState())); + composerGatingUnsubs.push( + uiStore.subscribeSelector( + (s) => s.connectionStatus, + () => refreshComposerState(), + ), + ); composerGatingUnsubs.push( channelsStore.subscribeSelector( (s) => s.channels.get(channelId)?.canSend ?? true, diff --git a/Client/tauri-client/src/pages/main-page/MessageController.ts b/Client/tauri-client/src/pages/main-page/MessageController.ts index 53832823..e3d62643 100644 --- a/Client/tauri-client/src/pages/main-page/MessageController.ts +++ b/Client/tauri-client/src/pages/main-page/MessageController.ts @@ -10,6 +10,8 @@ import { prependMessages, isChannelLoaded, getChannelMessages, + setChannelLoading, + setChannelLoadError, } from "@stores/messages.store"; const log = createLogger("message-ctrl"); @@ -75,6 +77,9 @@ export function createMessageController(opts: MessageControllerOptions): Message log.debug("Messages already loaded", { channelId }); return; } + // Runs synchronously before the first await, so the message region shows + // its in-region loading placeholder from the very first render. + setChannelLoading(channelId); try { const resp = await api.getMessages(channelId, { limit: PAGE_SIZE }, signal); if (!signal.aborted) { @@ -91,7 +96,9 @@ export function createMessageController(opts: MessageControllerOptions): Message channelId, error: String(err), }); - showError("Failed to load messages"); + // Inline section error + Retry in the message region (UX spec §2) — + // a toast would vanish and leave the region silently empty. + setChannelLoadError(channelId); } } } diff --git a/Client/tauri-client/src/pages/main-page/SidebarArea.ts b/Client/tauri-client/src/pages/main-page/SidebarArea.ts index 47a84123..4519f2fc 100644 --- a/Client/tauri-client/src/pages/main-page/SidebarArea.ts +++ b/Client/tauri-client/src/pages/main-page/SidebarArea.ts @@ -764,7 +764,7 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult { // --------------------------------------------------------------------------- const userBarSlot = createElement("div", {}); - const userBar = createUserBar({ onDisconnect: openQuickSwitch }); + const userBar = createUserBar({ onDisconnect: openQuickSwitch, ws }); userBar.mount(userBarSlot); children.push(userBar); sidebarWrapper.appendChild(userBarSlot); diff --git a/Client/tauri-client/src/stores/messages.store.ts b/Client/tauri-client/src/stores/messages.store.ts index a55ab9aa..d50f1d26 100644 --- a/Client/tauri-client/src/stores/messages.store.ts +++ b/Client/tauri-client/src/stores/messages.store.ts @@ -60,6 +60,11 @@ export interface MessagesState { readonly loadedChannels: ReadonlySet; /** Whether more messages exist above for a channel */ readonly hasMore: ReadonlyMap; + /** + * First-page history fetch state per channel. Absent entry = idle (loaded or + * never requested) — the message region then renders normally/empty. + */ + readonly historyLoadState: ReadonlyMap; } // ----------------------------------------------------------------------------- @@ -116,6 +121,7 @@ const INITIAL_STATE: MessagesState = { pendingSends: new Map(), loadedChannels: new Set(), hasMore: new Map(), + historyLoadState: new Map(), }; // ----------------------------------------------------------------------------- @@ -263,6 +269,24 @@ export function removeOptimistic(correlationId: string): void { }); } +/** Mark a channel's first-page history fetch as in flight. */ +export function setChannelLoading(channelId: number): void { + messagesStore.setState((prev) => { + const updated = new Map(prev.historyLoadState); + updated.set(channelId, "loading"); + return { ...prev, historyLoadState: updated }; + }); +} + +/** Mark a channel's first-page history fetch as failed (the region offers Retry). */ +export function setChannelLoadError(channelId: number): void { + messagesStore.setState((prev) => { + const updated = new Map(prev.historyLoadState); + updated.set(channelId, "error"); + return { ...prev, historyLoadState: updated }; + }); +} + /** Bulk set messages from a REST response. Marks channel as loaded. * The server returns messages newest-first; we reverse to chronological order. */ export function setMessages( @@ -285,11 +309,15 @@ export function setMessages( const updatedHasMore = new Map(prev.hasMore); updatedHasMore.set(channelId, hasMore || converted.length > MAX_MESSAGES_PER_CHANNEL); + const updatedLoadState = new Map(prev.historyLoadState); + updatedLoadState.delete(channelId); + return { ...prev, messagesByChannel: updatedMessages, loadedChannels: updatedLoaded, hasMore: updatedHasMore, + historyLoadState: updatedLoadState, }; }); } @@ -426,11 +454,15 @@ export function clearChannelMessages(channelId: number): void { const updatedHasMore = new Map(prev.hasMore); updatedHasMore.delete(channelId); + const updatedLoadState = new Map(prev.historyLoadState); + updatedLoadState.delete(channelId); + return { ...prev, messagesByChannel: updatedMessages, loadedChannels: updatedLoaded, hasMore: updatedHasMore, + historyLoadState: updatedLoadState, }; }); } @@ -494,3 +526,8 @@ export function isChannelLoaded(channelId: number): boolean { export function hasMoreMessages(channelId: number): boolean { return messagesStore.select((s) => s.hasMore.get(channelId) ?? false); } + +/** First-page history fetch state for a channel; null when idle/loaded. */ +export function getHistoryLoadState(channelId: number): "loading" | "error" | null { + return messagesStore.select((s) => s.historyLoadState.get(channelId) ?? null); +} diff --git a/Client/tauri-client/src/styles/app.css b/Client/tauri-client/src/styles/app.css index 91d5c2cd..36d42cc0 100644 --- a/Client/tauri-client/src/styles/app.css +++ b/Client/tauri-client/src/styles/app.css @@ -423,6 +423,21 @@ font-size: 14px; color: var(--text-muted); margin: 0; } +/* ── Message history fetch states (loading / inline error + Retry) ── */ +.messages-loading, .messages-load-error { + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 12px; padding: 48px 0; color: var(--text-muted); +} +.messages-loading .spinner { width: 24px; height: 24px; } +.messages-loading-text, .messages-load-error-text { + font-size: 13px; color: var(--text-muted); margin: 0; +} +.messages-retry-btn { + background: transparent; border: 1px solid currentColor; color: var(--text-muted); + border-radius: 4px; padding: 4px 16px; font-size: 13px; cursor: pointer; +} +.messages-retry-btn:hover { background: var(--bg-hover); color: var(--text-primary); } + .msg-day-divider { display: flex; align-items: center; gap: 8px; padding: 8px 16px 16px; margin-bottom: 8px; diff --git a/Client/tauri-client/tests/helpers/mock-ws.ts b/Client/tauri-client/tests/helpers/mock-ws.ts index 16837854..070d5d70 100644 --- a/Client/tauri-client/tests/helpers/mock-ws.ts +++ b/Client/tauri-client/tests/helpers/mock-ws.ts @@ -20,6 +20,7 @@ export function createMockWsClient() { const sent: SentEnvelope[] = []; const listeners = new Map>>(); const stateListeners = new Set<(state: ConnectionState) => void>(); + const sendFailureListeners = new Set<(id: string, code: string) => void>(); let idCounter = 0; @@ -72,6 +73,11 @@ export function createMockWsClient() { return () => stateListeners.delete(listener); }, + onSendFailure(listener: (id: string, code: string) => void): () => void { + sendFailureListeners.add(listener); + return () => sendFailureListeners.delete(listener); + }, + onCertFirstTrust(): () => void { return () => {}; }, @@ -123,6 +129,16 @@ export function createMockWsClient() { setState(newState); }, + /** + * Simulate a local transport send failure (proxy not open, channel + * full/closed) for the given envelope id. + */ + simulateSendFailure(id: string, code: string): void { + for (const listener of sendFailureListeners) { + listener(id, code); + } + }, + /** * Return all messages passed to send(), in order. */ diff --git a/Client/tauri-client/tests/helpers/test-utils.ts b/Client/tauri-client/tests/helpers/test-utils.ts index 0e910a6a..b1841a77 100644 --- a/Client/tauri-client/tests/helpers/test-utils.ts +++ b/Client/tauri-client/tests/helpers/test-utils.ts @@ -46,6 +46,7 @@ const MESSAGES_INITIAL: MessagesState = { pendingSends: new Map(), loadedChannels: new Set(), hasMore: new Map(), + historyLoadState: new Map(), }; const VOICE_INITIAL: VoiceState = { @@ -96,6 +97,7 @@ export function resetAllStores(): void { pendingSends: new Map(), loadedChannels: new Set(), hasMore: new Map(), + historyLoadState: new Map(), })); voiceStore.setState(() => ({ ...VOICE_INITIAL, diff --git a/Client/tauri-client/tests/integration/stores.test.ts b/Client/tauri-client/tests/integration/stores.test.ts index 5c9f3191..35bccf08 100644 --- a/Client/tauri-client/tests/integration/stores.test.ts +++ b/Client/tauri-client/tests/integration/stores.test.ts @@ -63,6 +63,10 @@ function createMockWsClient(): MockWsClient { return () => stateListeners.delete(listener); }, + onSendFailure(): () => void { + return () => {}; + }, + onCertFirstTrust(): () => void { return () => {}; }, @@ -118,6 +122,7 @@ function resetAllStores(): void { pendingSends: new Map(), loadedChannels: new Set(), hasMore: new Map(), + historyLoadState: new Map(), })); voiceStore.setState(() => ({ currentChannelId: null, diff --git a/Client/tauri-client/tests/unit/api.test.ts b/Client/tauri-client/tests/unit/api.test.ts index 29ca5f11..9049fe81 100644 --- a/Client/tauri-client/tests/unit/api.test.ts +++ b/Client/tauri-client/tests/unit/api.test.ts @@ -522,6 +522,20 @@ describe("API Client", () => { }); }); + it("uploadFile calls onUnauthorized on 401 like other REST calls", async () => { + mockFetch.mockResolvedValue(errorResponse(401, "UNAUTHORIZED", "Invalid session")); + const file = new File(["x"], "f.txt"); + await expect(api.uploadFile(file)).rejects.toMatchObject({ status: 401 }); + expect(onUnauthorized).toHaveBeenCalledTimes(1); + }); + + it("uploadFile does not call onUnauthorized on other errors", async () => { + mockFetch.mockResolvedValue(errorResponse(500, "SERVER_ERROR", "Internal error")); + const file = new File(["x"], "f.txt"); + await expect(api.uploadFile(file)).rejects.toThrow(); + expect(onUnauthorized).not.toHaveBeenCalled(); + }); + it("uploadFile omits Authorization header when no token set", async () => { const noTokenApi = createApiClient({ host: "localhost:8443" }); mockFetch.mockResolvedValue(jsonResponse({ url: "https://cdn/f.png", filename: "f.png" })); diff --git a/Client/tauri-client/tests/unit/channel-controller.test.ts b/Client/tauri-client/tests/unit/channel-controller.test.ts index 103ad6a7..b58e8c00 100644 --- a/Client/tauri-client/tests/unit/channel-controller.test.ts +++ b/Client/tauri-client/tests/unit/channel-controller.test.ts @@ -150,6 +150,7 @@ vi.mock("@stores/members.store", () => ({ import { createChannelController } from "../../src/pages/main-page/ChannelController"; import type { ChannelControllerOptions } from "../../src/pages/main-page/ChannelController"; +import { setConnectionStatus } from "@stores/ui.store"; // --------------------------------------------------------------------------- // Helpers @@ -201,6 +202,9 @@ describe("createChannelController", () => { vi.clearAllMocks(); capturedMessageListOpts = null; capturedMessageInputOpts = null; + // The controller gates sends on the store-backed connection status + // (docs/architecture/ux §3), not on ws.getState(). + setConnectionStatus("connected"); }); it("starts with no channel mounted", () => { @@ -350,7 +354,7 @@ describe("createChannelController", () => { it("onSend while disconnected records a failed optimistic row (no silent drop)", () => { const opts = makeOpts(); - (opts.ws.getState as ReturnType).mockReturnValue("disconnected"); + setConnectionStatus("disconnected"); const ctrl = createChannelController(opts); ctrl.mountChannel(42, "general"); diff --git a/Client/tauri-client/tests/unit/chat.test.ts b/Client/tauri-client/tests/unit/chat.test.ts index 676ca99b..95bbe2ae 100644 --- a/Client/tauri-client/tests/unit/chat.test.ts +++ b/Client/tauri-client/tests/unit/chat.test.ts @@ -36,6 +36,7 @@ function resetStores(): void { pendingSends: new Map(), loadedChannels: new Set(), hasMore: new Map(), + historyLoadState: new Map(), })); membersStore.setState(() => ({ members: new Map(), diff --git a/Client/tauri-client/tests/unit/dispatcher.test.ts b/Client/tauri-client/tests/unit/dispatcher.test.ts index 1a7ed135..a61a3e26 100644 --- a/Client/tauri-client/tests/unit/dispatcher.test.ts +++ b/Client/tauri-client/tests/unit/dispatcher.test.ts @@ -43,6 +43,7 @@ vi.spyOn(console, "error").mockImplementation(() => {}); */ function createMockWs() { const listeners = new Map>>(); + const sendFailureListeners = new Set<(id: string, code: string) => void>(); const ws: WsClient = { connect: vi.fn(), @@ -58,6 +59,10 @@ function createMockWs() { }; }, onStateChange: vi.fn(() => () => {}), + onSendFailure(listener: (id: string, code: string) => void): () => void { + sendFailureListeners.add(listener); + return () => sendFailureListeners.delete(listener); + }, onCertFirstTrust: vi.fn(() => () => {}), onCertMismatch: vi.fn(() => () => {}), acceptCertFingerprint: vi.fn(async () => {}), @@ -75,7 +80,13 @@ function createMockWs() { } } - return { ws, dispatch, listeners }; + function dispatchSendFailure(id: string, code: string): void { + for (const listener of sendFailureListeners) { + listener(id, code); + } + } + + return { ws, dispatch, dispatchSendFailure, listeners }; } describe("WS Dispatcher", () => { @@ -102,6 +113,7 @@ describe("WS Dispatcher", () => { pendingSends: new Map(), loadedChannels: new Set(), hasMore: new Map(), + historyLoadState: new Map(), })); membersStore.setState(() => ({ members: new Map(), @@ -798,6 +810,7 @@ describe("WS Dispatcher", () => { pendingSends: new Map(), loadedChannels: new Set(), hasMore: new Map(), + historyLoadState: new Map(), })); uiStore.setState((prev) => ({ ...prev, transientError: null })); @@ -820,6 +833,37 @@ describe("WS Dispatcher", () => { expect(uiStore.getState().transientError).toBeNull(); }); + it("wires a local transport send failure to mark the pending row failed", () => { + uiStore.setState((prev) => ({ ...prev, transientError: null })); + + addOptimisticMessage({ + correlationId: "corr-2", + channelId: 7, + user: { id: 1, username: "alex", avatar: null }, + content: "hi", + replyTo: null, + timestamp: "2026-03-15T10:00:00Z", + }); + + // ws_send rejected locally (outbound channel full) → the row fails with retry… + mock.dispatchSendFailure("corr-2", "NETWORK"); + + const row = getChannelMessages(7)[0]!; + expect(row.status).toBe("failed"); + expect(row.errorCode).toBe("NETWORK"); + // …and no global transient error is raised. + expect(uiStore.getState().transientError).toBeNull(); + }); + + it("ignores a transport send failure for an id with no pending send (fire-and-forget)", () => { + const before = messagesStore.getState(); + + mock.dispatchSendFailure("typing-id", "NETWORK"); + + // No store mutation: fire-and-forget sends (typing, presence…) stay silent. + expect(messagesStore.getState()).toBe(before); + }); + it("does not increment unread for own messages", () => { authStore.setState((prev) => ({ ...prev, diff --git a/Client/tauri-client/tests/unit/message-controller.test.ts b/Client/tauri-client/tests/unit/message-controller.test.ts index 6afb9ea9..5d89bb00 100644 --- a/Client/tauri-client/tests/unit/message-controller.test.ts +++ b/Client/tauri-client/tests/unit/message-controller.test.ts @@ -4,13 +4,21 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // Mocks // --------------------------------------------------------------------------- -const { mockSetMessages, mockPrependMessages, mockIsChannelLoaded, mockGetChannelMessages } = - vi.hoisted(() => ({ - mockSetMessages: vi.fn(), - mockPrependMessages: vi.fn(), - mockIsChannelLoaded: vi.fn((): boolean => false), - mockGetChannelMessages: vi.fn((): Array<{ id: number; content?: string }> => []), - })); +const { + mockSetMessages, + mockPrependMessages, + mockIsChannelLoaded, + mockGetChannelMessages, + mockSetChannelLoading, + mockSetChannelLoadError, +} = vi.hoisted(() => ({ + mockSetMessages: vi.fn(), + mockPrependMessages: vi.fn(), + mockIsChannelLoaded: vi.fn((): boolean => false), + mockGetChannelMessages: vi.fn((): Array<{ id: number; content?: string }> => []), + mockSetChannelLoading: vi.fn(), + mockSetChannelLoadError: vi.fn(), +})); vi.mock("@lib/logger", () => ({ createLogger: () => ({ @@ -26,6 +34,8 @@ vi.mock("@stores/messages.store", () => ({ prependMessages: mockPrependMessages, isChannelLoaded: mockIsChannelLoaded, getChannelMessages: mockGetChannelMessages, + setChannelLoading: mockSetChannelLoading, + setChannelLoadError: mockSetChannelLoadError, })); // --------------------------------------------------------------------------- @@ -83,6 +93,18 @@ describe("createMessageController", () => { expect(mockSetMessages).toHaveBeenCalledWith(42, [{ id: 1, content: "hi" }], false); }); + it("marks the channel loading before the fetch resolves", async () => { + const api = makeApi(); + const ctrl = createMessageController({ api, showError }); + + const pending = ctrl.loadMessages(42, makeAbort().signal); + + // Synchronous prefix: the loading placeholder is visible from the + // first render, before the first await. + expect(mockSetChannelLoading).toHaveBeenCalledWith(42); + await pending; + }); + it("skips fetch when channel is already loaded", async () => { mockIsChannelLoaded.mockReturnValue(true); const api = makeApi(); @@ -92,6 +114,7 @@ describe("createMessageController", () => { expect(api.getMessages).not.toHaveBeenCalled(); expect(mockSetMessages).not.toHaveBeenCalled(); + expect(mockSetChannelLoading).not.toHaveBeenCalled(); }); it("does not store messages after abort", async () => { @@ -109,7 +132,7 @@ describe("createMessageController", () => { expect(mockSetMessages).not.toHaveBeenCalled(); }); - it("shows error on fetch failure", async () => { + it("marks the channel load-errored on fetch failure (inline error, not a toast)", async () => { const api = makeApi({ getMessages: vi.fn().mockRejectedValue(new Error("network error")), }); @@ -117,10 +140,12 @@ describe("createMessageController", () => { await ctrl.loadMessages(42, makeAbort().signal); - expect(showError).toHaveBeenCalledWith("Failed to load messages"); + // The region renders an inline error + Retry (UX spec §2); no toast. + expect(mockSetChannelLoadError).toHaveBeenCalledWith(42); + expect(showError).not.toHaveBeenCalled(); }); - it("does not show error when aborted before failure", async () => { + it("does not mark an error when aborted before failure", async () => { const { signal, abort } = makeAbort(); abort(); const api = makeApi({ @@ -130,6 +155,7 @@ describe("createMessageController", () => { await ctrl.loadMessages(42, signal); + expect(mockSetChannelLoadError).not.toHaveBeenCalled(); expect(showError).not.toHaveBeenCalled(); }); }); diff --git a/Client/tauri-client/tests/unit/message-list.test.ts b/Client/tauri-client/tests/unit/message-list.test.ts index 69506b59..8d321406 100644 --- a/Client/tauri-client/tests/unit/message-list.test.ts +++ b/Client/tauri-client/tests/unit/message-list.test.ts @@ -27,6 +27,7 @@ function resetStores(): void { pendingSends: new Map(), loadedChannels: new Set(), hasMore: new Map(), + historyLoadState: new Map(), })); membersStore.setState(() => ({ members: new Map(), @@ -69,6 +70,14 @@ function setHasMore(channelId: number, value: boolean): void { }); } +function setHistoryLoadState(channelId: number, value: "loading" | "error"): void { + messagesStore.setState((prev) => { + const next = new Map(prev.historyLoadState); + next.set(channelId, value); + return { ...prev, historyLoadState: next }; + }); +} + export type MessageListComponent = ReturnType; describe("MessageList", () => { @@ -136,6 +145,45 @@ describe("MessageList", () => { expect(text?.textContent).toBe("This is the start of the #general channel."); }); + it("renders the in-region loading placeholder while history is loading", () => { + setHistoryLoadState(1, "loading"); + msgList.mount(container); + + expect(container.querySelector(".messages-loading")).not.toBeNull(); + expect(container.querySelector(".channel-welcome")).toBeNull(); + }); + + it("renders inline error with Retry on load failure, and Retry calls onRetryLoad", () => { + const onRetryLoad = vi.fn(); + msgList.destroy?.(); + msgList = createMessageList({ ...options, onRetryLoad }); + setHistoryLoadState(1, "error"); + msgList.mount(container); + + expect(container.querySelector(".messages-load-error")).not.toBeNull(); + expect(container.querySelector(".channel-welcome")).toBeNull(); + const retry = container.querySelector("[data-testid='messages-retry']") as HTMLButtonElement; + expect(retry).not.toBeNull(); + retry.click(); + expect(onRetryLoad).toHaveBeenCalledTimes(1); + }); + + it("transitions loading → welcome once the load state clears", () => { + setHistoryLoadState(1, "loading"); + msgList.mount(container); + expect(container.querySelector(".messages-loading")).not.toBeNull(); + + messagesStore.setState((prev) => { + const next = new Map(prev.historyLoadState); + next.delete(1); + return { ...prev, historyLoadState: next }; + }); + messagesStore.flush(); + + expect(container.querySelector(".messages-loading")).toBeNull(); + expect(container.querySelector(".channel-welcome")).not.toBeNull(); + }); + it("destroy removes DOM and cleans up", () => { msgList.mount(container); expect(container.querySelector(".messages-container")).not.toBeNull(); diff --git a/Client/tauri-client/tests/unit/messages.store.test.ts b/Client/tauri-client/tests/unit/messages.store.test.ts index 7937cf5d..cd85dcf8 100644 --- a/Client/tauri-client/tests/unit/messages.store.test.ts +++ b/Client/tauri-client/tests/unit/messages.store.test.ts @@ -17,6 +17,9 @@ import { isChannelLoaded, hasMoreMessages, clearChannelMessages, + setChannelLoading, + setChannelLoadError, + getHistoryLoadState, } from "../../src/stores/messages.store"; import type { ChatMessagePayload, @@ -979,4 +982,32 @@ describe("messages store", () => { expect(msgs[0]!.status).toBe("sent"); }); }); + + // 10. First-page history load state + describe("history load state", () => { + it("is idle (null) by default", () => { + expect(getHistoryLoadState(1)).toBeNull(); + }); + + it("setChannelLoading and setChannelLoadError set the per-channel state", () => { + setChannelLoading(1); + expect(getHistoryLoadState(1)).toBe("loading"); + expect(getHistoryLoadState(2)).toBeNull(); + + setChannelLoadError(1); + expect(getHistoryLoadState(1)).toBe("error"); + }); + + it("setMessages clears the channel's load state", () => { + setChannelLoading(1); + setMessages(1, [makeMessageResponse({ id: 1 })], false); + expect(getHistoryLoadState(1)).toBeNull(); + }); + + it("clearChannelMessages clears the channel's load state", () => { + setChannelLoadError(1); + clearChannelMessages(1); + expect(getHistoryLoadState(1)).toBeNull(); + }); + }); }); diff --git a/Client/tauri-client/tests/unit/renderers.test.ts b/Client/tauri-client/tests/unit/renderers.test.ts index 7801fd2d..a1314792 100644 --- a/Client/tauri-client/tests/unit/renderers.test.ts +++ b/Client/tauri-client/tests/unit/renderers.test.ts @@ -250,6 +250,25 @@ describe("renderers", () => { ac.abort(); }); + it("renders a transport-failure (NETWORK) row with the connection-problem reason", () => { + const msg = makeMessage({ + status: "failed", + correlationId: "c2", + id: 0, + errorCode: "NETWORK", + }); + const ac = new AbortController(); + const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal); + container.appendChild(el); + + expect(el.classList.contains("failed")).toBe(true); + expect(container.querySelector(".msg-send-failed-text")?.textContent).toBe( + "Connection problem — message not sent", + ); + + ac.abort(); + }); + it("renders deleted message with italic text", () => { const msg = makeMessage({ deleted: true }); const ac = new AbortController(); diff --git a/Client/tauri-client/tests/unit/status-picker-userbar.test.ts b/Client/tauri-client/tests/unit/status-picker-userbar.test.ts index c7bb0cb9..ba74030d 100644 --- a/Client/tauri-client/tests/unit/status-picker-userbar.test.ts +++ b/Client/tauri-client/tests/unit/status-picker-userbar.test.ts @@ -1,10 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { authStore } from "@stores/auth.store"; - -vi.mock("@stores/ui.store", () => ({ - openSettings: vi.fn(), - uiStore: { getState: () => ({}), subscribe: () => () => {} }, -})); +import { uiStore, setConnectionStatus } from "@stores/ui.store"; import { createUserBar } from "@components/UserBar"; import type { WsClient } from "@lib/ws"; @@ -52,11 +48,14 @@ describe("StatusPicker wired to UserBar", () => { container = document.createElement("div"); document.body.appendChild(container); vi.clearAllMocks(); + // The picker gates on the store-backed connection status (UX spec §3). + setConnectionStatus("connected"); }); afterEach(() => { comp?.destroy?.(); container.remove(); + setConnectionStatus("disconnected"); authStore.setState(() => ({ token: null, user: null, @@ -103,6 +102,7 @@ describe("StatusPicker wired to UserBar", () => { it("status picker is disabled when WS is disconnected", () => { setAuthState({ username: "alice" }, true); + setConnectionStatus("disconnected"); const ws = createMockWs("disconnected"); comp = createUserBar({ ws }); comp.mount(container); @@ -112,4 +112,31 @@ describe("StatusPicker wired to UserBar", () => { expect(wrap.classList.contains("ub-status-picker--disabled")).toBe(true); expect(wrap.title).toBe("Offline"); }); + + it("status picker reacts to a connection status change through the store", async () => { + setAuthState({ username: "alice" }, true); + const ws = createMockWs("connected"); + comp = createUserBar({ ws }); + comp.mount(container); + + const wrap = container.querySelector("[data-testid='status-picker-wrap']") as HTMLElement; + expect(wrap.classList.contains("ub-status-picker--disabled")).toBe(false); + + setConnectionStatus("reconnecting"); + uiStore.flush(); + + expect(wrap.classList.contains("ub-status-picker--disabled")).toBe(true); + expect(wrap.title).toBe("Offline"); + }); + + it("status picker is disabled without a ws send path even when connected", () => { + setAuthState({ username: "alice" }, true); + comp = createUserBar({}); + comp.mount(container); + + // Without a ws client, selecting a status would be a silent no-op — + // the control stays disabled instead (no-silent-failure principle). + const wrap = container.querySelector("[data-testid='status-picker-wrap']") as HTMLElement; + expect(wrap.classList.contains("ub-status-picker--disabled")).toBe(true); + }); }); diff --git a/Client/tauri-client/tests/unit/user-bar.test.ts b/Client/tauri-client/tests/unit/user-bar.test.ts index 2c3b08ae..fc81d981 100644 --- a/Client/tauri-client/tests/unit/user-bar.test.ts +++ b/Client/tauri-client/tests/unit/user-bar.test.ts @@ -5,7 +5,11 @@ import { createUserBar } from "@components/UserBar"; vi.mock("@stores/ui.store", () => ({ openSettings: vi.fn(), - uiStore: { getState: () => ({}), subscribe: () => () => {} }, + uiStore: { + getState: () => ({ connectionStatus: "connected" }), + subscribe: () => () => {}, + subscribeSelector: () => () => {}, + }, })); function setAuthState(user: { username: string } | null, isAuthenticated: boolean): void { diff --git a/Client/tauri-client/tests/unit/ws.test.ts b/Client/tauri-client/tests/unit/ws.test.ts index 6e09d341..3d7c7562 100644 --- a/Client/tauri-client/tests/unit/ws.test.ts +++ b/Client/tauri-client/tests/unit/ws.test.ts @@ -41,7 +41,7 @@ vi.spyOn(console, "warn").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); // Import after mocks are set up -import { createWsClient } from "../../src/lib/ws"; +import { createWsClient, toConnectionStatus } from "../../src/lib/ws"; /** Simulate Tauri emitting an event to JS */ function emitTauriEvent(event: string, payload: unknown): void { @@ -2837,6 +2837,175 @@ describe("send edge cases", () => { expect(client.getState()).toBe("connected"); }); + it("onSendFailure fires with NETWORK when ws_send hits backpressure (channel full)", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 1, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === "ws_send") throw new Error("ws_send: channel full, message dropped"); + return undefined; + }); + + const failures: Array<{ id: string; code: string }> = []; + client.onSendFailure((id, code) => failures.push({ id, code })); + + const id = client.send({ + type: "chat_send", + payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] }, + }); + await vi.advanceTimersByTimeAsync(10); + + expect(failures).toEqual([{ id, code: "NETWORK" }]); + }); + + it("onSendFailure fires with OFFLINE when ws_send reports the channel closed", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 1, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === "ws_send") throw new Error("ws_send: channel closed"); + return undefined; + }); + + const failures: Array<{ id: string; code: string }> = []; + client.onSendFailure((id, code) => failures.push({ id, code })); + + const id = client.send({ + type: "chat_send", + payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] }, + }); + await vi.advanceTimersByTimeAsync(10); + + expect(failures).toEqual([{ id, code: "OFFLINE" }]); + }); + + it("onSendFailure fires with OFFLINE when sending while the proxy is not open", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 1, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + // Drop the proxy: subsequent sends take the not-open early return. + emitTauriEvent("ws-state", "closed"); + + const failures: Array<{ id: string; code: string }> = []; + client.onSendFailure((id, code) => failures.push({ id, code })); + + const id = client.send({ + type: "chat_send", + payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] }, + }); + // The early-return notification is deferred a microtask so callers can + // register the id (optimistic row) before the failure lands. + expect(failures).toEqual([]); + await vi.advanceTimersByTimeAsync(0); + + expect(failures).toEqual([{ id, code: "OFFLINE" }]); + }); + + it("heartbeat ping failures do not fire onSendFailure (no envelope id)", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 1, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === "ws_send") throw new Error("ws_send: channel full, message dropped"); + return undefined; + }); + + const failures: Array<{ id: string; code: string }> = []; + client.onSendFailure((id, code) => failures.push({ id, code })); + + // Let the 30s heartbeat fire (and its ws_send reject). + await vi.advanceTimersByTimeAsync(30_100); + + expect(failures).toEqual([]); + }); + + it("onSendFailure unsubscribe works", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 1, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === "ws_send") throw new Error("ws_send: channel full, message dropped"); + return undefined; + }); + + const failures: Array<{ id: string; code: string }> = []; + const unsub = client.onSendFailure((id, code) => failures.push({ id, code })); + unsub(); + + client.send({ + type: "chat_send", + payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] }, + }); + await vi.advanceTimersByTimeAsync(10); + + expect(failures).toEqual([]); + }); + it("ws_disconnect error is ignored during disconnectProxy", async () => { client.connect({ host: "localhost:8443", token: "t" }); await vi.advanceTimersByTimeAsync(10); @@ -3103,3 +3272,15 @@ describe("listener registry mechanics (on/off/dispatch)", () => { expect(received).toEqual(["hello", "fourth:hello"]); }); }); + +describe("toConnectionStatus", () => { + it("maps the internal 5-state machine onto the UX-facing 3-state status", () => { + expect(toConnectionStatus("connected")).toBe("connected"); + expect(toConnectionStatus("disconnected")).toBe("disconnected"); + // Mid-retry states must read as "reconnecting", not "disconnected" — + // a reconnect cycle passes through connecting/authenticating. + expect(toConnectionStatus("reconnecting")).toBe("reconnecting"); + expect(toConnectionStatus("connecting")).toBe("reconnecting"); + expect(toConnectionStatus("authenticating")).toBe("reconnecting"); + }); +}); diff --git a/docs/architecture/ux/README.md b/docs/architecture/ux/README.md index 0a91fb09..abd443f6 100644 --- a/docs/architecture/ux/README.md +++ b/docs/architecture/ux/README.md @@ -79,14 +79,20 @@ source of truth in `ui.store.connectionStatus` (`connected | reconnecting | disconnected`), written from the WS client's `onStateChange`, and read by any control that needs a live socket. -> **⚠ Current gap.** The authoritative connection state lives in a closure inside -> `src/lib/ws.ts` (`state`, `ws.ts:33-38`) and is surfaced only through -> `onStateChange` callbacks wired ad hoc in `MainPage.ts:199-211`; -> `ui.store.connectionStatus` exists (`ui.store.ts:14`) but is not the single -> writer/reader. Consolidating onto the store lets every control reactively -> disable itself when the socket drops, instead of each call site guarding -> `ws.getState() !== "connected"` and reporting failure *after* the click -> (as the composer does today, `ChannelController.ts:200-204`). +> **✓ Implemented (2026-07).** `ui.store.connectionStatus` is now the single +> source of truth: `main.ts` registers the one writer +> (`ws.onStateChange` → `toConnectionStatus` → `setConnectionStatus`), mapping +> the internal 5-state machine onto the 3-state status (`connecting` / +> `authenticating` read as `reconnecting`, since a reconnect cycle passes +> through them). Consumers subscribe to the store instead of wiring ad-hoc +> callbacks: the reconnect banner (`MainPage`, which now also shows +> "Disconnected" instead of going stale), the composer gating +> (`ChannelController`), and the presence picker (`UserBar` — previously dead in +> production because `SidebarArea` never passed it a `ws`; it now gates on the +> store and receives the `ws` send path). The one-shot connected-overlay wiring +> in `main.ts` stays on `ws.onStateChange` deliberately — it needs the exact +> internal transition. Voice controls remain independent: LiveKit reconnection +> "retries underneath" per the table below. | Status | Composer / send | Voice controls | Presence picker | Reconnect banner | |--------|-----------------|----------------|-----------------|------------------| @@ -139,7 +145,7 @@ handling is per-call-site with no shared mapper (`api.ts:81-140` centralizes onl | Class | Source | Target reaction | |-------|--------|-----------------| -| **401 Unauthorized** | any REST call | Global: `clearAuth()` → disconnect → connect page, with "Your session expired — sign in again." (already centralized in `api.ts:116-120` + `main.ts:92-95`; extend to `uploadFile`, which skips it today, `api.ts:380-383`) | +| **401 Unauthorized** | any REST call | Global: `clearAuth()` → disconnect → connect page, with "Your session expired — sign in again." (centralized in `api.ts` + `main.ts`; since 2026-07 `uploadFile` honors it too, and the connect page shows the session-expired reason) | | **403 Forbidden** (action) | REST/WS | Toast "You don't have permission to do that." **and** pre-disable the control so it can't be attempted again in that context | | **403 Suspended/Banned** | login REST / WS `BANNED` | Transient-error store → connect page: "Your account has been suspended." Force logout, no reconnect | | **429 Rate-limited** | REST/WS `RATE_LIMITED` | Non-destructive toast "You're doing that too fast — try again in a moment." Keep the user's input; re-enable the control after a short cooldown | @@ -147,7 +153,7 @@ handling is per-call-site with no shared mapper (`api.ts:81-140` centralizes onl | **Validation (400)** | REST | Inline field error with the server message (capped to a safe length — the login form caps at 200 chars, `LoginForm.ts:598`; apply everywhere) | | **Conflict/Not-found (404/409)** | REST/WS | Contextual inline message + refresh the affected view (the target moved/vanished) | | **5xx / network** | REST | Inline section error + **Retry**; for one-shot actions, a toast "Couldn't reach the server." Never a silent drop | -| **Transport backpressure** | WS `ws_send` "channel full" | Surface it: mark the optimistic row failed with Retry. Today it's dropped silently (`ws.ts:432-437`) | +| **Transport backpressure** | WS `ws_send` "channel full" | Mark the optimistic row failed with Retry (✓ since 2026-07: `ws.onSendFailure` → dispatcher → `markSendFailed` with `NETWORK`/`OFFLINE`; id-less sends like heartbeats stay silent) | | **Cert first-use** | Rust `cert-tofu: trusted_first_use` | 8 s informational banner (already: `main.ts:105-129`) | | **Cert mismatch** | Rust `cert-tofu: mismatch` | Blocking `CertMismatchModal`; Accept re-pins + reconnects, Reject disconnects + returns to connect (already: `main.ts:133-164`) | diff --git a/docs/architecture/ux/channels-members-dms.md b/docs/architecture/ux/channels-members-dms.md index 11063a19..6236d001 100644 --- a/docs/architecture/ux/channels-members-dms.md +++ b/docs/architecture/ux/channels-members-dms.md @@ -145,11 +145,12 @@ and `IsEitherBlocked` is bidirectional). **Target UX:** | Being blocked | Composer read-only with a neutral "You can't message this user right now." (do not reveal the block state explicitly — the server returns a generic refusal) | | Unblock | Composer re-enables | -> **⚠ Current gap.** There is no client-side block-state composer gating (the -> composer has no read-only mode at all — see [messaging.md §2](messaging.md)). -> The block/unblock REST surface exists server-side; the client would refuse a -> DM send only via the generic WS `error`/`FORBIDDEN` path today. Target ties DM -> block state into the same composer-state machine. +> **⚠ Current gap.** There is no client-side block-state composer gating. The +> composer now has a disabled-with-reason mode (see [messaging.md §2](messaging.md)), +> but DM channels are left ungated: the block/unblock REST surface exists +> server-side, and the client refuses a DM send only via the failed-row / +> `FORBIDDEN` path today. Target ties DM block state into the same +> composer-state machine. --- diff --git a/docs/architecture/ux/messaging.md b/docs/architecture/ux/messaging.md index dc479031..2c67736b 100644 --- a/docs/architecture/ux/messaging.md +++ b/docs/architecture/ux/messaging.md @@ -22,11 +22,13 @@ The list renders from `messages.store` (`messagesByChannel`, capped 500/channel) | `loading older` | Scroll-to-top with `hasMore` | Top spinner while `prependMessages` resolves (already `MessageList.ts:459-468`) | | `error` | History fetch failed | **Inline section error + Retry** in the message area | -> **⚠ Current gap — no loading state on history fetch.** `MessageController.loadMessages` -> fetches silently; there is no placeholder in the message slot, only the -> post-render empty state or a toast on failure (`MessageController.ts:73-97`). -> Target: show an in-region loading placeholder while the first page loads, and -> an inline **Retry** on failure instead of a transient toast. +> **✓ Implemented (2026-07).** `messages.store` tracks a per-channel +> `historyLoadState` (`loading` / `error`, absent = idle); `loadMessages` sets it +> synchronously before the fetch and `setMessages` clears it. With no rows, +> `MessageList` renders the matching region state: a `.messages-loading` spinner +> placeholder, or `.messages-load-error` with an inline **Retry** button +> (`onRetryLoad` re-invokes `loadMessages`) — no toast. The welcome/empty state +> renders only once the channel is actually loaded and empty. --- @@ -98,7 +100,7 @@ sequenceDiagram SRV-->>WS: error{code} %% SLOW_MODE / RATE_LIMITED / FORBIDDEN / INVALID_INPUT WS->>S: markSendFailed(correlationId, code) %% row → "failed", Retry else transport drop - WS-->>S: markSendFailed(correlationId, "network") %% ws_send channel-full/closed + WS-->>S: markSendFailed(correlationId, "NETWORK") %% ws_send channel-full/closed end ``` @@ -122,6 +124,10 @@ existing pending/sent row for that id and replace-in-place rather than append. > (`ws/handlers.go` → `buildErrorMsgWithID`), so the dispatcher's `error` handler > maps `SLOW_MODE` / `FORBIDDEN` / `RATE_LIMITED` / `BAD_REQUEST` to the exact > row (`dispatcher.ts`), and an offline send is shown failed rather than dropped. +> The transport-drop arm is wired too: `ws.ts` notifies `onSendFailure(id, code)` +> when `ws_send` fails locally (channel full → `NETWORK`, closed/not-open → +> `OFFLINE`), and the dispatcher fails the matching pending row — fire-and-forget +> sends (typing, presence) have no pending entry and stay silent by design. --- @@ -164,8 +170,10 @@ upload state (already thorough — `MessageInput.ts`). | uploaded | Chip ready; ids attached to the `chat_send` payload | | failed | Inline error on the chip with remove/retry | -Upload goes through `POST /uploads` (multipart). **Target:** `uploadFile` should -honor the global 401 handler like other calls (today it does not — `api.ts:380-383`). +Upload goes through `POST /uploads` (multipart). **✓ Implemented (2026-07):** +`uploadFile` now honors the global 401 handler like every other call — a 401 +calls `onUnauthorized` (clearAuth → connect page with "Your session expired — +sign in again.") and throws `ApiClientError(401)`. ---