Skip to content

feat: advanced commands flow and composer effects#1723

Merged
isekovanic merged 24 commits intomasterfrom
fix/commands-flow
Apr 30, 2026
Merged

feat: advanced commands flow and composer effects#1723
isekovanic merged 24 commits intomasterfrom
fix/commands-flow

Conversation

@isekovanic
Copy link
Copy Markdown
Contributor

@isekovanic isekovanic commented Apr 27, 2026

CLA

  • I have signed the Stream CLA (required).
  • Code changes are tested

Description of the changes, What, Why and How?

This PR refactors command activation and disabled command handling so command availability is evaluated from live MessageComposer state instead of being stored on command search results.

Command disabled state depends on mutable composer state:

  • whether the composer in in an editing state
  • whether the composer is has a reply
  • which command is being evaluated

Initially, I had implemented this with a disabled and disabledReason state extension for the search source related to commands. However, this makes it super awkward for the UI SDKs as search results are not reactive (and we can't rely on suggestions for things like the commands picker content). Because of that, disabled/disabledReason have been moved away from CommandSearchSource results (keeping it purely CRUD). Since those results can be reused by different UI surfaces and they may become stale if composer state changes after the query runs, we rely purely on runtime logic for this.

Instead, we expose 2 new APIs:

  • messageComposer.getCommandDisabledReason(command)
  • messageComposer.isCommandDisabled(command)

We check whether a command is disabled as early in the response as possible so that we prevent stale command objects from activating after composer state changes.

Additionally, setQuotedMessage(...) and setEditedMessage(...) clear the currently active command if the new composer state makes it invalid. This prevents the composer from remaining in a disallowed active command state after reply/edit state changes.

Effects

For the purpose of achieving all of the above, we introduce effects as a concept to middleware execution. Effects are essentially deferred side-effects that need to happen in multiple different stores but triggered from one. For example, setting a command (i.e textComposer) needs to take care of clearing text, mentions and attachments - parts of the state which belong to textComposer.state and attachmentManager.state. The easy solution here would be to force textComposer to also mutate attachmentManager (which we can do), however that's inherently wrong as even though we have access to composer we should not introduce such tight coupling between the two.

Command effects remain scoped to activation side effects only:

  • snapshot current text/mentions/attachments
  • clear command entry state
  • restore previous state when clearing the command

They do not control command availability. Removing command activation effects still opts out of snapshot/clear behavior, but disabled-command guards are evaluated separately at runtime.

As a note, if in the future we want to introduce effects that are potentially concurrently unsafe - the proper way to solve this would be to introduce an effect registry that would then make sure that these are executed sequentially (or at the very least topologically, if it makes sense for the effects). However for this iteration it seemed like a bit of an overkill to do that and so I decided to keep it simple.

The definition of effects happens strictly within middlewares. Their execution however, is tied to messageComposer explicitly. Customizing effects has many approaches. As an example:

messageComposer.textComposer.middlewareExecutor.insert({
    middleware: {
      id: 'custom/remove-command-activation-effect',
      handlers: {
        onChange: ({ state, next }) => {
          if (!state.effects?.length) return next(state);

          return next({
            ...state,
            effects: state.effects.filter(
              (effect) => effect.type !== 'command.activate',
            ),
          });
        },
        onSuggestionItemSelect: ({ state, next }) => {
          if (!state.effects?.length) return next(state);

          return next({
            ...state,
            effects: state.effects.filter(
              (effect) => effect.type !== 'command.activate',
            ),
          });
        },
      },
    },
    position: { after: 'stream-io/text-composer/commands-middleware' },
  });

would remove all effects before they even reach execution.

The following:

messageComposer.registerEffectHandler<TextComposerCommandActivationEffect>(
    'command.activate',
    (effect, composer) => {
      // Keep Stream's command state update.
      composer.textComposer.state.partialNext({
        command: effect.command,
        suggestions: undefined,
      });

      // Custom behavior: do not clear attachments or mentions.
      composer.textComposer.state.partialNext({
        text: '',
        selection: { start: 0, end: 0 },
      });
    },
  );

messageComposer.registerEffectHandler<CustomComposerEffect>(
    'custom.effect',
    (effect, composer) => {
      console.log(effect.payload, composer.id);
    },
  );

overrides the default handling for command.activate and adds a custom.effect that does something arbitrary.

Changelog

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 27, 2026

Size Change: +5.63 kB (+1.5%)

Total Size: 381 kB

📦 View Changed
Filename Size Change
dist/cjs/index.browser.js 127 kB +1.88 kB (+1.51%)
dist/cjs/index.node.js 128 kB +1.87 kB (+1.49%)
dist/esm/index.mjs 126 kB +1.88 kB (+1.51%)

compressed-size-action

Comment thread src/messageComposer/messageComposer.ts Outdated
Comment thread src/messageComposer/messageComposer.ts Outdated
@MartinCupela
Copy link
Copy Markdown
Contributor

My proposal is not to introduce a notion of effects as they seem an overkill at the moment. Signaling that a command was activated can be seen from the TextComposer's state - it already has command key. I think the following would suffice:

Command activation

  • MessageComposer.activateCommand(command) — takes a snapshot, then clears child state for command entry
  • MessageComposer.deactivateCommand() — restores from snapshot, clears the active command

Snapshot API

  • Each manager implements takeSnapshot(): T and restoreSnapshot(snapshot: T): void, owning what's snapshot-worthy in its own state
  • MessageComposer.takeSnapshot() iterates managers, stores their snapshots
  • MessageComposer.restoreSnapshot() iterates and restores, then clears
  • MessageComposer.clearManagerStates() - iterates all the managers. Each manager already implements clear() knowing what "empty" means for its own state.

By each manager implementing specific API, we follow already established pattern of the methods of the same name across the managers.

And so MessageComposer.activateCommand can be as follows:

activateCommand = (command: CommandResponse) => {
  if (this.isCommandDisabled(command)) return;
  this.takeSnapshot();
  this.clearManagerStates();
  this.textComposer.setCommand(command); // already exists
};
deactivateCommand = () => {
  this.textComposer.clearCommand(); // already exists
  this.restoreSnapshot();
};

@isekovanic
Copy link
Copy Markdown
Contributor Author

As some general info, @MartinCupela and I decided offline how to proceed with this. Will update the description accordingly.

Comment thread src/messageComposer/middleware/messageComposer/compositionValidation.ts Outdated
Comment thread src/messageComposer/middleware/messageComposer/compositionValidation.ts Outdated
Comment thread src/messageComposer/middleware/messageComposer/compositionValidation.ts Outdated
Comment thread src/messageComposer/middleware/messageComposer/compositionValidation.ts Outdated
Comment thread src/messageComposer/middleware/textComposer/commandEffects.ts
Comment thread src/messageComposer/middleware/textComposer/commands.ts Outdated
Comment thread src/messageComposer/middleware/textComposer/commands.ts Outdated
Comment thread src/messageComposer/middleware/textComposer/commandEffects.ts
Comment thread src/messageComposer/messageComposer.ts Outdated
Comment thread src/messageComposer/MessageComposerEffectHandlers.ts Outdated
Comment thread src/messageComposer/MessageComposerEffectHandlers.ts Outdated
Comment thread src/messageComposer/textComposer.ts Outdated
Comment thread src/messageComposer/MessageComposerEffectHandlers.ts Outdated
Comment thread src/messageComposer/MessageComposerEffectHandlers.ts Outdated
Comment thread src/messageComposer/MessageComposerEffectHandlers.ts Outdated
Comment thread src/messageComposer/middleware/messageComposer/commandNotification.ts Outdated
@isekovanic isekovanic merged commit 5bcd1e6 into master Apr 30, 2026
4 checks passed
@isekovanic isekovanic deleted the fix/commands-flow branch April 30, 2026 15:03
github-actions Bot pushed a commit that referenced this pull request Apr 30, 2026
## [9.43.0](v9.42.3...v9.43.0) (2026-04-30)

### Features

* advanced commands flow and composer effects ([#1723](#1723)) ([5bcd1e6](5bcd1e6))
@stream-ci-bot
Copy link
Copy Markdown

🎉 This PR is included in version 9.43.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

isekovanic added a commit to GetStream/stream-chat-react-native that referenced this pull request Apr 30, 2026
## 🎯 Goal

This PR is an extension of our commands flow to better reflect what's
allowed and what's not on our backend.

LLC PR with better explanation:
GetStream/stream-chat-js#1723

General points:

- Commands are disabled in certain scenarios:
- When a quoted message is available, only `giphy` commands are allowed
- When we're in an editing state, no commands are allowed (this is
ignored server-side)
- Adding a command when we have `composer` state already (specifically
`text` and `attachments`), removes these but keeps a snapshot of the
state so that if we remove the command we go back to the previous state
- Adding `editing`/`quoted_message` state on already existing commands
for which this is not allowed will remove them

Trying to attach commands in a non-allowed state will also fire a
notification so that the UI can react accordingly.

## 🛠 Implementation details

<!-- Provide a description of the implementation -->

## 🎨 UI Changes

<!-- Add relevant screenshots -->

<details>
<summary>iOS</summary>


<table>
    <thead>
        <tr>
            <td>Before</td>
            <td>After</td>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>
                <!--<img src="" /> -->
            </td>
            <td>
                <!--<img src="" /> -->
            </td>
        </tr>
    </tbody>
</table>
</details>


<details>
<summary>Android</summary>

<table>
    <thead>
        <tr>
            <td>Before</td>
            <td>After</td>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>
                <!--<img src="" /> -->
            </td>
            <td>
                <!--<img src="" /> -->
            </td>
        </tr>
    </tbody>
</table>
</details>

## 🧪 Testing

<!-- Explain how this change can be tested (or why it can't be tested)
-->

## ☑️ Checklist

- [ ] I have signed the [Stream
CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform)
(required)
- [ ] PR targets the `develop` branch
- [ ] Documentation is updated
- [ ] New code is tested in main example apps, including all possible
scenarios
  - [ ] SampleApp iOS and Android
  - [ ] Expo iOS and Android
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants