From a546af2d4f087e514c2263b28f8eddb6cb4d93c8 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:19:31 +0200 Subject: [PATCH 1/2] feat(dm): gate DM composer on block state with spec reasons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire DM block state into the existing disabled-with-reason composer mode (channels-members-dms.md §3.2). A new blocks.store holds two directions: - blockedByMe (from GET /blocks on every ready) -> "You've blocked this user. Unblock to send messages." - blockedByThem (inferred from a refused DM send: ErrBlocked -> FORBIDDEN, cleared on the next ready) -> neutral "You can't message this user right now.", never revealing the block explicitly. ChannelController reads dmComposerBlockReason(recipientId) and subscribes to blocks.store so an unblock (shrunken GET /blocks) re-enables the composer live; blockedByMe takes precedence when both apply. Adds api.listBlocks(), threads an optional api into wireDispatcher, and covers both directions plus un-gating in blocks-store / channel-controller / dispatcher tests. Co-Authored-By: Claude Fable 5 --- Client/tauri-client/src/lib/api.ts | 6 ++ Client/tauri-client/src/lib/dispatcher.ts | 34 +++++++- Client/tauri-client/src/lib/types.ts | 5 ++ Client/tauri-client/src/main.ts | 2 +- .../src/pages/main-page/ChannelController.ts | 24 +++++- .../src/pages/main-page/SidebarDmHelpers.ts | 4 +- .../tauri-client/src/stores/blocks.store.ts | 64 ++++++++++++++ .../tests/unit/blocks-store.test.ts | 83 +++++++++++++++++++ .../tests/unit/channel-controller.test.ts | 75 +++++++++++++++++ .../tests/unit/dispatcher.test.ts | 64 ++++++++++++++ docs/architecture/ux/channels-members-dms.md | 22 +++-- 11 files changed, 369 insertions(+), 14 deletions(-) create mode 100644 Client/tauri-client/src/stores/blocks.store.ts create mode 100644 Client/tauri-client/tests/unit/blocks-store.test.ts diff --git a/Client/tauri-client/src/lib/api.ts b/Client/tauri-client/src/lib/api.ts index f46437ba..f0a24f85 100644 --- a/Client/tauri-client/src/lib/api.ts +++ b/Client/tauri-client/src/lib/api.ts @@ -22,6 +22,7 @@ import type { MemberResponse, DmChannelsResponse, CreateDmResponse, + BlockedUsersResponse, } from "./types"; /** Configuration for the API client. */ @@ -445,6 +446,11 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?: return request("DELETE", `/dms/${channelId}`, undefined, signal); }, + /** List recipient user IDs the current user has blocked. */ + listBlocks(signal?: AbortSignal): Promise { + return request("GET", "/blocks", undefined, signal); + }, + // ── Voice ───────────────────────────────────────────── getVoiceCredentials(signal?: AbortSignal): Promise { diff --git a/Client/tauri-client/src/lib/dispatcher.ts b/Client/tauri-client/src/lib/dispatcher.ts index 1dc766b6..389bbc7e 100644 --- a/Client/tauri-client/src/lib/dispatcher.ts +++ b/Client/tauri-client/src/lib/dispatcher.ts @@ -52,7 +52,9 @@ import { updateDmLastMessagePreview, } from "@stores/dm.store"; import type { DmChannel } from "@stores/dm.store"; +import { setBlockedByMe, setUserBlockedByThem, clearBlockedByThem } from "@stores/blocks.store"; import type { DmChannelPayload } from "./types"; +import type { ApiClient } from "./api"; import { handleVoiceToken, handleE2EEAnnounce, @@ -99,8 +101,14 @@ export function wireConnectionStatus(ws: Pick): () => /** * Wire a WsClient to all domain stores. * Returns a cleanup function that removes all listeners. + * + * `api` is optional so tests can wire the dispatcher without a client; when + * present it is used to refresh DM block state (GET /blocks) on ready. */ -export function wireDispatcher(ws: WsClient): DispatcherCleanup { +export function wireDispatcher( + ws: WsClient, + api?: Pick, +): DispatcherCleanup { const unsubs: Array<() => void> = []; // ── Auth ────────────────────────────────────────────── @@ -156,6 +164,17 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup { setDmChannels(dmPayloads.map(mapDmPayload)); } + // Refresh DM block state (channels-members-dms.md §3.2). "Being blocked" + // is only known from a refused send, so it's stale after a reconnect — + // clear it and re-fetch our own outgoing blocks authoritatively. + clearBlockedByThem(); + if (api !== undefined) { + api + .listBlocks() + .then((r) => setBlockedByMe(r.blocked_user_ids)) + .catch((err) => log.warn("Failed to load block list", { error: String(err) })); + } + log.info("Ready payload applied", { channels: payload.channels.length, members: payload.members.length, @@ -460,6 +479,19 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup { // that specific row failed (with retry) instead of a global toast. This // covers SLOW_MODE, FORBIDDEN, RATE_LIMITED, BAD_REQUEST, etc. on send. if (id && messagesStore.getState().pendingSends.has(id)) { + // A FORBIDDEN on a DM send is the server's generic block refusal + // (ErrBlocked → FORBIDDEN, bidirectional). Gate the composer with the + // neutral "being blocked" reason; blocks.store precedence still shows + // the explicit reason if we are the blocker. Read the channel before + // markSendFailed clears the pending row. + if (payload.code === "FORBIDDEN") { + const chId = messagesStore.getState().pendingSends.get(id); + const dm = + chId === undefined + ? undefined + : dmStore.getState().channels.find((c) => c.channelId === chId); + if (dm !== undefined) setUserBlockedByThem(dm.recipient.id, true); + } markSendFailed(id, payload.code); return; } diff --git a/Client/tauri-client/src/lib/types.ts b/Client/tauri-client/src/lib/types.ts index 3f32ab2f..eb3d52ed 100644 --- a/Client/tauri-client/src/lib/types.ts +++ b/Client/tauri-client/src/lib/types.ts @@ -650,6 +650,11 @@ export interface CreateDmResponse { readonly created: boolean; } +/** GET /api/v1/blocks response. */ +export interface BlockedUsersResponse { + readonly blocked_user_ids: readonly number[]; +} + /** TURN/STUN credentials from GET /api/voice/credentials. */ export interface IceServer { readonly urls: string; diff --git a/Client/tauri-client/src/main.ts b/Client/tauri-client/src/main.ts index cb80f535..4e387f35 100644 --- a/Client/tauri-client/src/main.ts +++ b/Client/tauri-client/src/main.ts @@ -240,7 +240,7 @@ function renderPage(pageId: "connect" | "main"): void { lastConnectHost = host; lastConnectToken = token; ws.connect({ host, token }); - dispatcherCleanup = wireDispatcher(ws); + dispatcherCleanup = wireDispatcher(ws, api); log.info("Dispatcher wired, connecting WS"); // BUG-135: Only persist credentials when the user opted in. diff --git a/Client/tauri-client/src/pages/main-page/ChannelController.ts b/Client/tauri-client/src/pages/main-page/ChannelController.ts index c625b0ff..8f955636 100644 --- a/Client/tauri-client/src/pages/main-page/ChannelController.ts +++ b/Client/tauri-client/src/pages/main-page/ChannelController.ts @@ -30,6 +30,7 @@ import type { ReactionController } from "./ReactionController"; import { updateChatHeaderForDm } from "./ChatHeader"; import type { ChatHeaderRefs } from "./ChatHeader"; import { dmStore } from "@stores/dm.store"; +import { blocksStore, dmComposerBlockReason } from "@stores/blocks.store"; import { membersStore } from "@stores/members.store"; import { channelsStore } from "@stores/channels.store"; import { uiStore } from "@stores/ui.store"; @@ -314,14 +315,20 @@ export function createChannelController(opts: ChannelControllerOptions): Channel // Composer gating: express permission + connection as affordance. The // composer disables (with a reason) when the socket is down or the user - // may not post here, instead of accepting a click and failing. DM channels - // are not in channelsStore (dmStore), so they are left ungated here — the - // server still enforces block/permission and a refused send shows as a - // failed row. + // may not post here, instead of accepting a click and failing. For DM + // channels the reason also covers block state (channels-members-dms.md §3.2). + const dmRecipientId = + channelType === "dm" + ? (dmStore.getState().channels.find((c) => c.channelId === channelId)?.recipient.id ?? null) + : null; const computeComposerReason = (): string | null => { const status = uiStore.getState().connectionStatus; if (status === "reconnecting") return "Reconnecting…"; if (status === "disconnected") return "Not connected"; + if (dmRecipientId !== null) { + const blockReason = dmComposerBlockReason(blocksStore.getState(), dmRecipientId); + if (blockReason !== null) return blockReason; + } const ch = channelsStore.getState().channels.get(channelId); if (ch === undefined) return null; if (!ch.canSend) { @@ -347,6 +354,15 @@ export function createChannelController(opts: ChannelControllerOptions): Channel () => refreshComposerState(), ), ); + if (dmRecipientId !== null) { + // Un-gate live when the block clears (unblock) and gate on a refused send. + composerGatingUnsubs.push( + blocksStore.subscribeSelector( + (s) => dmComposerBlockReason(s, dmRecipientId), + () => refreshComposerState(), + ), + ); + } // Arrow-up edit: listen for edit-last-message bubbling from MessageInput slots.inputSlot.addEventListener( diff --git a/Client/tauri-client/src/pages/main-page/SidebarDmHelpers.ts b/Client/tauri-client/src/pages/main-page/SidebarDmHelpers.ts index bb88f57d..4ef63d6d 100644 --- a/Client/tauri-client/src/pages/main-page/SidebarDmHelpers.ts +++ b/Client/tauri-client/src/pages/main-page/SidebarDmHelpers.ts @@ -72,8 +72,8 @@ export function addDmToChannelsStore(dmChannel: DmChannel): void { position: 0, unreadCount: dmChannel.unreadCount, lastMessageId: dmChannel.lastMessageId, - // DMs are always postable from the client; the server enforces block state, - // and a refused send surfaces as a failed row rather than a disabled composer. + // Channel-level permission is always true for DMs; block state is layered on + // top by the composer via blocks.store (see ChannelController), not canSend. canSend: true, }; channelsStore.setState((prev) => { diff --git a/Client/tauri-client/src/stores/blocks.store.ts b/Client/tauri-client/src/stores/blocks.store.ts new file mode 100644 index 00000000..a2f82062 --- /dev/null +++ b/Client/tauri-client/src/stores/blocks.store.ts @@ -0,0 +1,64 @@ +/** + * Blocks store — holds DM block state so the composer can gate on it. + * Immutable state updates only. + * + * Two directions, per channels-members-dms.md §3.2: + * - blockedByMe: recipient userIds the local user has blocked (authoritative, + * from GET /blocks). Shows "You've blocked this user…". + * - blockedByThem: recipient userIds whose DM refused a send (the server returns + * a generic FORBIDDEN, so this is learned from a refused send, + * not revealed up front). Shows the neutral "You can't message…". + * Cleared on every ready so a fresh session re-evaluates. + */ + +import { createStore } from "@lib/store"; + +/** Shown when the local user is the blocker. */ +export const BLOCKED_BY_ME_REASON = "You've blocked this user. Unblock to send messages."; +/** Neutral reason for the blocking direction — never reveals the block explicitly. */ +export const BLOCKED_BY_THEM_REASON = "You can't message this user right now."; + +export interface BlocksState { + readonly blockedByMe: ReadonlySet; + readonly blockedByThem: ReadonlySet; +} + +const INITIAL: BlocksState = { + blockedByMe: new Set(), + blockedByThem: new Set(), +}; + +export const blocksStore = createStore(INITIAL); + +/** Replace the blocked-by-me set (from GET /blocks). */ +export function setBlockedByMe(userIds: readonly number[]): void { + blocksStore.setState((prev) => ({ ...prev, blockedByMe: new Set(userIds) })); +} + +/** Mark (or unmark) a recipient as having refused our DM. */ +export function setUserBlockedByThem(userId: number, blocked: boolean): void { + blocksStore.setState((prev) => { + if (prev.blockedByThem.has(userId) === blocked) return prev; + const next = new Set(prev.blockedByThem); + if (blocked) next.add(userId); + else next.delete(userId); + return { ...prev, blockedByThem: next }; + }); +} + +/** Clear all blocked-by-them state (called on ready — stale after reconnect). */ +export function clearBlockedByThem(): void { + blocksStore.setState((prev) => + prev.blockedByThem.size === 0 ? prev : { ...prev, blockedByThem: new Set() }, + ); +} + +/** + * The composer disable reason for a DM with `recipientId`, or null if unblocked. + * blockedByMe takes precedence so the user always sees that they are the blocker. + */ +export function dmComposerBlockReason(state: BlocksState, recipientId: number): string | null { + if (state.blockedByMe.has(recipientId)) return BLOCKED_BY_ME_REASON; + if (state.blockedByThem.has(recipientId)) return BLOCKED_BY_THEM_REASON; + return null; +} diff --git a/Client/tauri-client/tests/unit/blocks-store.test.ts b/Client/tauri-client/tests/unit/blocks-store.test.ts new file mode 100644 index 00000000..4fd32e66 --- /dev/null +++ b/Client/tauri-client/tests/unit/blocks-store.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + blocksStore, + setBlockedByMe, + setUserBlockedByThem, + clearBlockedByThem, + dmComposerBlockReason, + BLOCKED_BY_ME_REASON, + BLOCKED_BY_THEM_REASON, +} from "../../src/stores/blocks.store"; + +describe("blocksStore", () => { + beforeEach(() => { + blocksStore.setState(() => ({ blockedByMe: new Set(), blockedByThem: new Set() })); + }); + + describe("dmComposerBlockReason", () => { + it("returns null when the recipient is not blocked in either direction", () => { + expect(dmComposerBlockReason(blocksStore.getState(), 5)).toBeNull(); + }); + + it("gates with the explicit reason when the local user blocked them", () => { + setBlockedByMe([5]); + expect(dmComposerBlockReason(blocksStore.getState(), 5)).toBe(BLOCKED_BY_ME_REASON); + expect(BLOCKED_BY_ME_REASON).toBe("You've blocked this user. Unblock to send messages."); + }); + + it("gates with the neutral reason when being blocked", () => { + setUserBlockedByThem(7, true); + expect(dmComposerBlockReason(blocksStore.getState(), 7)).toBe(BLOCKED_BY_THEM_REASON); + expect(BLOCKED_BY_THEM_REASON).toBe("You can't message this user right now."); + }); + + it("prefers the explicit reason over the neutral one when both apply", () => { + setBlockedByMe([9]); + setUserBlockedByThem(9, true); + expect(dmComposerBlockReason(blocksStore.getState(), 9)).toBe(BLOCKED_BY_ME_REASON); + }); + + it("only gates the blocked recipient, not others", () => { + setBlockedByMe([5]); + expect(dmComposerBlockReason(blocksStore.getState(), 6)).toBeNull(); + }); + }); + + describe("un-gating", () => { + it("un-gates when the local user unblocks (blockedByMe cleared)", () => { + setBlockedByMe([5]); + expect(dmComposerBlockReason(blocksStore.getState(), 5)).toBe(BLOCKED_BY_ME_REASON); + setBlockedByMe([]); // GET /blocks after an unblock returns the shrunken list + expect(dmComposerBlockReason(blocksStore.getState(), 5)).toBeNull(); + }); + + it("un-gates a being-blocked recipient when cleared on reconnect", () => { + setUserBlockedByThem(7, true); + expect(dmComposerBlockReason(blocksStore.getState(), 7)).toBe(BLOCKED_BY_THEM_REASON); + clearBlockedByThem(); + expect(dmComposerBlockReason(blocksStore.getState(), 7)).toBeNull(); + }); + + it("setUserBlockedByThem(false) removes a single recipient", () => { + setUserBlockedByThem(7, true); + setUserBlockedByThem(8, true); + setUserBlockedByThem(7, false); + expect(dmComposerBlockReason(blocksStore.getState(), 7)).toBeNull(); + expect(dmComposerBlockReason(blocksStore.getState(), 8)).toBe(BLOCKED_BY_THEM_REASON); + }); + }); + + describe("immutability", () => { + it("setUserBlockedByThem is a no-op (same reference) when state is unchanged", () => { + const before = blocksStore.getState(); + setUserBlockedByThem(7, false); // not present → no change + expect(blocksStore.getState()).toBe(before); + }); + + it("clearBlockedByThem is a no-op (same reference) when already empty", () => { + const before = blocksStore.getState(); + clearBlockedByThem(); + expect(blocksStore.getState()).toBe(before); + }); + }); +}); diff --git a/Client/tauri-client/tests/unit/channel-controller.test.ts b/Client/tauri-client/tests/unit/channel-controller.test.ts index cdd2c898..85b2c631 100644 --- a/Client/tauri-client/tests/unit/channel-controller.test.ts +++ b/Client/tauri-client/tests/unit/channel-controller.test.ts @@ -146,6 +146,25 @@ vi.mock("@stores/members.store", () => ({ membersStore: { getState: mockMembersStoreGetState }, })); +// Block-state gating: capture the subscription callback so tests can simulate a +// live change (block/unblock) and mock the reason the store reports. +const { mockBlocksGetState, mockDmComposerBlockReason, blocksSubscribers } = vi.hoisted(() => ({ + mockBlocksGetState: vi.fn(() => ({ blockedByMe: new Set(), blockedByThem: new Set() })), + mockDmComposerBlockReason: vi.fn((): string | null => null), + blocksSubscribers: [] as Array<() => void>, +})); + +vi.mock("@stores/blocks.store", () => ({ + blocksStore: { + getState: mockBlocksGetState, + subscribeSelector: vi.fn((_sel: unknown, cb: () => void) => { + blocksSubscribers.push(cb); + return () => {}; + }), + }, + dmComposerBlockReason: mockDmComposerBlockReason, +})); + // --------------------------------------------------------------------------- // Imports // --------------------------------------------------------------------------- @@ -204,6 +223,8 @@ describe("createChannelController", () => { vi.clearAllMocks(); capturedMessageListOpts = null; capturedMessageInputOpts = null; + blocksSubscribers.length = 0; + mockDmComposerBlockReason.mockReturnValue(null); // The controller gates sends on the store-backed connection status // (docs/architecture/ux §3), not on ws.getState(). setConnectionStatus("connected"); @@ -844,6 +865,60 @@ describe("createChannelController", () => { }); }); + describe("DM composer block gating", () => { + function mountDm(reason: string | null): void { + mockDmStoreGetState.mockReturnValue({ + channels: [ + { + channelId: 42, + recipient: { id: 5, username: "alice", avatar: "", status: "online" }, + lastMessageId: null, + lastMessage: "", + lastMessageAt: "", + unreadCount: 0, + }, + ], + }); + mockDmComposerBlockReason.mockReturnValue(reason); + const ctrl = createChannelController(makeOpts()); + ctrl.mountChannel(42, "alice", "dm"); + } + + it("disables the DM composer with the explicit reason when the user blocked them", () => { + mountDm("You've blocked this user. Unblock to send messages."); + expect(mockSetDisabled).toHaveBeenLastCalledWith( + "You've blocked this user. Unblock to send messages.", + ); + }); + + it("disables the DM composer with the neutral reason when being blocked", () => { + mountDm("You can't message this user right now."); + expect(mockSetDisabled).toHaveBeenLastCalledWith("You can't message this user right now."); + }); + + it("leaves the DM composer enabled when not blocked", () => { + mountDm(null); + expect(mockSetDisabled).toHaveBeenLastCalledWith(null); + }); + + it("un-gates live when the block clears (unblock)", () => { + mountDm("You've blocked this user. Unblock to send messages."); + expect(mockSetDisabled).toHaveBeenLastCalledWith( + "You've blocked this user. Unblock to send messages.", + ); + // Simulate an unblock: the store reports no reason and notifies subscribers. + mockDmComposerBlockReason.mockReturnValue(null); + for (const cb of blocksSubscribers) cb(); + expect(mockSetDisabled).toHaveBeenLastCalledWith(null); + }); + + it("does not subscribe to block state for non-DM channels", () => { + const ctrl = createChannelController(makeOpts()); + ctrl.mountChannel(42, "general", "text"); + expect(blocksSubscribers).toHaveLength(0); + }); + }); + describe("destroyChannel edge cases", () => { it("destroyChannel is safe to call when no channel is mounted", () => { const opts = makeOpts(); diff --git a/Client/tauri-client/tests/unit/dispatcher.test.ts b/Client/tauri-client/tests/unit/dispatcher.test.ts index d38e86d9..bb4b1723 100644 --- a/Client/tauri-client/tests/unit/dispatcher.test.ts +++ b/Client/tauri-client/tests/unit/dispatcher.test.ts @@ -11,6 +11,7 @@ import { import { membersStore } from "../../src/stores/members.store"; import { voiceStore } from "../../src/stores/voice.store"; import { dmStore } from "../../src/stores/dm.store"; +import { blocksStore } from "../../src/stores/blocks.store"; import { uiStore } from "../../src/stores/ui.store"; import type { WsClient, WsListener } from "../../src/lib/ws"; import type { ServerMessage } from "../../src/lib/types"; @@ -132,6 +133,7 @@ describe("WS Dispatcher", () => { listenOnly: false, })); dmStore.setState(() => ({ channels: [] })); + blocksStore.setState(() => ({ blockedByMe: new Set(), blockedByThem: new Set() })); uiStore.setState((prev) => ({ ...prev, transientError: null })); mock = createMockWs(); @@ -834,6 +836,68 @@ describe("WS Dispatcher", () => { expect(uiStore.getState().transientError).toBeNull(); }); + it("gates the DM composer (blockedByThem) when a DM send is refused with FORBIDDEN", () => { + dmStore.setState(() => ({ + channels: [ + { + channelId: 7, + recipient: { id: 5, username: "alice", avatar: "", status: "online" }, + lastMessageId: null, + lastMessage: "", + lastMessageAt: "", + unreadCount: 0, + }, + ], + })); + addOptimisticMessage({ + correlationId: "corr-dm", + channelId: 7, + user: { id: 1, username: "alex", avatar: null }, + content: "hi", + replyTo: null, + timestamp: "2026-03-15T10:00:00Z", + }); + + mock.dispatch("error", { code: "FORBIDDEN", message: "blocked" }, "corr-dm"); + + expect(blocksStore.getState().blockedByThem.has(5)).toBe(true); + // Still marks the row failed (existing behaviour preserved). + expect(getChannelMessages(7)[0]!.status).toBe("failed"); + }); + + it("does not gate on a FORBIDDEN send outside a DM channel", () => { + // channel 7 is not in dmStore → no block state is inferred. + addOptimisticMessage({ + correlationId: "corr-nondm", + channelId: 7, + user: { id: 1, username: "alex", avatar: null }, + content: "hi", + replyTo: null, + timestamp: "2026-03-15T10:00:00Z", + }); + + mock.dispatch("error", { code: "FORBIDDEN", message: "nope" }, "corr-nondm"); + + expect(blocksStore.getState().blockedByThem.size).toBe(0); + }); + + it("on ready clears being-blocked state and refreshes blocked-by-me via api", async () => { + cleanup(); // tear down the no-api dispatcher wired in beforeEach + const listBlocks = vi.fn().mockResolvedValue({ blocked_user_ids: [11, 22] }); + cleanup = wireDispatcher(mock.ws, { listBlocks }); + + // Pre-seed a stale being-blocked entry that a fresh ready must clear. + blocksStore.setState(() => ({ blockedByMe: new Set(), blockedByThem: new Set([5]) })); + + mock.dispatch("ready", { channels: [], members: [], voice_states: [], roles: [] }); + + expect(blocksStore.getState().blockedByThem.size).toBe(0); + expect(listBlocks).toHaveBeenCalled(); + await Promise.resolve(); + await Promise.resolve(); + expect([...blocksStore.getState().blockedByMe]).toEqual([11, 22]); + }); + it("wires a local transport send failure to mark the pending row failed", () => { uiStore.setState((prev) => ({ ...prev, transientError: null })); diff --git a/docs/architecture/ux/channels-members-dms.md b/docs/architecture/ux/channels-members-dms.md index 6236d001..a2f6e667 100644 --- a/docs/architecture/ux/channels-members-dms.md +++ b/docs/architecture/ux/channels-members-dms.md @@ -145,12 +145,22 @@ 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 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. +> **✅ Wired (composer gating).** DM block state now drives the same +> disabled-with-reason composer mode (see [messaging.md §2](messaging.md)) via +> `blocks.store`. `blockedByMe` is loaded authoritatively from `GET /blocks` on +> every `ready` (`dispatcher.ts`) → the explicit "You've blocked this user…" +> reason. `blockedByThem` is inferred from a refused DM send (`ErrBlocked` → +> `FORBIDDEN`, bidirectional) → the neutral "You can't message this user right +> now." reason, and is cleared on the next `ready` so a reconnect re-evaluates. +> `ChannelController` reads `dmComposerBlockReason(recipientId)` and subscribes to +> `blocks.store`, so an unblock (shrunken `GET /blocks`) re-enables the composer +> live. `blockedByMe` takes precedence when both directions apply. +> +> **Remaining gap.** There is no in-client **block button** yet (the block/unblock +> REST surface exists server-side; blocks made from the web panel or a prior +> session are honoured via `GET /blocks`). Adding the block affordance to the DM +> profile sidebar would call `PUT/DELETE /blocks/{userId}` and update `blocks.store` +> directly for an instant local un-gate. --- From ca91d28561e5edccb15092e5c9db68a8a9248800 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:19:42 +0200 Subject: [PATCH 2/2] feat(updater): surface download progress in the update banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Rust download callback was a no-op, so "Downloading update…" looked hung for large binaries (settings-and-admin.md §5). download_and_install_update now accumulates received bytes and emits an `update-progress` event ({ received, total }) to the webview. downloadAndInstallUpdate(serverUrl, onProgress) listens for it and UpdateNotifier renders a percentage when the total is known, falling back to bytes (MB) until Content-Length arrives. Rust change is minimal and CI-gated only (not built locally per policy). Adds TS tests for the formatter and the banner wiring. Co-Authored-By: Claude Fable 5 --- .../src-tauri/src/update_commands.rs | 28 ++++- .../src/components/UpdateNotifier.ts | 24 +++- Client/tauri-client/src/lib/updater.ts | 30 ++++- .../tests/unit/update-notifier.test.ts | 107 ++++++++++++++++++ docs/architecture/ux/settings-and-admin.md | 13 ++- 5 files changed, 184 insertions(+), 18 deletions(-) create mode 100644 Client/tauri-client/tests/unit/update-notifier.test.ts diff --git a/Client/tauri-client/src-tauri/src/update_commands.rs b/Client/tauri-client/src-tauri/src/update_commands.rs index 932dd325..5d7904d2 100644 --- a/Client/tauri-client/src-tauri/src/update_commands.rs +++ b/Client/tauri-client/src-tauri/src/update_commands.rs @@ -1,6 +1,6 @@ use std::sync::Arc; use serde::Serialize; -use tauri::AppHandle; +use tauri::{AppHandle, Emitter}; use tauri_plugin_updater::UpdaterExt; use crate::livekit_proxy::{cert_store_key, load_stored_fingerprint, PinnedVerifier}; @@ -12,6 +12,15 @@ pub struct UpdateCheckResult { pub body: Option, } +/// Download progress, emitted to the webview as `update-progress` so the banner +/// can show a percentage/bytes instead of looking hung. `total` is None until +/// the server sends a Content-Length. +#[derive(Clone, Serialize)] +struct DownloadProgress { + received: u64, + total: Option, +} + /// Extract the host (with port if non-443) from an https:// URL for cert store lookup. fn extract_host_for_cert_store(server_url: &str) -> Result { let parsed = url::Url::parse(server_url) @@ -174,9 +183,20 @@ pub async fn download_and_install_update( match update { Some(u) => { - u.download_and_install(|_chunk_len, _total| {}, || {}) - .await - .map_err(|e| format!("download/install failed: {e}"))?; + // Accumulate downloaded bytes and emit progress to the webview. + // A failed emit must never abort the install, hence `let _ =`. + let progress_app = app.clone(); + let mut received: u64 = 0; + u.download_and_install( + move |chunk_len, total| { + received += chunk_len as u64; + let _ = + progress_app.emit("update-progress", DownloadProgress { received, total }); + }, + || {}, + ) + .await + .map_err(|e| format!("download/install failed: {e}"))?; Ok(()) } None => Err("no update available".into()), diff --git a/Client/tauri-client/src/components/UpdateNotifier.ts b/Client/tauri-client/src/components/UpdateNotifier.ts index 0fad5058..85b667cf 100644 --- a/Client/tauri-client/src/components/UpdateNotifier.ts +++ b/Client/tauri-client/src/components/UpdateNotifier.ts @@ -4,6 +4,7 @@ import { createElement, appendChildren } from "@lib/dom"; import { createLogger } from "@lib/logger"; import { checkForUpdate, downloadAndInstallUpdate } from "@lib/updater"; +import type { DownloadProgress } from "@lib/updater"; import type { MountableComponent } from "@lib/safe-render"; const log = createLogger("update-notifier"); @@ -12,6 +13,19 @@ export interface UpdateNotifierOptions { readonly serverUrl: string; } +/** + * Human-readable download status. Shows a percentage when the total size is + * known, otherwise the bytes received so the banner never looks hung. + */ +export function formatDownloadProgress(p: DownloadProgress): string { + if (p.total !== null && p.total > 0) { + const pct = Math.min(100, Math.max(0, Math.round((p.received / p.total) * 100))); + return `Downloading update… ${pct}%`; + } + const mb = (p.received / (1024 * 1024)).toFixed(1); + return `Downloading update… ${mb} MB`; +} + export function createUpdateNotifier(options: UpdateNotifierOptions): MountableComponent { const { serverUrl } = options; let container: Element | null = null; @@ -66,15 +80,13 @@ export function createUpdateNotifier(options: UpdateNotifierOptions): MountableC // Replace banner content with progress indicator while (banner.firstChild) banner.removeChild(banner.firstChild); - const progress = createElement( - "span", - { class: "update-banner-text" }, - "Downloading update...", - ); + const progress = createElement("span", { class: "update-banner-text" }, "Downloading update…"); banner.appendChild(progress); try { - await downloadAndInstallUpdate(serverUrl); + await downloadAndInstallUpdate(serverUrl, (p) => { + progress.textContent = formatDownloadProgress(p); + }); // App will relaunch — this code won't execute after relaunch() } catch (err) { log.error("Update install failed", { error: String(err) }); diff --git a/Client/tauri-client/src/lib/updater.ts b/Client/tauri-client/src/lib/updater.ts index 17c46f64..8cc8a262 100644 --- a/Client/tauri-client/src/lib/updater.ts +++ b/Client/tauri-client/src/lib/updater.ts @@ -14,6 +14,12 @@ export interface UpdateCheckResult { readonly body: string | null; } +/** Download progress reported by the Rust updater during install. */ +export interface DownloadProgress { + readonly received: number; + readonly total: number | null; +} + /** Check if a newer client version is available on the connected server. */ export async function checkForUpdate(serverUrl: string): Promise { try { @@ -32,10 +38,28 @@ export async function checkForUpdate(serverUrl: string): Promise { +/** + * Download and install a pending update, then relaunch the app. + * `onProgress`, when given, is fed the Rust updater's `update-progress` events + * so the caller can show download progress instead of a hung spinner. + */ +export async function downloadAndInstallUpdate( + serverUrl: string, + onProgress?: (progress: DownloadProgress) => void, +): Promise { log.info("Downloading and installing update..."); - await invoke("download_and_install_update", { serverUrl }); + let unlisten: (() => void) | undefined; + if (onProgress !== undefined) { + const { listen } = await import("@tauri-apps/api/event"); + unlisten = await listen("update-progress", (event) => { + onProgress({ received: event.payload.received, total: event.payload.total ?? null }); + }); + } + try { + await invoke("download_and_install_update", { serverUrl }); + } finally { + unlisten?.(); + } log.info("Update installed, relaunching..."); await relaunch(); } diff --git a/Client/tauri-client/tests/unit/update-notifier.test.ts b/Client/tauri-client/tests/unit/update-notifier.test.ts new file mode 100644 index 00000000..52001351 --- /dev/null +++ b/Client/tauri-client/tests/unit/update-notifier.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const { mockCheckForUpdate, mockDownloadAndInstall } = vi.hoisted(() => ({ + mockCheckForUpdate: vi.fn(), + mockDownloadAndInstall: vi.fn(), +})); + +vi.mock("@lib/logger", () => ({ + createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }), +})); + +vi.mock("@lib/updater", () => ({ + checkForUpdate: mockCheckForUpdate, + downloadAndInstallUpdate: mockDownloadAndInstall, +})); + +import { createUpdateNotifier, formatDownloadProgress } from "../../src/components/UpdateNotifier"; +import type { DownloadProgress } from "../../src/lib/updater"; + +// --------------------------------------------------------------------------- +// Pure formatter +// --------------------------------------------------------------------------- + +describe("formatDownloadProgress", () => { + it("shows a percentage when the total size is known", () => { + expect(formatDownloadProgress({ received: 50, total: 100 })).toBe("Downloading update… 50%"); + }); + + it("clamps the percentage to 0..100", () => { + expect(formatDownloadProgress({ received: 250, total: 100 })).toBe("Downloading update… 100%"); + }); + + it("falls back to bytes (MB) when the total is unknown", () => { + expect(formatDownloadProgress({ received: 5 * 1024 * 1024, total: null })).toBe( + "Downloading update… 5.0 MB", + ); + }); +}); + +// --------------------------------------------------------------------------- +// Banner rendering +// --------------------------------------------------------------------------- + +describe("createUpdateNotifier download progress", () => { + let host: HTMLElement; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + host = document.createElement("div"); + document.body.appendChild(host); + }); + + afterEach(() => { + vi.useRealTimers(); + host.remove(); + }); + + async function mountWithAvailableUpdate(): Promise { + mockCheckForUpdate.mockResolvedValue({ available: true, version: "1.2.0", body: "" }); + const notifier = createUpdateNotifier({ serverUrl: "https://s.example" }); + notifier.mount(host); + await vi.advanceTimersByTimeAsync(3000); // fire the delayed check + resolve + } + + function bannerText(): string | null | undefined { + return host.querySelector(".update-banner-text")?.textContent; + } + + it("updates the banner from the updater progress callback", async () => { + let onProgress: ((p: DownloadProgress) => void) | undefined; + mockDownloadAndInstall.mockImplementation((_url: string, cb: (p: DownloadProgress) => void) => { + onProgress = cb; + return new Promise(() => {}); // never resolves — stays "downloading" + }); + + await mountWithAvailableUpdate(); + (host.querySelector(".update-banner-install") as HTMLButtonElement).click(); + + // Initial state before any progress event. + expect(bannerText()).toBe("Downloading update…"); + expect(mockDownloadAndInstall).toHaveBeenCalledWith("https://s.example", expect.any(Function)); + + onProgress!({ received: 25, total: 100 }); + expect(bannerText()).toBe("Downloading update… 25%"); + + onProgress!({ received: 2 * 1024 * 1024, total: null }); + expect(bannerText()).toBe("Downloading update… 2.0 MB"); + }); + + it("shows a failure message when the download rejects", async () => { + mockDownloadAndInstall.mockRejectedValue(new Error("boom")); + + await mountWithAvailableUpdate(); + (host.querySelector(".update-banner-install") as HTMLButtonElement).click(); + + // Let the rejected install promise settle and the catch run. + await Promise.resolve(); + await Promise.resolve(); + + expect(bannerText()).toBe("Update failed. Please try again later."); + }); +}); diff --git a/docs/architecture/ux/settings-and-admin.md b/docs/architecture/ux/settings-and-admin.md index 27d413ae..4d18cc1a 100644 --- a/docs/architecture/ux/settings-and-admin.md +++ b/docs/architecture/ux/settings-and-admin.md @@ -171,14 +171,17 @@ sequenceDiagram |-------|--------------| | checking | Silent (no UI until a result) | | available | Non-modal banner with version + Update Now / Later (already `UpdateNotifier.ts:30-62`) | -| downloading | Banner "Downloading update…" | +| downloading | Banner "Downloading update… N%" (or "… N.N MB" until Content-Length is known) | | applied | App relaunches automatically | | failed | "Update failed. Please try again later." + Dismiss | -> **⚠ Current gap — no download progress.** The download callback is a no-op -> (`update_commands.rs:177`), so "Downloading update…" has no percentage. For a -> large binary this looks hung. Target: surface a progress indicator (percentage -> or indeterminate-with-bytes) by wiring the plugin's progress callback. +> **✅ Wired — download progress.** The Rust download callback +> (`download_and_install_update` in `update_commands.rs`) accumulates received +> bytes and emits an `update-progress` event (`{ received, total }`) to the +> webview. `downloadAndInstallUpdate(serverUrl, onProgress)` (`updater.ts`) listens +> for it and forwards to `UpdateNotifier`, whose `formatDownloadProgress` renders a +> percentage when `total` is known and falls back to bytes (MB) otherwise, so the +> banner never looks hung. (Rust change is minimal and CI-gated only.) ---