Commands

Composer Commands Overview

The SwiftUI SDK has support for several types of commands in the composer. For example, when a user types "@" in the input field, a list of users that can be mentioned will be displayed. Additionally, the composer supports instant commands ("/"), similar to Slack. You can share a giphy, by typing the "/giphy" command, or mute/unmute users. All the symbols and text for the commands are configurable, so you can use different displaying information for them. The SDK allows you to create your own commands, and define their rules and handling. One such use-case can be creating a "/pay" command, to send money to a user in the chat.

Enhanced Mentions

Starting from version 5.6.0, the SDK supports enhanced mentions. In addition to the standard user mentions (@user), you can enable four additional mention types:

Mention typeTokenNotifies
user@<name>A single user
channel@channelEveryone in the channel
here@hereMembers currently online
role@<role>Users with that role in the channel
user_group@<group>Members of a named user group

Enabling Enhanced Mentions

To enable enhanced mentions, use the EnhancedMentionsCommandsConfig instead of the default DefaultCommandsConfig:

let utils = Utils(commandsConfig: EnhancedMentionsCommandsConfig())
let streamChat = StreamChat(chatClient: chatClient, utils: utils)

Required Channel Capabilities

The broadcast (@channel, @here), role, and user-group variants are gated by channel capabilities, so they only appear in the suggestions list when the matching permission is granted to the sender's role.

Mention typeChannel capabilityDashboard permission
channelnotify-channelNotifyChannel
herenotify-hereNotifyHere
rolenotify-roleNotifyRole
user_groupnotify-groupNotifyGroup

Grant these permissions in the Stream Dashboard under Chat Messaging → Roles & Permissions: pick the role (for example channel_member) and the channel-type scope (for example messaging), then enable the relevant permissions. They surface on the channel as own_capabilities, and the composer checks them at query time, so suggestions update if capabilities change. Until a capability is present, that mention type is filtered out of the suggestion list.

Two additional requirements:

  • Role mentions resolve against the roles defined under the same Roles & Permissions dashboard page.
  • User-group mentions require user groups to exist in your app. Define and manage them as described in the User Groups documentation.

Custom Mention Suggestions Provider

The EnhancedMentionSuggestionsProvider is the default provider used by EnhancedMentionsCommandsConfig. If you need more control over how mention suggestions are fetched, you can create your own provider by conforming to the MentionSuggestionsProvider protocol:

public protocol MentionSuggestionsProvider: Sendable {
    func mentionSuggestions(
        for request: MentionSuggestionsRequest
    ) async throws -> [MentionSuggestion]
}

The MentionSuggestionsRequest provides the text typed after the @ symbol and the channel context. Each suggestion is represented by a MentionSuggestion, which wraps a Kind value. The built-in kinds are User, Here, Channel, Role, and Group.

Here is an example of a custom provider that filters out certain roles:

final class FilteredMentionSuggestionsProvider: MentionSuggestionsProvider {
    private let base: EnhancedMentionSuggestionsProvider

    init(client: ChatClient) {
        base = EnhancedMentionSuggestionsProvider(client: client)
    }

    func mentionSuggestions(
        for request: MentionSuggestionsRequest
    ) async throws -> [MentionSuggestion] {
        let suggestions = try await base.mentionSuggestions(for: request)
        return suggestions.filter { suggestion in
            if let role = suggestion.kind as? MentionSuggestion.Role {
                return role.role.name != "admin"
            }
            return true
        }
    }
}

Then, wire it into your own CommandsConfig:

public class CustomMentionsConfig: CommandsConfig {
    public init() {}

    public let mentionsSymbol: String = "@"
    public let instantCommandsSymbol: String = "/"

    public func makeCommandsHandler(
        with channelController: ChatChannelController
    ) -> CommandsHandler {
        let provider = FilteredMentionSuggestionsProvider(
            client: channelController.client
        )
        let mentionsCommand = MentionsCommandHandler(
            channelController: channelController,
            commandSymbol: mentionsSymbol,
            provider: provider
        )
        return DefaultCommandsConfig.makeCommandsHandler(
            mentionsCommandHandler: mentionsCommand,
            channelController: channelController
        )
    }
}

