diff --git a/docs/docs_screenshots/test/message_input/goldens/ci/message_composer_slow_mode.png b/docs/docs_screenshots/test/message_input/goldens/ci/message_composer_slow_mode.png new file mode 100644 index 000000000..85bf3344f Binary files /dev/null and b/docs/docs_screenshots/test/message_input/goldens/ci/message_composer_slow_mode.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/message_composer_slow_mode.png b/docs/docs_screenshots/test/message_input/goldens/macos/message_composer_slow_mode.png new file mode 100644 index 000000000..5ace9ec65 Binary files /dev/null and b/docs/docs_screenshots/test/message_input/goldens/macos/message_composer_slow_mode.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/message_input.png b/docs/docs_screenshots/test/message_input/goldens/macos/message_input.png index 15cd1ebb2..a0c3b8a52 100644 Binary files a/docs/docs_screenshots/test/message_input/goldens/macos/message_input.png and b/docs/docs_screenshots/test/message_input/goldens/macos/message_input.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/message_input_change_position.png b/docs/docs_screenshots/test/message_input/goldens/macos/message_input_change_position.png index e1b2d28ff..b4c1d342b 100644 Binary files a/docs/docs_screenshots/test/message_input/goldens/macos/message_input_change_position.png and b/docs/docs_screenshots/test/message_input/goldens/macos/message_input_change_position.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/message_input_custom_send_icon.png b/docs/docs_screenshots/test/message_input/goldens/macos/message_input_custom_send_icon.png index 64f0a52dd..18869a9cc 100644 Binary files a/docs/docs_screenshots/test/message_input/goldens/macos/message_input_custom_send_icon.png and b/docs/docs_screenshots/test/message_input/goldens/macos/message_input_custom_send_icon.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/message_input_quoted_message.png b/docs/docs_screenshots/test/message_input/goldens/macos/message_input_quoted_message.png index 9fa9e7173..55b646490 100644 Binary files a/docs/docs_screenshots/test/message_input/goldens/macos/message_input_quoted_message.png and b/docs/docs_screenshots/test/message_input/goldens/macos/message_input_quoted_message.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/stream_message_composer_default.png b/docs/docs_screenshots/test/message_input/goldens/macos/stream_message_composer_default.png index 15cd1ebb2..a0c3b8a52 100644 Binary files a/docs/docs_screenshots/test/message_input/goldens/macos/stream_message_composer_default.png and b/docs/docs_screenshots/test/message_input/goldens/macos/stream_message_composer_default.png differ diff --git a/docs/docs_screenshots/test/message_input/stream_message_composer_test.dart b/docs/docs_screenshots/test/message_input/stream_message_composer_test.dart index 6a7621bb7..a5d89b7bb 100644 --- a/docs/docs_screenshots/test/message_input/stream_message_composer_test.dart +++ b/docs/docs_screenshots/test/message_input/stream_message_composer_test.dart @@ -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; @@ -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', diff --git a/migrations/redesign/localizations.md b/migrations/redesign/localizations.md index ab090565e..3ec863b07 100644 --- a/migrations/redesign/localizations.md +++ b/migrations/redesign/localizations.md @@ -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: @@ -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 diff --git a/migrations/redesign/message_composer.md b/migrations/redesign/message_composer.md index 7ba2c5501..1900782ff 100644 --- a/migrations/redesign/message_composer.md +++ b/migrations/redesign/message_composer.md @@ -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. @@ -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, diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 7d289cb76..2325110bb 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -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`. diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_component_props.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_component_props.dart index c9acbb2a6..5a76b977b 100644 --- a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_component_props.dart +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_component_props.dart @@ -48,6 +48,7 @@ class MessageComposerComponentProps { this.currentUserId, required this.audioRecorderState, this.onQuotedMessageCleared, + this.cooldownTimeOut, }); /// The controller for the message composer component. @@ -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; @@ -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]. @@ -118,6 +131,7 @@ class MessageComposerLeadingProps extends MessageComposerComponentProps { currentUserId: props.currentUserId, audioRecorderState: props.audioRecorderState, onQuotedMessageCleared: props.onQuotedMessageCleared, + cooldownTimeOut: props.cooldownTimeOut, ); } } @@ -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]. @@ -150,6 +165,7 @@ class MessageComposerTrailingProps extends MessageComposerComponentProps { currentUserId: props.currentUserId, audioRecorderState: props.audioRecorderState, onQuotedMessageCleared: props.onQuotedMessageCleared, + cooldownTimeOut: props.cooldownTimeOut, ); } } @@ -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, @@ -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, @@ -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, @@ -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, @@ -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]. @@ -360,6 +381,7 @@ class MessageComposerInputLeadingProps extends MessageComposerComponentProps { currentUserId: props.currentUserId, audioRecorderState: props.audioRecorderState, onQuotedMessageCleared: props.onQuotedMessageCleared, + cooldownTimeOut: props.cooldownTimeOut, ); } } @@ -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]. @@ -392,6 +415,7 @@ class MessageComposerInputHeaderProps extends MessageComposerComponentProps { currentUserId: props.currentUserId, audioRecorderState: props.audioRecorderState, onQuotedMessageCleared: props.onQuotedMessageCleared, + cooldownTimeOut: props.cooldownTimeOut, ); } } @@ -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]. @@ -424,6 +449,7 @@ class MessageComposerInputTrailingProps extends MessageComposerComponentProps { currentUserId: props.currentUserId, audioRecorderState: props.audioRecorderState, onQuotedMessageCleared: props.onQuotedMessageCleared, + cooldownTimeOut: props.cooldownTimeOut, ); } } diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input.dart index 6276cf98d..a84afc98b 100644 --- a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input.dart +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input.dart @@ -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, diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_center.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_center.dart index 53a02c395..a52f36d0c 100644 --- a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_center.dart +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_center.dart @@ -83,6 +83,7 @@ class DefaultStreamMessageComposerInputCenter extends StatelessWidget { textCapitalization: props.textCapitalization, autofocus: props.autofocus, autocorrect: props.autocorrect, + enabled: !props.isSlowModeActive, ), if (props.canAlsoSendToChannel) DmCheckboxListTile( diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_field.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_field.dart index 3832e447f..c7f9fec32 100644 --- a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_field.dart +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_field.dart @@ -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. @@ -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; @@ -85,6 +95,7 @@ class StreamMessageComposerInputField extends StatelessWidget { child: TextField( controller: controller, focusNode: focusNode, + enabled: enabled, textInputAction: textInputAction, keyboardType: keyboardType, textCapitalization: textCapitalization, diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_trailing.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_trailing.dart index 068124531..8b880ac8a 100644 --- a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_trailing.dart +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_trailing.dart @@ -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; @@ -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 || @@ -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, @@ -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'); diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_leading.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_leading.dart index 50113af72..2ad6f65c5 100644 --- a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_leading.dart +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_leading.dart @@ -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. @@ -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), diff --git a/packages/stream_chat_flutter/lib/src/localization/translations.dart b/packages/stream_chat_flutter/lib/src/localization/translations.dart index 34ad68c91..81bee7d14 100644 --- a/packages/stream_chat_flutter/lib/src/localization/translations.dart +++ b/packages/stream_chat_flutter/lib/src/localization/translations.dart @@ -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. @@ -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'; diff --git a/packages/stream_chat_flutter/lib/src/message_input/message_input_placeholder.dart b/packages/stream_chat_flutter/lib/src/message_input/message_input_placeholder.dart index 5563ccb89..b2896beba 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/message_input_placeholder.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/message_input_placeholder.dart @@ -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, diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_chat_message_input.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_chat_message_input.dart index f9e59ead7..d0015a617 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_chat_message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_chat_message_input.dart @@ -202,56 +202,62 @@ class _StreamChatMessageInputContent extends StatelessWidget { @override Widget build(BuildContext context) { - final componentProps = MessageComposerComponentProps( - controller: inputController, - isFloating: widget.isFloating, - currentUserId: widget.currentUserId, - onSendPressed: widget.onSendPressed, - voiceRecordingCallback: _createVoiceRecordingCallback(context), - onAttachmentButtonPressed: widget.onAttachmentButtonPressed, - isPickerOpen: widget.isPickerOpen, - audioRecorderState: audioRecorderState, - focusNode: widget.focusNode, - onQuotedMessageCleared: widget.onQuotedMessageCleared, - ); + final spacing = context.streamSpacing; - final inputProps = MessageComposerInputProps.from( - componentProps, - placeholder: widget.placeholder, - textInputAction: widget.textInputAction, - keyboardType: widget.keyboardType, - textCapitalization: widget.textCapitalization, - autofocus: widget.autofocus, - autocorrect: widget.autocorrect, - canAlsoSendToChannel: widget.canAlsoSendToChannel, - audioRecorderController: widget.audioRecorderController, - feedback: widget.feedback, - sendVoiceRecordingAutomatically: widget.sendVoiceRecordingAutomatically, - ); + return ValueListenableBuilder( + valueListenable: inputController, + builder: (context, value, child) { + final cooldownTimeOut = inputController.isSlowModeActive ? inputController.cooldownTimeOut : null; + + final componentProps = MessageComposerComponentProps( + controller: inputController, + isFloating: widget.isFloating, + currentUserId: widget.currentUserId, + onSendPressed: widget.onSendPressed, + voiceRecordingCallback: _createVoiceRecordingCallback(context), + onAttachmentButtonPressed: widget.onAttachmentButtonPressed, + isPickerOpen: widget.isPickerOpen, + audioRecorderState: audioRecorderState, + focusNode: widget.focusNode, + onQuotedMessageCleared: widget.onQuotedMessageCleared, + cooldownTimeOut: cooldownTimeOut, + ); - final spacing = context.streamSpacing; + final inputProps = MessageComposerInputProps.from( + componentProps, + placeholder: widget.placeholder, + textInputAction: widget.textInputAction, + keyboardType: widget.keyboardType, + textCapitalization: widget.textCapitalization, + autofocus: widget.autofocus, + autocorrect: widget.autocorrect, + canAlsoSendToChannel: widget.canAlsoSendToChannel, + audioRecorderController: widget.audioRecorderController, + feedback: widget.feedback, + sendVoiceRecordingAutomatically: widget.sendVoiceRecordingAutomatically, + ); - return Container( - padding: EdgeInsets.only(top: spacing.md), - decoration: widget.isFloating - ? null - : BoxDecoration( - border: Border( - top: BorderSide(color: context.streamColorScheme.borderDefault), + return Container( + padding: EdgeInsets.only(top: spacing.md, right: spacing.md, left: spacing.md), + decoration: widget.isFloating + ? null + : BoxDecoration( + border: Border( + top: BorderSide(color: context.streamColorScheme.borderDefault), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + StreamMessageComposerLeading(props: componentProps), + Expanded( + child: StreamMessageComposerInput(props: inputProps), ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - SizedBox(width: spacing.md), - StreamMessageComposerLeading(props: componentProps), - Expanded( - child: StreamMessageComposerInput(props: inputProps), + StreamMessageComposerTrailing(props: componentProps), + ], ), - StreamMessageComposerTrailing(props: componentProps), - SizedBox(width: spacing.md), - ], - ), + ); + }, ); } diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart index 5c2803a08..3a05356f1 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart @@ -332,7 +332,8 @@ class MessageComposerProps { /// placeholderBuilder: (context, placeholder) { /// final translations = context.translations; /// return switch (placeholder) { - /// SlowModePlaceholder() => translations.slowModeOnLabel, + /// SlowModePlaceholder(:final cooldownTimeOut) => + /// translations.slowModeOnLabel(cooldownTimeOut), /// CommandPlaceholder(command: 'weather') => 'Type a city name', /// CommandPlaceholder() => translations.writeAMessageLabel, /// AttachmentsPlaceholder() => translations.addACommentOrSendLabel, @@ -440,7 +441,7 @@ class MessageComposerProps { ) { 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, CommandPlaceholder() || AttachmentsPlaceholder() || WriteMessagePlaceholder() => translations.writeAMessageLabel, diff --git a/packages/stream_chat_flutter/test/src/message_input/goldens/ci/message_composer_slow_mode.png b/packages/stream_chat_flutter/test/src/message_input/goldens/ci/message_composer_slow_mode.png new file mode 100644 index 000000000..538a5d1be Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_input/goldens/ci/message_composer_slow_mode.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/message_composer_slow_mode_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_composer_slow_mode_test.dart new file mode 100644 index 000000000..f1f005554 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_input/message_composer_slow_mode_test.dart @@ -0,0 +1,92 @@ +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 '../fakes.dart'; +import '../mocks.dart'; + +void main() { + final originalRecordPlatform = RecordPlatform.instance; + setUp(() => RecordPlatform.instance = FakeRecordPlatform()); + tearDown(() => RecordPlatform.instance = originalRecordPlatform); + + goldenTest( + 'slow mode active', + fileName: 'message_composer_slow_mode', + constraints: const BoxConstraints.tightFor(width: 400, height: 120), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(OwnUser(id: 'user-id'))); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.lastMessageAtStream).thenAnswer((_) => Stream.value(lastMessageAt)); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(channel.getRemainingCooldown).thenReturn(10); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((_) => Stream.value(false)); + when(() => channel.isPinned).thenReturn(false); + when(() => channel.isPinnedStream).thenAnswer((_) => Stream.value(false)); + when(() => channel.isDistinct).thenReturn(false); + when(() => channel.extraDataStream).thenAnswer((_) => Stream.value({'name': 'test'})); + when(() => channel.extraData).thenReturn({'name': 'test'}); + when(() => channel.name).thenReturn('test'); + when(() => channel.nameStream).thenAnswer((_) => Stream.value('test')); + when(() => channel.image).thenReturn(null); + when(() => channel.imageStream).thenAnswer((_) => Stream.value(null)); + when(() => channelState.membersStream).thenAnswer( + (_) => Stream.value([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ), + ]), + ); + when(() => channelState.members).thenReturn([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ), + ]); + when(() => channelState.messages).thenReturn([]); + when(() => channelState.messagesStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.draft).thenReturn(null); + when(() => channelState.draftStream).thenAnswer((_) => Stream.value(null)); + when(() => channelState.pinnedMessages).thenReturn([]); + when(() => channelState.pinnedMessagesStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.read).thenReturn([]); + when(() => channelState.readStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.currentUserReadStream).thenAnswer((_) => Stream.value(null)); + when(() => channelState.channelState).thenReturn(const ChannelState()); + when(() => channelState.channelStateStream).thenAnswer((_) => Stream.value(const ChannelState())); + + return MaterialApp( + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + channel: channel, + child: Scaffold( + body: Column( + children: [ + const Expanded(child: SizedBox()), + StreamMessageComposer(), + ], + ), + ), + ), + ), + ); + }, + ); +} diff --git a/packages/stream_chat_flutter/test/src/message_input/message_input_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_input_test.dart index 2f3b787f2..1b3923700 100644 --- a/packages/stream_chat_flutter/test/src/message_input/message_input_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/message_input_test.dart @@ -106,7 +106,14 @@ void main() { // wait for the initial state to be rendered. await tester.pumpAndSettle(); - expect(find.text('Slow mode ON'), findsOneWidget); + expect(find.text('Slow mode, wait 10s\u2026'), findsOneWidget); + + // The text field is locked while slow mode is active. + final textField = tester.widget(find.byType(TextField)); + expect(textField.enabled, isFalse); + + // The trailing button shows the remaining cooldown instead of send / mic. + expect(find.text('10'), findsOneWidget); }, ); diff --git a/packages/stream_chat_localizations/CHANGELOG.md b/packages/stream_chat_localizations/CHANGELOG.md index dc1736422..aceb90f99 100644 --- a/packages/stream_chat_localizations/CHANGELOG.md +++ b/packages/stream_chat_localizations/CHANGELOG.md @@ -9,6 +9,10 @@ method across all supported locales. - Renamed `endVoteConfirmationText` → `endVoteConfirmationTitle` across all supported locales; English default changed to `'End This Poll?'`. +- Renamed `slowModeOnLabel` getter → `slowModeOnLabel(int cooldownTimeOut)` + method across all supported locales. The default now renders a live + countdown (English default: `'Slow mode ON'` → `'Slow mode, wait + ${cooldownTimeOut}s\u2026'`). ✅ Added diff --git a/packages/stream_chat_localizations/example/lib/add_new_lang.dart b/packages/stream_chat_localizations/example/lib/add_new_lang.dart index 1b9bf4264..bb796fee2 100644 --- a/packages/stream_chat_localizations/example/lib/add_new_lang.dart +++ b/packages/stream_chat_localizations/example/lib/add_new_lang.dart @@ -406,7 +406,7 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { String attachmentLimitExceedError(int limit) => 'Attachment limit exceeded, limit: $limit'; @override - String get slowModeOnLabel => 'Slow mode ON'; + String slowModeOnLabel(int cooldownTimeOut) => 'Slow mode, wait ${cooldownTimeOut}s\u2026'; @override String get commandUsernameLabel => '@username'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart index 3b8b467d4..8ff95df68 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart @@ -388,7 +388,7 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { String get viewLibrary => 'Veure llibreria'; @override - String get slowModeOnLabel => 'Mode lent activat'; + String slowModeOnLabel(int cooldownTimeOut) => 'Mode lent, espera ${cooldownTimeOut}s\u2026'; @override String get commandUsernameLabel => '@username'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart index 0761fb974..2982e226e 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart @@ -380,7 +380,7 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { String attachmentLimitExceedError(int limit) => 'Dateigröße überschritten, Grenze: $limit'; @override - String get slowModeOnLabel => 'Langsamer Modus: EIN'; + String slowModeOnLabel(int cooldownTimeOut) => 'Langsamer Modus, warte ${cooldownTimeOut}s\u2026'; @override String get commandUsernameLabel => '@username'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart index bb53b34fa..ec178210d 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart @@ -384,7 +384,7 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { String attachmentLimitExceedError(int limit) => 'Attachment limit exceeded, limit: $limit'; @override - String get slowModeOnLabel => 'Slow mode ON'; + String slowModeOnLabel(int cooldownTimeOut) => 'Slow mode, wait ${cooldownTimeOut}s\u2026'; @override String get commandUsernameLabel => '@username'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart index 2d971b1a2..5c8509d96 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart @@ -391,7 +391,7 @@ No es posible añadir más de $limit archivos adjuntos String get viewLibrary => 'Ver Librería'; @override - String get slowModeOnLabel => 'Modo lento activado'; + String slowModeOnLabel(int cooldownTimeOut) => 'Modo lento, espera ${cooldownTimeOut}s\u2026'; @override String get commandUsernameLabel => '@username'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart index b6ace6fda..1b7dcf4bb 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart @@ -391,7 +391,7 @@ Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $ String get viewLibrary => 'Voir la bibliothèque'; @override - String get slowModeOnLabel => 'Mode lent activé'; + String slowModeOnLabel(int cooldownTimeOut) => 'Mode lent, attendez ${cooldownTimeOut}s\u2026'; @override String get commandUsernameLabel => '@username'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart index 199faf2bd..0ec85b93a 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart @@ -389,7 +389,7 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { String get viewLibrary => 'पुस्तकालय देखिये'; @override - String get slowModeOnLabel => 'स्लो मोड चालू'; + String slowModeOnLabel(int cooldownTimeOut) => 'स्लो मोड, $cooldownTimeOut सेकंड प्रतीक्षा करें\u2026'; @override String get commandUsernameLabel => '@username'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart index f5f98a969..f169fae6b 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart @@ -394,7 +394,7 @@ Attenzione: il limite massimo di $limit file è stato superato. String get viewLibrary => 'Vedi la biblioteca'; @override - String get slowModeOnLabel => 'Slowmode attiva'; + String slowModeOnLabel(int cooldownTimeOut) => 'Modalità lenta, attendi ${cooldownTimeOut}s\u2026'; @override String get commandUsernameLabel => '@username'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart index 817cc2ea0..d87b19649 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart @@ -368,7 +368,7 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { String get replyToMessageLabel => 'メッセージに返信'; @override - String get slowModeOnLabel => 'スローモードオン'; + String slowModeOnLabel(int cooldownTimeOut) => 'スローモード、$cooldownTimeOut秒お待ちください\u2026'; @override String get commandUsernameLabel => '@username'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart index 197ce29a6..6bec8e741 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart @@ -371,7 +371,7 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { String get replyToMessageLabel => '메시지에 회신합니다.'; @override - String get slowModeOnLabel => '슬로모드 켜짐'; + String slowModeOnLabel(int cooldownTimeOut) => '슬로모드, $cooldownTimeOut초만 기다려 주세요\u2026'; @override String get commandUsernameLabel => '@username'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart index 87ccc9a32..181190e9c 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart @@ -380,7 +380,7 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { String attachmentLimitExceedError(int limit) => 'Antall vedlegg oversteget, maks antall: $limit'; @override - String get slowModeOnLabel => 'Sakte modus PÅ'; + String slowModeOnLabel(int cooldownTimeOut) => 'Sakte modus, vent ${cooldownTimeOut}s\u2026'; @override String get commandUsernameLabel => '@username'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart index 1f7f22350..92b5246b4 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart @@ -384,7 +384,7 @@ Não é possível adicionar mais de $limit arquivos de uma vez '''; @override - String get slowModeOnLabel => 'Modo lento ativado'; + String slowModeOnLabel(int cooldownTimeOut) => 'Modo lento, aguarde ${cooldownTimeOut}s\u2026'; @override String get commandUsernameLabel => '@username'; diff --git a/packages/stream_chat_localizations/test/translations_test.dart b/packages/stream_chat_localizations/test/translations_test.dart index 3efef3634..27431d8e1 100644 --- a/packages/stream_chat_localizations/test/translations_test.dart +++ b/packages/stream_chat_localizations/test/translations_test.dart @@ -196,7 +196,7 @@ void main() { localizations.galleryPaginationText(currentPage: 1, totalPages: 2), isNotNull, ); - expect(localizations.slowModeOnLabel, isNotNull); + expect(localizations.slowModeOnLabel(10), isNotNull); expect(localizations.linkDisabledDetails, isNotNull); expect(localizations.linkDisabledError, isNotNull); expect(localizations.sendMessagePermissionError, isNotNull);