Info:
This is release candidate documentation for Stream Chat React SDK v15. For the latest stable version, see the latest version (v14).

Flagging, muting, blocking and bans

Four tools keep conversations safe, and they differ in who uses them and whom they affect:

  • Flagging reports a message or a user to the app's moderators.
  • Muting silences a user, or a channel, for the person who mutes.
  • Blocking cuts off a user's direct messages to the person who blocks.
  • Banning stops a user from posting, in one channel or in the whole app. Moderators ban; a shadow ban hides the user's messages without telling them.

This page covers what users see of each one, what it calls in the state layer, and how to change the actions.

What users get

The user opens the menu on a message from Bob. Besides the other message actions they see:

  • Flag, which reports the message and confirms with "Message has been successfully flagged";
  • Mute, which mutes Bob and confirms with "Bob has been muted". Once Bob is muted, the action reads Unmute;
  • Block User, which blocks Bob. Once he's blocked, it reads Unblock.

The same tools appear in other places:

  • Channel list rows have a mute button for the channel. In a direct message channel the row menu has Block User, which bans the other member from that channel. See Channel lists.
  • Channel details have a Mute chat switch. In a direct message channel they also have a switch to mute the other person and a Block user button. A member's profile has Mute user and Block user. See Channel details and members.

Two things show in the message list itself. When moderation removes a message, its author sees "Message was blocked by moderation policies" in its place. A message marked as shadowed, the mark the server puts on a shadow-banned user's messages, shows the same text. A message that moderation bounces back to its author offers to edit it, delete it or send it anyway; see Sending, editing and deleting.

How it works across the layers

State: what each tool calls

Every tool is a call to the server. None of them changes local state before the server answers: the state that follows each one changes with the response or with the server's event.

Tool What it calls What changes on the client
Flag a message client.moderation.flagMessage({ message_id, reason?, ...options }) Nothing. The flag goes to the moderation queue.
Flag a user client.moderation.flagUser({ user_id, reason?, ...options }) Nothing
Mute a user client.moderation.mute({ target_ids, timeout? }), client.moderation.unmute({ target_ids }) client.mutedUsersStore, when notification.mutes_updated arrives
Mute a channel Channel.mute(), Channel.unmute() channel.state.muteStatus, when notification.channel_mutes_updated arrives
Block a user client.blockUser(userId), client.unblockUser(userId) client.blockedUsers, when the response arrives
Ban in a channel Channel.banUser(userId, options), Channel.unbanUser(userId) The member's banned or shadow_banned in channel.state.members, when user.banned arrives
Ban in the whole app client.moderation.ban({ target_user_id, ... }), client.unbanUser(userId) No client state of its own

The sections below explain each tool.

Flagging

A flag sends a message or a user to the moderation queue, where moderators review it in the Stream Dashboard. The reporting user's view doesn't change. Pass a reason to help moderators sort the queue:

await client.moderation.flagMessage({ message_id: message.id, reason: "spam" });
await client.moderation.flagUser({ user_id: "bob", reason: "harassment" });

The Flag action calls client.moderation.flagMessage() with no reason. Nothing in the SDK flags a user; call client.moderation.flagUser() from your own UI.

Muting users

A muted user's messages still arrive and still show in the message list: the SDK doesn't hide them. They don't count as unread, and the server sends no push notifications for them. timeout mutes for that many minutes:

// Mute Bob for one day.
await client.moderation.mute({ target_ids: ["bob"], timeout: 24 * 60 });

await client.moderation.unmute({ target_ids: ["bob"] });

The user's mute list is client.mutedUsersStore, a store whose mutedUsers holds one entry per muted user, with the user as target. client.userMuteStatus(userId) reads it once. To follow it in a component, subscribe to the store:

import type { UserMuteResponse } from "stream-chat";
import { useChatContext, useStateStore } from "stream-chat-react";

const mutedUsersSelector = ({
  mutedUsers,
}: {
  mutedUsers: UserMuteResponse[];
}) => ({ mutedUsers });