Modifying the Supported Commands

Before creating your own custom commands, let's see how you can modify the supported ones, to fit your needs. First, you can change the order of the commands, as well as remove the ones you don't want to support. You can also change the invoking symbols of the commands. In order to accomplish this, you will need to implement your own CommandsConfig and inject it in the Utils class inside the StreamChat object.

public class CustomCommandsConfig: CommandsConfig {

    public init() {}

    // Change these properties for different symbols for the commands.
    public let mentionsSymbol: String = "@"
    public let instantCommandsSymbol: String = "/"

    @MainActor public func makeCommandsHandler(
        with channelController: ChatChannelController
    ) -> CommandsHandler {
    	// Modify the configuration of the commands here:
        let mentionsCommand = MentionsCommandHandler(
            channelController: channelController,
            commandSymbol: mentionsSymbol,
            mentionAllAppUsers: false
        )
        let giphyCommand = GiphyCommandHandler(commandSymbol: "/giphy")
        let muteCommand = MuteCommandHandler(
            channelController: channelController,
            commandSymbol: "/mute"
        )
        let unmuteCommand = UnmuteCommandHandler(
            channelController: channelController,
            commandSymbol: "/unmute"
        )

        // Add or remove commands here, or change the order.
        let instantCommands = InstantCommandsHandler(
            commands: [giphyCommand, muteCommand, unmuteCommand]
        )
        return CommandsHandler(commands: [mentionsCommand, instantCommands])
    }
}

In the code above, you can modify the command symbols by changing the mentionsSymbol and instantCommandsSymbol properties accordingly. In the makeCommandsHandler method, you can change the order of the commands, or add or remove commands.

After you have created the CustomCommandsConfig, you need pass it to the StreamChat object in the setup step (for example in your AppDelegate):

let utils = Utils(commandsConfig: CustomCommandsConfig())
let streamChat = StreamChat(chatClient: chatClient, utils: utils)

Creating a Custom Command

In order to create a custom command, you need to create your own class implementing the CommandHandler protocol. After you create your own implementation, you will need to inject it in your own CustomCommandsConfig, as described in the above example.

The methods defined by the CommandHandler are the following:

@MainActor public protocol CommandHandler: Sendable {

    /// Identifier of the command.
    var id: String { get }

    /// Display info for the command.
    var displayInfo: CommandDisplayInfo? { get }

    /// Whether execution of the command replaces sending of a message.
    var replacesMessageSent: Bool { get }

    /// Checks whether the command can be handled.
    /// - Parameters:
    ///  - text: the user entered text.
    ///  - caretLocation: the end location of a selected text range.
    /// - Returns: optional `ComposerCommand` (if the handler can handle the command).
    func canHandleCommand(
        in text: String,
        caretLocation: Int
    ) -> ComposerCommand?

    /// Returns a command handler for a command (if available).
    /// - Parameter command: the command whose handler will be returned.
    /// - Returns: Optional `CommandHandler`.
    func commandHandler(for command: ComposerCommand) -> CommandHandler?

    /// Shows suggestions for the provided command.
    /// - Parameter command: the command whose suggestions will be shown.
    /// - Returns: `Future` with the suggestions, or an error.
    func showSuggestions(
        for command: ComposerCommand
    ) -> Future<SuggestionInfo, Error>

    /// Handles the provided command.
    /// - Parameters:
    ///  - text: the user entered text.
    ///  - selectedRangeLocation: the end location of the selected text.
    ///  - command: binding of the command.
    ///  - extraData: additional data that can be passed from the command.
    func handleCommand(
        for text: Binding<String>,
        selectedRangeLocation: Binding<Int>,
        command: Binding<ComposerCommand?>,
        extraData: [String: Any]
    )

    /// Checks whether the command can be executed on message sent.
    /// - Parameter command: the command to be checked.
    /// - Returns: `Bool` whether the command can be executed.
    func canBeExecuted(composerCommand: ComposerCommand) -> Bool

