feat(dm): gate DM composer on block state with spec reasons

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 <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-07-20 10:19:31 +02:00
co-authored by Claude Fable 5
parent 5de92b9510
commit a546af2d4f
11 changed files with 369 additions and 14 deletions
+6
View File
@@ -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<void>("DELETE", `/dms/${channelId}`, undefined, signal);
},
/** List recipient user IDs the current user has blocked. */
listBlocks(signal?: AbortSignal): Promise<BlockedUsersResponse> {
return request<BlockedUsersResponse>("GET", "/blocks", undefined, signal);
},
// ── Voice ─────────────────────────────────────────────
getVoiceCredentials(signal?: AbortSignal): Promise<VoiceCredentialsResponse> {
+33 -1
View File
@@ -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<WsClient, "onStateChange">): () =>
/**
* 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<ApiClient, "listBlocks">,
): 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;
}
+5
View File
@@ -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;
+1 -1
View File
@@ -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.
@@ -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(
@@ -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) => {
@@ -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<number>;
readonly blockedByThem: ReadonlySet<number>;
}
const INITIAL: BlocksState = {
blockedByMe: new Set(),
blockedByThem: new Set(),
};
export const blocksStore = createStore<BlocksState>(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;
}
@@ -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);
});
});
});
@@ -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<number>(), 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();
@@ -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 }));
+16 -6
View File
@@ -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.
---