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. ---