    /// Needs to be implemented if you need some code executed before the message is sent.
    /// - Parameters:
    ///  - composerCommand: the command to be executed.
    ///  - completion: called when the command is executed.
    func executeOnMessageSent(
        composerCommand: ComposerCommand,
        completion: @escaping @MainActor (Error?) -> Void
    )
}

The protocol provides default implementations for replacesMessageSent (returns false), canBeExecuted (returns true when the command's typing suggestion is non-empty), and executeOnMessageSent (empty — no-op by default).

You can implement these methods to have the most customized command handling behavior. However, in most cases you will need support for a two-step command process, where in the first one, you will pick the instant command and in the second step, you will mention a user, that will be affected by your command. These can be actions both supported by the SDK (muting, banning, flagging, etc), or your own custom actions.

In order to re-use the two-step command process from the SDK, you will need to subclass the TwoStepMentionCommand (it is an open class). For example, let's see how the mute action can be implemented by subclassing the TwoStepMentionCommand.

public class MuteCommandHandler: TwoStepMentionCommand {

    @Injected(\.images) private var images
    @Injected(\.chatClient) private var chatClient

    public init(
        channelController: ChatChannelController,
        commandSymbol: String,
        id: String = "/mute"
    ) {
        super.init(
            channelController: channelController,
            commandSymbol: commandSymbol,
            id: id
        )
        let displayInfo = CommandDisplayInfo(
            displayName: L10n.Composer.Commands.mute,
            icon: images.commandMuteIcon,
            format: "\(id) [\(L10n.Composer.Commands.Format.username)]",
            isInstant: true
        )
        self.displayInfo = displayInfo
    }

    override public func executeOnMessageSent(
        composerCommand: ComposerCommand,
        completion: @escaping @MainActor (Error?) -> Void
    ) {
        if let mutedUser = selectedUser {
            chatClient
                .userController(userId: mutedUser.id)
                .mute { [weak self] error in
                    self?.selectedUser = nil
                    completion(error)
                }

            return
        }
    }
}

In the init method, we setup display info of the command. If this is not specified, the command will not appear in the instant commands suggestions popup above the composer.

CommandDisplayInfo accepts two additional optional parameters — placeholder (shown in the input while the command is active) and description (a subtitle shown in the suggestions list):

let displayInfo = CommandDisplayInfo(
    displayName: "Pay",
    icon: UIImage(systemName: "creditcard")!,
    format: "/pay [@username]",
    isInstant: true,
    placeholder: "Enter amount",
    description: "Send a payment to a user"
)

Additionally, we only need to override the executeOnMessageSent method, which is called when all the data is selected and the user is allowed to execute the command. In this method, we can make use of the selectedUser variable, which gives us information about the mentioned user in the command. In the example, we are muting the user. You can execute your own code here, for example sending a payment to the user, or anything else that fits your app's use-cases. You only need to call the completion handler when you are done with the action.

By default, TwoStepMentionCommand sets replacesMessageSent to true, which means no message is posted to the channel when the command runs. If you want the message to be sent in addition to executing the command, override replacesMessageSent to return false:

override public var replacesMessageSent: Bool {
    return false
}

Custom Suggestions View

The SDK comes with a default container view, displayed above the composer and over the message list, that shows mention suggestions and instant command suggestions (CommandSuggestionsView). You can replace this view to adjust the user interface or to support different types of suggestions for your custom commands.

Which mention suggestion view is used depends on whether enhanced mentions are enabled:

  • Enhanced mentions enabled (EnhancedMentionsCommandsConfig) — the "mentions" key holds [MentionSuggestion], and the SDK renders MentionSuggestionsView. Each row is a MentionSuggestionView that displays a different cell layout depending on the suggestion's kind (user, @here, @channel, role, or group).
  • Default configuration (DefaultCommandsConfig) — the "mentions" key holds [ChatUser], and the SDK renders UserSuggestionsView with a simple user avatar and name row.

Custom mention suggestion row

When enhanced mentions are enabled, you can customize each suggestion row by providing a custom MentionSuggestionView. Since each row receives a MentionSuggestion, you can switch on its kind to render a completely different cell for each mention type:

struct CustomMentionSuggestionView: View {
    var suggestion: MentionSuggestion
    var suggestionSelected: (MentionSuggestion) -> Void

    var body: some View {
        Button {
            suggestionSelected(suggestion)
        } label: {
            HStack {
                switch suggestion.kind {
                case let userSuggestion as MentionSuggestion.User:
                    Image(systemName: "person.fill")
                    Text(userSuggestion.user.name ?? userSuggestion.user.id)
                case is MentionSuggestion.Here:
                    Image(systemName: "eye.fill")
                    Text("@here - Notify online members")
                case is MentionSuggestion.Channel:
                    Image(systemName: "megaphone.fill")
                    Text("@channel - Notify everyone")
                case let roleSuggestion as MentionSuggestion.Role:
                    Image(systemName: "shield.fill")
                    Text("@\(roleSuggestion.role.name)")
                case let groupSuggestion as MentionSuggestion.Group:
                    Image(systemName: "person.3.fill")
                    Text("@\(groupSuggestion.group.name)")
                default:
                    EmptyView()
                }
                Spacer()
            }
            .padding(.horizontal)
            .padding(.vertical, 8)
        }
    }
}

The default case returns an EmptyView(). This is intentional — if new mention kinds are introduced in a future SDK version, they will fall into the default case and simply not render until you add explicit handling for them.

Replacing the entire suggestions container

To fully replace the suggestions container, implement makeSuggestionsContainerView in your ViewFactory. This is useful when you want to combine mention suggestions with custom command suggestions — for example, showing a pay button alongside the /pay command created in the previous section.

class CustomViewFactory: ViewFactory {

    @Injected(\.chatClient) public var chatClient

    public var styles = RegularStyles()

    public func makeSuggestionsContainerView(
        options: SuggestionsContainerViewOptions
    ) -> some View {
        CustomSuggestionsContainerView(
            suggestions: options.suggestions,
            handleCommand: options.handleCommand
        )
    }
}

The SuggestionsContainerViewOptions provides:

  • suggestions – a [String: Any] dictionary containing the suggestions for the current command. When enhanced mentions are enabled, the key "mentions" maps to [MentionSuggestion]. The key "instantCommands" maps to [CommandHandler] for slash command suggestions.
  • handleCommand – a callback that must be called when the user selects a suggestion. Pass a dictionary with "mentionSuggestion" (for mention suggestions) or "instantCommand" (for instant commands) as the key.

Here is an example custom suggestions container that renders mention suggestions with the CustomMentionSuggestionView defined above and adds a custom view for the /pay command:

struct CustomSuggestionsContainerView: View {

    var suggestions: [String: Any]
    var handleCommand: ([String: Any]) -> Void

    var body: some View {
        ZStack {
            if let mentionSuggestions = suggestions["mentions"] as? [MentionSuggestion] {
                ScrollView {
                    LazyVStack(spacing: 0) {
                        ForEach(mentionSuggestions, id: \.id) { suggestion in
                            CustomMentionSuggestionView(
                                suggestion: suggestion,
                                suggestionSelected: { selected in
                                    handleCommand(["mentionSuggestion": selected])
                                }
                            )
                        }
                    }
                }
            }

            if let instantCommands = suggestions["instantCommands"] as? [CommandHandler] {
                VStack {
                    ForEach(instantCommands.indices, id: \.self) { index in
                        let command = instantCommands[index]
                        Button {
                            handleCommand(["instantCommand": command])
                        } label: {
                            HStack {
                                Image(uiImage: command.displayInfo?.icon ?? UIImage())
                                Text(command.displayInfo?.displayName ?? command.id)
                                    .bold()

                                if command.id == "/pay" {
                                    Spacer()
                                    Image(systemName: "creditcard.fill")
                                        .foregroundColor(.green)
                                }

                                Spacer()
                            }
                            .padding(.horizontal)
                            .padding(.vertical, 8)
                        }
                    }
                }
            }
        }
    }
}

Finally, inject the CustomViewFactory in your view hierarchy.

var body: some Scene {
    WindowGroup {
        ChatChannelListView(viewFactory: CustomViewFactory.shared)
    }
}