export const MutedUsersList = () => {
  const { client } = useChatContext();
  const { mutedUsers } = useStateStore(
    client.mutedUsersStore,
    mutedUsersSelector,
  );

  return (
    <ul>
      {mutedUsers.map(({ target }) => (
        <li key={target?.id}>
          {target?.name ?? target?.id}{" "}
          <button
            onClick={() =>
              target && client.moderation.unmute({ target_ids: [target.id] })
            }
            type="button"
          >
            Unmute
          </button>
        </li>
      ))}
    </ul>
  );
};

Muting channels

A muted channel's new messages don't count as unread, and its row in the channel list shows a mute icon and no unread badge. Channel.mute({ expiration }) mutes for that many milliseconds. What channels and threads hold explains muteStatus.

Blocking

Blocking is personal: it changes what the blocking user gets, not what anyone else sees. The server hides the direct message channels between the two users from the one who blocked, and stops events and push notifications from the blocked user in them. Group channels aren't affected, and the blocked user's messages there still show. Block in the Chat API docs lists every effect.

await client.blockUser("bob");
await client.unblockUser("bob");

// The blocked users, with each user's details.
const { blocks } = await client.getBlockedUsers();
console.log(blocks.map(({ blocked_user }) => blocked_user.name));

The ids of the blocked users are in the client.blockedUsers store, as userIds. The client fills it when it connects, and client.blockUser(), client.unblockUser() and client.getBlockedUsers() update it after the server answers.

Bans and shadow bans

A ban stops a user from posting. Channel.banUser(userId, options) bans them from one channel, and needs the ban-channel-members capability. Its options are optional fields, so pass {} for none:

  • reason, an explanation for the ban;
  • timeout, the ban's length in minutes;
  • shadow: true, for a shadow ban.
// Ban Bob from this channel for an hour.
await channel.banUser("bob", { reason: "Spam", timeout: 60 });

// Shadow ban: Bob can still post, and isn't told about the ban.
await channel.banUser("bob", { shadow: true });

await channel.unbanUser("bob");

client.moderation.ban({ target_user_id }) bans a user from the whole app, and client.unbanUser(userId) lifts the ban. Apps usually ban app-wide from the server. Ban in the Moderation docs covers bans, shadow bans and their options.

The SDK doesn't check bans itself: the server rejects a banned user's messages, and the message list marks them as failed. When the client receives a message marked shadowed, it renders the "blocked by moderation" text in its place, and the message doesn't count as unread.

UI: the actions and the blocked message

The message actions come from defaultMessageActionSet, which MessageActions renders. Each action reads the message from context and calls the state layer:

Action type What it calls Shown when
Flag flag client.moderation.flagMessage({ message_id: message.id }) Not the user's own message, and flag-message
Mute, Unmute mute client.moderation.mute() or client.moderation.unmute() for the author Not the user's own message, and mute-channel
Block User, Unblock blockUser client.blockUser() or client.unblockUser() for the author Not the user's own message

Flag and Mute report their result through client.notifications; Block User reports nothing. Message actions lists every message action.

The channel list rows and channel details have their own actions. They call the same methods, except the channel list's Block User, which calls Channel.banUser(). Their tables are on Channel lists and Channel details and members.

MessageUI renders MessageBlocked instead of the message when the message is marked shadowed, or when it's an error message that moderation removed. The default shows "Message was blocked by moderation policies".

Channel types can also offer the /ban, /unban, /mute and /unmute commands in the composer; see Commands and Giphy.

Turning it on or off

These tools are always available. Who sees each action depends on the user's capabilities in the channel, which the server derives from their roles and the channel type's permissions:

  • Flag needs flag-message.
  • Mute needs mute-channel in the message menu, and for channels. Muting a user from channel details checks no capability.
  • Ban needs ban-channel-members. In channel details, Block user in a direct message channel needs it too.
  • Block User in the message menu and in a member's profile needs no capability, because blocking is personal.

