Merge pull request #1191 from J3vb/claude/blueprints-architectural-audit-k927qb

feat(client): connection-status store + no-silent-failure batch
This commit is contained in:
J3vb
2026-07-19 21:43:53 +02:00
committed by GitHub
33 changed files with 932 additions and 89 deletions
@@ -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,23 @@ export function createServerBanner(): ServerBannerControl {
root.remove();
}
return { element: root, showRestart, showReconnecting, hide, destroy };
return { element: root, showRestart, showReconnecting, showDisconnected, hide, destroy };
}
/**
* Apply a store connection status to the banner (UX spec §3 table):
* reconnecting → "Reconnecting...", disconnected → "Disconnected",
* connected → hidden.
*/
export function applyConnectionStatus(
banner: ServerBannerControl,
status: "connected" | "reconnecting" | "disconnected",
): void {
if (status === "reconnecting") {
banner.showReconnecting();
} else if (status === "disconnected") {
banner.showDisconnected();
} else {
banner.hide();
}
}
+16 -14
View File
@@ -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:
+8 -2
View File
@@ -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);
+24 -1
View File
@@ -3,8 +3,9 @@
// Each server message type maps to one or more store actions.
import type { WsClient } from "./ws";
import { toConnectionStatus } from "./ws";
import { authStore, setAuth, clearAuth } from "@stores/auth.store";
import { setTransientError } from "@stores/ui.store";
import { setTransientError, setConnectionStatus } from "@stores/ui.store";
import {
setChannels,
setRoles,
@@ -85,6 +86,16 @@ function mapDmPayload(p: DmChannelPayload): DmChannel {
/** Unsubscribe all listeners. */
export type DispatcherCleanup = () => void;
/**
* The single writer for ui.store.connectionStatus (UX spec §3): collapses the
* ws client's internal state machine onto the 3-state status. Wired once at
* startup and kept for the app's lifetime — deliberately separate from
* wireDispatcher, whose listeners are torn down per connection.
*/
export function wireConnectionStatus(ws: Pick<WsClient, "onStateChange">): () => void {
return ws.onStateChange((s) => setConnectionStatus(toConnectionStatus(s)));
}
/**
* Wire a WsClient to all domain stores.
* Returns a cleanup function that removes all listeners.
@@ -420,6 +431,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", {
+57 -4
View File
@@ -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);
+7 -1
View File
@@ -10,7 +10,7 @@ import { installGlobalErrorHandlers, safeMount } from "@lib/safe-render";
import { createRouter } from "@lib/router";
import { createApiClient } from "@lib/api";
import { createWsClient } from "@lib/ws";
import { wireDispatcher } from "@lib/dispatcher";
import { wireDispatcher, wireConnectionStatus } from "@lib/dispatcher";
import { authStore, clearAuth } from "@stores/auth.store";
import { setTransientError } from "@stores/ui.store";
import { voiceStore, leaveVoiceChannel } from "@stores/voice.store";
@@ -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.
wireConnectionStatus(ws);
const profileManager = createProfileManager(createTauriBackend());
let dispatcherCleanup: (() => void) | null = null;
let connectedOverlay: ConnectedOverlayControl | null = null;
+21 -13
View File
@@ -9,14 +9,14 @@ import type { ApiClient } from "@lib/api";
import { createLogger } from "@lib/logger";
import { createRateLimiterSet } from "@lib/rate-limiter";
import type { VideoGridComponent } from "@components/VideoGrid";
import { createServerBanner } from "@components/ServerBanner";
import { createServerBanner, applyConnectionStatus } from "@components/ServerBanner";
import type { ServerBannerControl } from "@components/ServerBanner";
import { createSettingsOverlay } from "@components/SettingsOverlay";
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,20 +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;
applyConnectionStatus(banner, status);
} catch (err) {
log.error("Connection status handler error", err);
}
} catch (err) {
log.error("State change handler error", err);
}
}),
},
),
);
// Synchronous initial sync: the selector subscription baselines on the
// current value and only fires on change, so a MainPage mounted mid-outage
// (status already "reconnecting") would otherwise never show the banner —
// the whole retry cycle maps to the same 3-state value.
applyConnectionStatus(banner, uiStore.getState().connectionStatus);
unsubscribers.push(
ws.on("server_restart", (payload) => {
@@ -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,9 @@ 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…";
const status = uiStore.getState().connectionStatus;
if (status === "reconnecting") return "Reconnecting…";
if (status === "disconnected") return "Not connected";
const ch = channelsStore.getState().channels.get(channelId);
if (ch === undefined) return null;
if (!ch.canSend) {
@@ -327,7 +335,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,15 @@ 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);
// The inline region only renders when the channel has no rows; live
// broadcasts or an optimistic send may already have populated it, in
// which case the failure must still be surfaced (no silent drop).
if (getChannelMessages(channelId).length > 0) {
showError("Failed to load message history");
}
}
}
}
@@ -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);
}
+15
View File
@@ -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" }));
@@ -15,6 +15,7 @@ const {
mockSetReplyTo,
mockStartEdit,
mockScrollToMessage,
mockSetDisabled,
} = vi.hoisted(() => ({
mockMessageListMount: vi.fn(),
mockMessageListDestroy: vi.fn(),
@@ -33,6 +34,7 @@ const {
mockSetReplyTo: vi.fn(),
mockStartEdit: vi.fn(),
mockScrollToMessage: vi.fn(() => true),
mockSetDisabled: vi.fn(),
}));
vi.mock("@lib/logger", () => ({
@@ -80,7 +82,7 @@ vi.mock("@components/MessageInput", () => ({
startEdit: mockStartEdit,
clearReply: vi.fn(),
cancelEdit: vi.fn(),
setDisabled: vi.fn(),
setDisabled: mockSetDisabled,
};
}),
}));
@@ -150,6 +152,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 +204,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 +356,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");
@@ -362,6 +368,68 @@ describe("createChannelController", () => {
expect(mockMarkSendFailed).toHaveBeenCalledWith(expect.any(String), "OFFLINE");
});
it("composer disable reason distinguishes reconnecting from disconnected", () => {
const opts = makeOpts();
setConnectionStatus("reconnecting");
const ctrl = createChannelController(opts);
ctrl.mountChannel(42, "general");
expect(mockSetDisabled).toHaveBeenLastCalledWith("Reconnecting…");
ctrl.destroyChannel();
setConnectionStatus("disconnected");
ctrl.mountChannel(43, "general-2");
expect(mockSetDisabled).toHaveBeenLastCalledWith("Not connected");
});
it("onRetryLoad re-invokes loadMessages for the mounted channel", () => {
const opts = makeOpts();
const ctrl = createChannelController(opts);
ctrl.mountChannel(42, "general");
(opts.msgCtrl.loadMessages as ReturnType<typeof vi.fn>).mockClear();
capturedMessageListOpts.onRetryLoad();
expect(opts.msgCtrl.loadMessages).toHaveBeenCalledWith(42, expect.any(AbortSignal));
});
it("onRetry re-sends the failed draft with a fresh correlation id", () => {
const opts = makeOpts();
let n = 0;
(opts.ws.send as ReturnType<typeof vi.fn>).mockImplementation(() => `cid-${++n}`);
const ctrl = createChannelController(opts);
ctrl.mountChannel(42, "general");
// cid-1 is channel_focus; the chat_send gets cid-2.
capturedMessageInputOpts.onSend("hello", null, []);
expect(mockAddOptimistic).toHaveBeenCalledWith(
expect.objectContaining({ correlationId: "cid-2", content: "hello" }),
);
capturedMessageListOpts.onRetry("cid-2");
// The old row is discarded and the draft re-sent under a new id.
expect(mockRemoveOptimistic).toHaveBeenCalledWith("cid-2");
expect(mockAddOptimistic).toHaveBeenLastCalledWith(
expect.objectContaining({ correlationId: "cid-3", content: "hello" }),
);
});
it("onDeleteDraft discards the failed row without re-sending", () => {
const opts = makeOpts();
let n = 0;
(opts.ws.send as ReturnType<typeof vi.fn>).mockImplementation(() => `cid-${++n}`);
const ctrl = createChannelController(opts);
ctrl.mountChannel(42, "general");
capturedMessageInputOpts.onSend("hello", null, []);
const sendCalls = (opts.ws.send as ReturnType<typeof vi.fn>).mock.calls.length;
capturedMessageListOpts.onDeleteDraft("cid-2");
expect(mockRemoveOptimistic).toHaveBeenCalledWith("cid-2");
expect((opts.ws.send as ReturnType<typeof vi.fn>).mock.calls.length).toBe(sendCalls);
});
it("onTyping sends typing_start via ws", () => {
const opts = makeOpts();
const ctrl = createChannelController(opts);
@@ -36,6 +36,7 @@ function resetStores(): void {
pendingSends: new Map(),
loadedChannels: new Set(),
hasMore: new Map(),
historyLoadState: new Map(),
}));
membersStore.setState(() => ({
members: new Map(),
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { wireDispatcher } from "../../src/lib/dispatcher";
import { wireDispatcher, wireConnectionStatus } from "../../src/lib/dispatcher";
import { createMockWsClient } from "../helpers/mock-ws";
import { authStore, clearAuth } from "../../src/stores/auth.store";
import { channelsStore } from "../../src/stores/channels.store";
import {
@@ -43,6 +44,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 +60,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 +81,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 +114,7 @@ describe("WS Dispatcher", () => {
pendingSends: new Map(),
loadedChannels: new Set(),
hasMore: new Map(),
historyLoadState: new Map(),
}));
membersStore.setState(() => ({
members: new Map(),
@@ -798,6 +811,7 @@ describe("WS Dispatcher", () => {
pendingSends: new Map(),
loadedChannels: new Set(),
hasMore: new Map(),
historyLoadState: new Map(),
}));
uiStore.setState((prev) => ({ ...prev, transientError: null }));
@@ -820,6 +834,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,
@@ -1210,3 +1255,30 @@ describe("WS Dispatcher", () => {
expect(messagesStore.getState().messagesByChannel.get(1)).toBeUndefined();
});
});
describe("wireConnectionStatus", () => {
beforeEach(() => {
uiStore.setState((prev) => ({ ...prev, connectionStatus: "disconnected" }));
});
it("writes ws state changes into ui.store.connectionStatus via the 5→3 mapping", () => {
const mockWs = createMockWsClient();
const unsub = wireConnectionStatus(mockWs);
mockWs.simulateStateChange("connecting");
expect(uiStore.getState().connectionStatus).toBe("reconnecting");
mockWs.simulateStateChange("authenticating");
expect(uiStore.getState().connectionStatus).toBe("reconnecting");
mockWs.simulateStateChange("connected");
expect(uiStore.getState().connectionStatus).toBe("connected");
mockWs.simulateStateChange("disconnected");
expect(uiStore.getState().connectionStatus).toBe("disconnected");
unsub();
mockWs.simulateStateChange("connected");
expect(uiStore.getState().connectionStatus).toBe("disconnected");
});
});
@@ -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,28 @@ 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("falls back to a toast on failure when the channel already has rows", async () => {
// Live broadcasts or an optimistic send can populate a channel before
// history loads; the inline region won't render then, so the failure
// must surface as a toast instead of silently.
mockGetChannelMessages.mockReturnValue([{ id: 5, content: "live row" }]);
const api = makeApi({
getMessages: vi.fn().mockRejectedValue(new Error("network error")),
});
const ctrl = createMessageController({ api, showError });
await ctrl.loadMessages(42, makeAbort().signal);
expect(mockSetChannelLoadError).toHaveBeenCalledWith(42);
expect(showError).toHaveBeenCalledWith("Failed to load message history");
});
it("does not mark an error when aborted before failure", async () => {
const { signal, abort } = makeAbort();
abort();
const api = makeApi({
@@ -130,6 +171,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,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createServerBanner } from "@components/ServerBanner";
import { createServerBanner, applyConnectionStatus } from "@components/ServerBanner";
describe("ServerBanner", () => {
beforeEach(() => {
@@ -47,6 +47,33 @@ describe("ServerBanner", () => {
banner.destroy();
});
it('showDisconnected adds visible class with "Disconnected" text', () => {
const banner = createServerBanner();
banner.showDisconnected();
expect(banner.element.classList.contains("visible")).toBe(true);
expect(banner.element.textContent).toBe("Disconnected");
banner.destroy();
});
it("applyConnectionStatus maps each store status to the right banner state", () => {
const banner = createServerBanner();
applyConnectionStatus(banner, "reconnecting");
expect(banner.element.classList.contains("visible")).toBe(true);
expect(banner.element.textContent).toBe("Reconnecting...");
applyConnectionStatus(banner, "disconnected");
expect(banner.element.classList.contains("visible")).toBe(true);
expect(banner.element.textContent).toBe("Disconnected");
applyConnectionStatus(banner, "connected");
expect(banner.element.classList.contains("visible")).toBe(false);
banner.destroy();
});
it("countdown decrements every second", () => {
const banner = createServerBanner();
banner.showRestart(3);
@@ -1104,6 +1104,18 @@ describe("SidebarArea", () => {
cleanup(result);
});
it("passes the ws client to the user bar (presence picker send path)", () => {
const opts = defaultOpts();
const result = createSidebarArea(opts);
container.appendChild(result.sidebarWrapper);
// Without ws, the status picker is permanently disabled and
// presence_update can never be sent — this pins the fix.
expect(createUserBar).toHaveBeenCalledWith(expect.objectContaining({ ws: opts.ws }));
cleanup(result);
});
it("voice widget and user bar are included in children", () => {
const result = createSidebarArea(defaultOpts());
expect(result.children.length).toBe(2);
@@ -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 {
+182 -1
View File
@@ -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");
});
});
+18 -10
View File
@@ -79,14 +79,22 @@ 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`, synced at mount and now also
> showing "Disconnected" instead of going stale), the composer gating
> (`ChannelController`, "Reconnecting…" / "Not connected" per the table), 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.
> **Remaining gap:** the table's voice column. Voice controls are not yet
> frozen during a WS reconnect — LiveKit reconnection retries underneath, but
> join/leave controls stay enabled and would send over the down socket.
| Status | Composer / send | Voice controls | Presence picker | Reconnect banner |
|--------|-----------------|----------------|-----------------|------------------|
@@ -139,7 +147,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 +155,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`) |
+6 -5
View File
@@ -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.
---
+17 -9
View File
@@ -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.
---
@@ -57,7 +59,7 @@ stateDiagram-v2
| `enabled` | Editable textarea, attach + pickers active | — |
| `read-only` (announcement, no MANAGE_MESSAGES) | Textarea replaced by a disabled bar | "Only moderators can post in announcement channels." |
| `no-permission` | Disabled bar | "You don't have permission to send messages here." |
| `offline` | Disabled, "Reconnecting…" | connection status (README §3) |
| `offline` | Disabled "Reconnecting…" while retrying, "Not connected" when disconnected | connection status (README §3) |
| `slow-mode` | Disabled with a live countdown | "Slow mode: wait Ns." |
| `uploading` | Send disabled until uploads settle (already `MessageInput.ts:138-141`) | per-attachment spinner |
@@ -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, code) %% channel full → "NETWORK"; closed/not-open → "OFFLINE"
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)`.
---