mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user