mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
feat(client): connection-status store + no-silent-failure batch
Implements the next four gaps from the client UX spec (docs/architecture/ux).
Connection status as single source of truth (spec §3):
- main.ts registers the one writer: ws.onStateChange → toConnectionStatus
(new 5→3 state mapper exported from ws.ts) → ui.store.connectionStatus.
- Consumers now subscribe to the store instead of ad-hoc ws wirings: the
MainPage reconnect banner (which also gains a "Disconnected" state via
ServerBanner.showDisconnected instead of going stale), ChannelController
composer gating + per-click send guard, and the UserBar presence picker.
- Fixes a latent production bug: SidebarArea never passed ws to UserBar, so
the status picker was permanently disabled and its presence_update path
dead. It now gates on the store and receives the ws send path.
- The one-shot connected-overlay wiring stays on ws.onStateChange by design
(it needs the exact internal transition); LiveKit voice reconnection stays
independent ("retrying underneath").
Transport backpressure surfaced (spec §5):
- ws.ts sendRaw no longer drops local send failures silently: send() passes
the envelope id, and failures notify a new onSendFailure(id, code)
listener — channel full → NETWORK, closed/not-open → OFFLINE (deferred a
microtask on the not-open path so the optimistic row registers first).
- The dispatcher fails the matching pending row via markSendFailed, exactly
like a server error reply; id-less sends (heartbeat) and fire-and-forget
sends (typing, presence) stay silent by design. MessageList renders the
new NETWORK reason ("Connection problem — message not sent").
uploadFile honors global 401 handling (spec §5):
- api.uploadFile now calls onUnauthorized and throws ApiClientError(401)
like every other REST call; main.ts sets the "Your session expired — sign
in again." transient error so the connect page shows the reason.
History fetch loading/error states (messaging.md §1):
- messages.store gains per-channel historyLoadState (loading/error, absent
= idle) with setChannelLoading/setChannelLoadError; setMessages and
clearChannelMessages clear it.
- MessageController.loadMessages sets loading synchronously before the
fetch and marks error inline instead of a toast; MessageList renders an
in-region spinner placeholder or an inline error + Retry (onRetryLoad
re-invokes loadMessages via ChannelController).
Also fixes two pre-existing eslint errors in api.ts (redundant assertions).
Docs: the corresponding gap callouts in docs/architecture/ux are updated
(README §3/§5, messaging.md §1/§3/§6, channels-members-dms.md block-gating
note no longer claims the composer lacks a read-only mode).
Verified: tsc + full client unit suite (3225 tests) + oxlint/eslint +
prettier all green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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<T extends ServerMessage["type"]> = (
|
||||
payload: Extract<ServerMessage, { type: T }>["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<CertMismatchListener>();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -60,6 +60,11 @@ export interface MessagesState {
|
||||
readonly loadedChannels: ReadonlySet<number>;
|
||||
/** Whether more messages exist above for a channel */
|
||||
readonly hasMore: ReadonlyMap<number, boolean>;
|
||||
/**
|
||||
* First-page history fetch state per channel. Absent entry = idle (loaded or
|
||||
* never requested) — the message region then renders normally/empty.
|
||||
*/
|
||||
readonly historyLoadState: ReadonlyMap<number, "loading" | "error">;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -20,6 +20,7 @@ export function createMockWsClient() {
|
||||
const sent: SentEnvelope[] = [];
|
||||
const listeners = new Map<string, Set<WsListener<ServerMessage["type"]>>>();
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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<typeof vi.fn>).mockReturnValue("disconnected");
|
||||
setConnectionStatus("disconnected");
|
||||
const ctrl = createChannelController(opts);
|
||||
ctrl.mountChannel(42, "general");
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ function resetStores(): void {
|
||||
pendingSends: new Map(),
|
||||
loadedChannels: new Set(),
|
||||
hasMore: new Map(),
|
||||
historyLoadState: new Map(),
|
||||
}));
|
||||
membersStore.setState(() => ({
|
||||
members: new Map(),
|
||||
|
||||
@@ -43,6 +43,7 @@ vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
*/
|
||||
function createMockWs() {
|
||||
const listeners = new Map<string, Set<WsListener<ServerMessage["type"]>>>();
|
||||
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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof createMessageList>;
|
||||
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user