The channel type's settings and the moderation policies on the server decide the rest, such as which messages moderation removes. See Moderation for Chat and Permissions reference.

To remove an action from the message menu, filter it out of the action set:

import type { PropsWithChildren } from "react";
import {
  MessageActions,
  WithComponents,
  defaultMessageActionSet,
} from "stream-chat-react";

// Keeps the "more" button, which has no type, and drops Block User.
const messageActionSet = defaultMessageActionSet.filter(
  (action) =>
    action.placement === "quick-dropdown-toggle" || action.type !== "blockUser",
);

const CustomMessageActions = () => (
  <MessageActions messageActionSet={messageActionSet} />
);

const overrides = { MessageActions: CustomMessageActions };

export const WithoutBlockAction = ({ children }: PropsWithChildren) => (
  <WithComponents overrides={overrides}>{children}</WithComponents>
);

Customizing

Flagging with a reason

This menu replaces Flag with Report spam, which flags the message with a reason. The action keeps the flag type, so the base filter still shows it only to users with flag-message:

import type { PropsWithChildren } from "react";
import {
  ContextMenuButton,
  MessageActions,
  WithComponents,
  defaultMessageActionSet,
  useChatContext,
  useContextMenuContext,
  useMessageContext,
} from "stream-chat-react";
import type { MessageActionSetItem } from "stream-chat-react";

const ReportSpam = () => {
  const { client } = useChatContext();
  const { message } = useMessageContext();
  const { closeMenu } = useContextMenuContext();

  const report = async () => {
    closeMenu();
    try {
      await client.moderation.flagMessage({
        message_id: message.id,
        reason: "spam",
      });
      client.notifications.addSuccess({
        message: "Reported as spam",
        origin: { emitter: "ReportSpam" },
      });
    } catch {
      client.notifications.addError({
        message: "Couldn't report the message",
        origin: { emitter: "ReportSpam" },
      });
    }
  };

  return (
    <ContextMenuButton onClick={() => void report()}>
      Report spam
    </ContextMenuButton>
  );
};

const messageActionSet: MessageActionSetItem[] = defaultMessageActionSet.map(
  (item) =>
    item.placement === "dropdown" && item.type === "flag"
      ? { ...item, Component: ReportSpam }
      : item,
);

const CustomMessageActions = () => (
  <MessageActions messageActionSet={messageActionSet} />
);

const overrides = { MessageActions: CustomMessageActions };

export const WithReportSpam = ({ children }: PropsWithChildren) => (
  <WithComponents overrides={overrides}>{children}</WithComponents>
);

Actions explains action sets, including the ones in the channel list and in channel details.

The blocked message

Replace MessageBlocked through WithComponents. It renders inside the message, so it reads the message with useMessageContext():

import type { PropsWithChildren } from "react";
import { WithComponents, useMessageContext } from "stream-chat-react";

const HiddenMessage = () => {
  const { message } = useMessageContext();

  return (
    <div className="str-chat__message str-chat__message--blocked">
      {message.shadowed
        ? "This message is hidden."
        : "This message was removed."}
    </div>
  );
};

const overrides = { MessageBlocked: HiddenMessage };

export const WithHiddenMessages = ({ children }: PropsWithChildren) => (
  <WithComponents overrides={overrides}>{children}</WithComponents>
);

Showing who is blocked

client.blockedUsers is a store, so a component that subscribes to it updates when the user blocks or unblocks someone:

import type { BlockedUsersState } from "stream-chat";
import { useChatContext, useStateStore } from "stream-chat-react";

const blockedSelector = ({ userIds }: BlockedUsersState) => ({ userIds });

export const BlockedBadge = ({ userId }: { userId: string }) => {
  const { client } = useChatContext();
  const { userIds } = useStateStore(client.blockedUsers, blockedSelector);

  return userIds.includes(userId) ? (
    <span className="badge">Blocked</span>
  ) : null;
};

Reference