Skip to content
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:alchemist/alchemist.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:record/record.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_core_flutter/stream_core_flutter.dart' as core;
Expand Down Expand Up @@ -188,6 +189,29 @@ void main() {
},
);

goldenTest(
'slow mode active',
fileName: 'message_composer_slow_mode',
constraints: const BoxConstraints.tightFor(width: 375, height: 100),
builder: () {
final client = MockClient();
final clientState = MockClientState();
final channel = MockChannel();
final channelState = MockChannelState();

setupMockChannel(
client: client,
clientState: clientState,
channel: channel,
channelState: channelState,
);

when(channel.getRemainingCooldown).thenReturn(10);

return _buildMessageInputScaffold(client: client, channel: channel);
},
);

goldenTest(
'message input with quoted message',
fileName: 'message_input_quoted_message',
Expand Down
2 changes: 2 additions & 0 deletions migrations/redesign/localizations.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ The following members were renamed. If you have overridden them in a custom `Tra
|------------|------------|-------|
| `String get questionsLabel` | `String questionLabel({bool isPlural = false})` | Now mirrors `optionLabel({bool isPlural})`. Pass `isPlural: true` to get the previous plural value, or call with no arguments for the singular "Question" label used in the poll results/options dialogs. |
| `String get endVoteConfirmationText` | `String get endVoteConfirmationTitle` | Renamed to reflect that this string is the dialog title rather than body text; see also the new default value in [Changed Default String Values](#changed-default-string-values). |
| `String get slowModeOnLabel` | `String slowModeOnLabel(int cooldownTimeOut)` | Now takes the remaining cooldown in seconds so the placeholder can render a live countdown (default English: `'Slow mode, wait ${cooldownTimeOut}s\u2026'`). The composer text input is also disabled and the trailing send button shows the remaining seconds while slow mode is active. |

Example migration:

Expand Down Expand Up @@ -226,4 +227,5 @@ If your app overrides these in a `Translations` subclass, your custom values are
- [ ] Add implementations for all 35 new abstract members listed above — the compiler will flag missing ones
- [ ] Update the signature of any `questionsLabel` override to `questionLabel({bool isPlural = false})`, and replace any call to `translations.questionsLabel` with `translations.questionLabel(isPlural: true)`
- [ ] Rename any `endVoteConfirmationText` override (and consumer) to `endVoteConfirmationTitle`
- [ ] Update the signature of any `slowModeOnLabel` override from `String get slowModeOnLabel` to `String slowModeOnLabel(int cooldownTimeOut)`, and update consumers to pass the cooldown seconds (e.g. `translations.slowModeOnLabel(cooldownTimeOut)`)
- [ ] Review the changed default string values and decide whether to keep the new defaults or override them to preserve the old text
12 changes: 11 additions & 1 deletion migrations/redesign/message_composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,16 @@ The default builder now renders dedicated placeholders for Stream's built-in use

The default builder no longer uses `addACommentOrSendLabel` ("Add a comment or send") when the input only has attachments and no text — it now falls back to `writeAMessageLabel` ("Send a message"), matching the empty/idle state. The `addACommentOrSendLabel` translation key is still part of the public `Translations` interface, so to restore the old behaviour override `placeholderBuilder` and map `AttachmentsPlaceholder()` to `translations.addACommentOrSendLabel` yourself.

### Behavior change: slow mode

While slow mode is active for the current user, the composer is now visibly locked instead of just guarding the send call:

- The text input is disabled (`enabled: false`) so the user cannot edit the field.
- The trailing send button is replaced by a disabled countdown button showing the remaining seconds (e.g. `9`).
- The placeholder shows a live countdown such as `Slow mode, wait 9s…` (English default), driven by `Translations.slowModeOnLabel(int cooldownTimeOut)`. The translation key changed signature — see the [Localizations migration guide](localizations.md).

Once the cooldown reaches zero the input is automatically re-enabled and the regular send / microphone button returns. To restore the previous behaviour (editable input + normal send button while slow mode is active), supply your own `placeholderBuilder` and a custom trailing component via `streamChatComponentBuilders(messageComposerInputTrailing: ...)`.

### Sealed-class state shape

Each case carries the contextual data relevant to that input state. Pattern-match on these fields in your `placeholderBuilder` to render rich, state-aware placeholders.
Expand All @@ -272,7 +282,7 @@ StreamMessageComposer(
final translations = context.translations;
return switch (placeholder) {
SlowModePlaceholder(:final cooldownTimeOut) =>
'Slow mode on – ${cooldownTimeOut}s left',
translations.slowModeOnLabel(cooldownTimeOut),
CommandPlaceholder(command: 'giphy') => translations.searchGifLabel,
CommandPlaceholder(command: 'mute' || 'unmute' || 'ban' || 'unban') =>
translations.commandUsernameLabel,
Expand Down
1 change: 1 addition & 0 deletions packages/stream_chat_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
- Redesigned `StreamPollOptionsSheetThemeData`, `StreamPollResultsSheetThemeData`, `StreamPollOptionVotesSheetThemeData` and `StreamPollCommentsSheetThemeData` — see [`migrations/redesign/attachments_and_polls.md`](../../migrations/redesign/attachments_and_polls.md).
- Renamed `Translations.questionsLabel` getter → `questionLabel({bool isPlural = false})` method.
- Renamed `Translations.endVoteConfirmationText` → `endVoteConfirmationTitle`; English default changed to `'End This Poll?'`.
- Renamed `Translations.slowModeOnLabel` getter → `slowModeOnLabel(int cooldownTimeOut)` method; English default changed from `'Slow mode ON'` to `'Slow mode, wait ${cooldownTimeOut}s\u2026'`. While slow mode is active for the current user the composer text input is now disabled and the trailing send button is replaced by a disabled countdown button showing the remaining seconds, matching the redesigned Figma. See [`migrations/redesign/message_composer.md`](../../migrations/redesign/message_composer.md).
- Reworded `Translations.endVoteLabel` English default to `'End Poll'`.
- Removed `AttachmentButton`, `StreamQuotedMessageWidget`, `EditMessageSheet`, `StreamMessageSendButton` and `DesktopReactionsBuilder`.
- Removed `StreamChannelGridView`, `StreamChannelGridTile` and `StreamMessageSearchGridView`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class MessageComposerComponentProps {
this.currentUserId,
required this.audioRecorderState,
this.onQuotedMessageCleared,
this.cooldownTimeOut,
});

/// The controller for the message composer component.
Expand Down Expand Up @@ -80,6 +81,17 @@ class MessageComposerComponentProps {
/// Callback for when the quoted message is cleared.
final VoidCallback? onQuotedMessageCleared;

/// Remaining slow-mode cooldown in seconds, or `null` when slow mode is not
/// active for the current user.
///
/// Non-null (even if zero is never emitted) means the composer should be
/// locked: text input disabled, attachment button disabled, border dimmed,
/// and the send button replaced by a countdown indicator.
final int? cooldownTimeOut;

/// Whether slow mode is currently active for the current user.
bool get isSlowModeActive => cooldownTimeOut != null;

/// Whether the audio recording flow is active.
bool get isAudioRecordingFlowActive => audioRecorderState is RecordStateRecording || isAudioRecordingFlowStopped;

Expand All @@ -103,6 +115,7 @@ class MessageComposerLeadingProps extends MessageComposerComponentProps {
required super.currentUserId,
required super.audioRecorderState,
required super.onQuotedMessageCleared,
required super.cooldownTimeOut,
}) : super();

/// Creates a new instance of [MessageComposerLeadingProps] from a [MessageComposerComponentProps].
Expand All @@ -118,6 +131,7 @@ class MessageComposerLeadingProps extends MessageComposerComponentProps {
currentUserId: props.currentUserId,
audioRecorderState: props.audioRecorderState,
onQuotedMessageCleared: props.onQuotedMessageCleared,
cooldownTimeOut: props.cooldownTimeOut,
);
}
}
Expand All @@ -135,6 +149,7 @@ class MessageComposerTrailingProps extends MessageComposerComponentProps {
required super.currentUserId,
required super.audioRecorderState,
required super.onQuotedMessageCleared,
required super.cooldownTimeOut,
}) : super();

/// Creates a new instance of [MessageComposerTrailingProps] from a [MessageComposerComponentProps].
Expand All @@ -150,6 +165,7 @@ class MessageComposerTrailingProps extends MessageComposerComponentProps {
currentUserId: props.currentUserId,
audioRecorderState: props.audioRecorderState,
onQuotedMessageCleared: props.onQuotedMessageCleared,
cooldownTimeOut: props.cooldownTimeOut,
);
}
}
Expand All @@ -167,6 +183,7 @@ class MessageComposerInputProps extends MessageComposerComponentProps {
required super.currentUserId,
required super.audioRecorderState,
required super.onQuotedMessageCleared,
required super.cooldownTimeOut,
this.placeholder,
this.textInputAction,
this.keyboardType,
Expand Down Expand Up @@ -205,6 +222,7 @@ class MessageComposerInputProps extends MessageComposerComponentProps {
currentUserId: props.currentUserId,
audioRecorderState: props.audioRecorderState,
onQuotedMessageCleared: props.onQuotedMessageCleared,
cooldownTimeOut: props.cooldownTimeOut,
placeholder: placeholder,
textInputAction: textInputAction,
keyboardType: keyboardType,
Expand Down Expand Up @@ -262,6 +280,7 @@ class MessageComposerInputCenterProps extends MessageComposerComponentProps {
required super.currentUserId,
required super.audioRecorderState,
required super.onQuotedMessageCleared,
required super.cooldownTimeOut,
this.placeholder,
this.textInputAction,
this.keyboardType,
Expand All @@ -288,6 +307,7 @@ class MessageComposerInputCenterProps extends MessageComposerComponentProps {
currentUserId: inputProps.currentUserId,
audioRecorderState: inputProps.audioRecorderState,
onQuotedMessageCleared: inputProps.onQuotedMessageCleared,
cooldownTimeOut: inputProps.cooldownTimeOut,
placeholder: inputProps.placeholder,
textInputAction: inputProps.textInputAction,
keyboardType: inputProps.keyboardType,
Expand Down Expand Up @@ -345,6 +365,7 @@ class MessageComposerInputLeadingProps extends MessageComposerComponentProps {
required super.currentUserId,
required super.audioRecorderState,
required super.onQuotedMessageCleared,
required super.cooldownTimeOut,
}) : super();

/// Creates a new instance of [MessageComposerInputLeadingProps] from a [MessageComposerComponentProps].
Expand All @@ -360,6 +381,7 @@ class MessageComposerInputLeadingProps extends MessageComposerComponentProps {
currentUserId: props.currentUserId,
audioRecorderState: props.audioRecorderState,
onQuotedMessageCleared: props.onQuotedMessageCleared,
cooldownTimeOut: props.cooldownTimeOut,
);
}
}
Expand All @@ -377,6 +399,7 @@ class MessageComposerInputHeaderProps extends MessageComposerComponentProps {
required super.currentUserId,
required super.audioRecorderState,
required super.onQuotedMessageCleared,
required super.cooldownTimeOut,
}) : super();

/// Creates a new instance of [MessageComposerInputHeaderProps] from a [MessageComposerComponentProps].
Expand All @@ -392,6 +415,7 @@ class MessageComposerInputHeaderProps extends MessageComposerComponentProps {
currentUserId: props.currentUserId,
audioRecorderState: props.audioRecorderState,
onQuotedMessageCleared: props.onQuotedMessageCleared,
cooldownTimeOut: props.cooldownTimeOut,
);
}
}
Expand All @@ -409,6 +433,7 @@ class MessageComposerInputTrailingProps extends MessageComposerComponentProps {
required super.currentUserId,
required super.audioRecorderState,
required super.onQuotedMessageCleared,
required super.cooldownTimeOut,
}) : super();

/// Creates a new instance of [MessageComposerInputTrailingProps] from a [MessageComposerComponentProps].
Expand All @@ -424,6 +449,7 @@ class MessageComposerInputTrailingProps extends MessageComposerComponentProps {
currentUserId: props.currentUserId,
audioRecorderState: props.audioRecorderState,
onQuotedMessageCleared: props.onQuotedMessageCleared,
cooldownTimeOut: props.cooldownTimeOut,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,15 @@ class DefaultStreamMessageComposerInput extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isFloating = props.isFloating;
final borderColor = props.isSlowModeActive
? context.streamColorScheme.borderDisabled
: context.streamColorScheme.borderDefault;

return Container(
clipBehavior: Clip.antiAlias,
foregroundDecoration: BoxDecoration(
borderRadius: BorderRadius.all(context.streamRadius.xxxl),
border: Border.all(
color: context.streamColorScheme.borderDefault,
),
border: Border.all(color: borderColor),
),
decoration: BoxDecoration(
color: context.streamColorScheme.backgroundElevation1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ class DefaultStreamMessageComposerInputCenter extends StatelessWidget {
textCapitalization: props.textCapitalization,
autofocus: props.autofocus,
autocorrect: props.autocorrect,
enabled: !props.isSlowModeActive,
),
if (props.canAlsoSendToChannel)
DmCheckboxListTile(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class StreamMessageComposerInputField extends StatelessWidget {
this.textCapitalization = TextCapitalization.sentences,
this.autofocus = false,
this.autocorrect = true,
this.enabled = true,
});

/// The controller for the text field.
Expand Down Expand Up @@ -53,6 +54,15 @@ class StreamMessageComposerInputField extends StatelessWidget {
/// Whether to enable autocorrect.
final bool autocorrect;

/// Whether the text field is enabled.
///
/// When false the text field cannot be edited and the placeholder remains
/// visible regardless of the underlying [controller] text. Used by the
/// composer to lock input while slow mode is active.
///
/// Defaults to true.
final bool enabled;

@override
Widget build(BuildContext context) {
final spacing = context.streamSpacing;
Expand Down Expand Up @@ -85,6 +95,7 @@ class StreamMessageComposerInputField extends StatelessWidget {
child: TextField(
controller: controller,
focusNode: focusNode,
enabled: enabled,
textInputAction: textInputAction,
keyboardType: keyboardType,
textCapitalization: textCapitalization,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ class DefaultStreamMessageComposerInputTrailing extends StatelessWidget {
return ValueListenableBuilder(
valueListenable: _controller,
builder: (context, value, child) {
if (props.isAudioRecordingFlowLocked || props.isAudioRecordingFlowStopped) {
return const SizedBox.shrink();
}

if (props.isSlowModeActive) {
return _SlowModeCountdownButton(
key: _slowModeKey,
cooldownTimeOut: props.cooldownTimeOut!,
);
}

final hasText = _controller.text.trim().isNotEmpty;
final hasContent = hasText || _controller.attachments.isNotEmpty;
final isEditing = _controller.isEditing;
Expand All @@ -60,10 +71,6 @@ class DefaultStreamMessageComposerInputTrailing extends StatelessWidget {

final isEnabled = (!isEditing && !hasCommand) || hasContent;

if (props.isAudioRecordingFlowLocked || props.isAudioRecordingFlowStopped) {
return const SizedBox.shrink();
}

final voiceRecordingCallback = props.voiceRecordingCallback;
if (buttonState == _ButtonState.send ||
buttonState == _ButtonState.edit ||
Expand Down Expand Up @@ -98,6 +105,21 @@ enum _ButtonState {
voiceRecordingActive,
}

class _SlowModeCountdownButton extends StatelessWidget {
const _SlowModeCountdownButton({super.key, required this.cooldownTimeOut});

final int cooldownTimeOut;

@override
Widget build(BuildContext context) {
return StreamButton.icon(
icon: Text('$cooldownTimeOut'),
style: StreamButtonStyle.secondary,
size: StreamButtonSize.small,
);
}
}

class _VoiceRecordingButton extends StatelessWidget {
const _VoiceRecordingButton({
required this.voiceRecordingCallback,
Expand Down Expand Up @@ -140,5 +162,6 @@ class _VoiceRecordingButton extends StatelessWidget {
}
}

final _sendKey = UniqueKey();
final _microphoneKey = UniqueKey();
const _sendKey = ValueKey('send_key');
const _microphoneKey = ValueKey('microphone_key');
const _slowModeKey = ValueKey('slow_mode_key');
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';

/// A widget that shows the leading of the message composer.
Expand Down Expand Up @@ -60,7 +60,7 @@ class DefaultStreamMessageComposerLeading extends StatelessWidget {
type: StreamButtonType.outline,
size: StreamButtonSize.large,
isFloating: props.isFloating,
onPressed: () => props.onAttachmentButtonPressed?.call(),
onPressed: props.isSlowModeActive ? null : props.onAttachmentButtonPressed,
),
),
SizedBox(width: context.streamSpacing.xs),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,13 @@ abstract class Translations {
/// The label for write a message in [StreamMessageComposer]
String get writeAMessageLabel;

/// The label for slow mode enabled in [StreamMessageComposer]
String get slowModeOnLabel;
/// The placeholder shown in [StreamMessageComposer] while slow mode is
/// active for the current user.
///
/// [cooldownTimeOut] is the number of seconds remaining before the user
/// can send another message. Defaults to `'Slow mode, wait ${cooldownTimeOut}s\u2026'`
/// which renders as e.g. "Slow mode, wait 9s…".
String slowModeOnLabel(int cooldownTimeOut);

/// The placeholder shown in the composer when a user-target command (for
/// example `/mute`, `/unmute`, `/ban`, `/unban`) is active.
Expand Down Expand Up @@ -1106,7 +1111,7 @@ class DefaultTranslations implements Translations {
String replyToUserLabel(String userName) => 'Reply to $userName';

@override
String get slowModeOnLabel => 'Slow mode ON';
String slowModeOnLabel(int cooldownTimeOut) => 'Slow mode, wait ${cooldownTimeOut}s\u2026';

@override
String get commandUsernameLabel => '@username';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// ) {
/// final translations = context.translations;
/// return switch (placeholder) {
/// SlowModePlaceholder() => translations.slowModeOnLabel,
/// SlowModePlaceholder(:final cooldownTimeOut) =>
/// translations.slowModeOnLabel(cooldownTimeOut),
/// CommandPlaceholder(command: 'giphy') => translations.searchGifLabel,
/// CommandPlaceholder(command: 'mute' || 'unmute' || 'ban' || 'unban') =>
/// translations.commandUsernameLabel,
Expand Down
Loading
Loading