From ea7db779142cc64fdcd6bc8c8351390bf039c7d9 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 9 Jun 2026 15:43:14 +0200 Subject: [PATCH 1/6] fix slowdown on threads --- packages/stream_chat/CHANGELOG.md | 1 + .../stream_chat/lib/src/client/channel.dart | 64 +++++++++------ .../test/src/client/channel_test.dart | 79 +++++++++++++++++++ 3 files changed, 120 insertions(+), 24 deletions(-) diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 58096aa40b..3f87777ba3 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -29,6 +29,7 @@ šŸž Fixed +- Fixed slow mode (cooldown) not activating after sending a reply in a thread. `Channel.getRemainingCooldown()` and `currentUserLastMessageAt` now scan thread replies in addition to main-channel messages, matching the backend behaviour where both message types share the same per-channel cooldown bucket. - Fixed reactions, polls, and quoted-message enrichment briefly flickering after the app returned from the background. The reconnect path now refreshes channels and advances `lastSyncAt` to the current time instead of replaying every event since `lastSyncAt` through `handleEvent`. `client.sync()` remains available for consumers that need event-level replay. - Fixed `Channel.sendMessage` / `Channel.updateMessage` hanging forever when any attachment upload failed; they now throw `StreamChatError`. - Fixed quoted poll messages losing their poll, shared-location, or nested-quote content when the server omits it from the `quoted_message` payload during channel re-sync. diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index f1e22c87c8..c5794e51d2 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'dart:math' as math; import 'package:collection/collection.dart'; +import 'package:meta/meta.dart'; import 'package:rxdart/rxdart.dart'; import 'package:stream_chat/src/client/retry_queue.dart'; import 'package:stream_chat/src/core/util/utils.dart'; @@ -361,19 +362,6 @@ class Channel { return state!.channelStateStream.map((cs) => cs.channel?.lastMessageAt); } - DateTime? _currentUserLastMessageAt(List? messages) { - final currentUserId = client.state.currentUser?.id; - if (currentUserId == null) return null; - - final validMessages = messages?.where((message) { - if (message.isEphemeral) return false; - if (message.user?.id != currentUserId) return false; - return true; - }); - - return validMessages?.map((m) => m.createdAt).max; - } - /// The date of the last message sent by the current user. DateTime? get currentUserLastMessageAt { _checkInitialized(); @@ -382,23 +370,17 @@ class Channel { // from the current user. if (!state!.isUpToDate) return null; - final messages = state!.channelState.messages; - return _currentUserLastMessageAt(messages); + return state!._currentUserLastSentAt; } /// The date of the last message sent by the current user as a stream. Stream get currentUserLastMessageAtStream { _checkInitialized(); - return CombineLatestStream.combine2?, DateTime?>( + return CombineLatestStream.combine2( state!.isUpToDateStream, - state!.channelStateStream.map((state) => state.messages), - (isUpToDate, messages) { - // If the channel is not up to date, we can't rely on the last message - // from the current user. - if (!isUpToDate) return null; - return _currentUserLastMessageAt(messages); - }, + state!._currentUserLastSentAtStream, + (isUpToDate, lastSentAt) => isUpToDate ? lastSentAt : null, ); } @@ -2373,6 +2355,7 @@ class ChannelClientState { _channelStateController = BehaviorSubject.seeded(channelState); // Update the persistence storage with the seeded channel state. _debouncedUpdatePersistenceChannelState.call([channelState]); + _maybeUpdateCurrentUserLastSentAt(channelState.messages ?? const []); // region TYPING EVENTS _listenTypingEvents(); @@ -2455,7 +2438,10 @@ class ChannelClientState { ?.getChannelThreads(_channel.cid!) .then((threads) { // Load all the threads for the channel from the offline storage. - if (threads.isNotEmpty) _threads = threads; + if (threads.isNotEmpty) { + _threads = threads; + _maybeUpdateCurrentUserLastSentAt(threads.values.flattened); + } }) .then((_) => retryFailedMessages()); } @@ -3610,6 +3596,8 @@ class ChannelClientState { pushPreferences: updatedState.pushPreferences, activeLiveLocations: updatedState.activeLiveLocations, ); + + _maybeUpdateCurrentUserLastSentAt(updatedState.messages ?? const []); } int _sortByCreatedAt(Message a, Message b) => a.createdAt.compareTo(b.createdAt); @@ -3683,6 +3671,7 @@ class ChannelClientState { updatedThreads[parentId] = updatedThreadMessages.toList(); _threads = updatedThreads; + _maybeUpdateCurrentUserLastSentAt(messages); } Draft? _getThreadDraft(String parentId, List? messages) { @@ -3904,6 +3893,7 @@ class ChannelClientState { _updateChannelMessages(messages, update: update); _updatePinnedMessages(messages, update: update); _updateActiveLiveLocations(messages); + _maybeUpdateCurrentUserLastSentAt(messages); } void _updateThreadMessages( @@ -4286,6 +4276,31 @@ class ChannelClientState { ); } + /// The timestamp of the most recent non-ephemeral message sent by the + /// current user in this channel, including thread replies. + DateTime? get _currentUserLastSentAt => _currentUserLastSentAtController.value; + + /// Stream of [_currentUserLastSentAt]. + Stream get _currentUserLastSentAtStream => _currentUserLastSentAtController.stream; + + final _currentUserLastSentAtController = BehaviorSubject.seeded(null); + + /// Updates [_currentUserLastSentAt] if [messages] contains a newer + /// non-ephemeral message from the current user. + void _maybeUpdateCurrentUserLastSentAt(Iterable messages) { + final currentUserId = _client.state.currentUser?.id; + if (currentUserId == null) return; + + for (final message in messages) { + if (message.user?.id != currentUserId) continue; + if (message.isEphemeral) continue; + final current = _currentUserLastSentAtController.value; + if (current == null || message.createdAt.isAfter(current)) { + _currentUserLastSentAtController.safeAdd(message.createdAt); + } + } + } + /// Call this method to dispose this object. void dispose() { _debouncedUpdatePersistenceChannelThreads.cancel(); @@ -4295,6 +4310,7 @@ class ChannelClientState { _channelStateController.close(); _isUpToDateController.close(); _threadsController.close(); + _currentUserLastSentAtController.close(); _staleTypingEventsCleanerTimer?.cancel(); _stalePinnedMessagesCleanerTimer?.cancel(); _staleLiveLocationsCleanerTimer?.cancel(); diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index 81cf8a7878..fec802acf1 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -8620,6 +8620,85 @@ void main() { }); }); + group('Thread reply cooldown', () { + const currentUserId = 'test-user-id'; // matches FakeClientState default + const cooldownDuration = 30; // seconds + + Channel _buildChannelWithCooldown() { + final channelModel = ChannelModel( + id: channelId, + type: channelType, + cooldown: cooldownDuration, + ownCapabilities: [ChannelCapability.slowMode], + ); + final state = ChannelState(channel: channelModel); + final ch = Channel.fromState(client, state); + // isUpToDate is seeded true by default + return ch; + } + + test( + 'should return positive cooldown after current user sends a thread reply', + () { + final ch = _buildChannelWithCooldown(); + addTearDown(ch.dispose); + + // Simulate a thread reply by the current user sent just now. + final threadReply = Message( + id: 'thread-reply-1', + parentId: 'parent-msg-1', + showInChannel: false, + createdAt: DateTime.timestamp(), + user: User(id: currentUserId), + ); + ch.state!.updateThreadInfo('parent-msg-1', [threadReply]); + + expect(ch.getRemainingCooldown(), greaterThan(0)); + }, + ); + + test( + 'should return 0 cooldown when thread reply was sent outside the cooldown window', + () { + final ch = _buildChannelWithCooldown(); + addTearDown(ch.dispose); + + // Reply sent cooldownDuration+5 seconds ago — outside the window. + final oldReply = Message( + id: 'thread-reply-old', + parentId: 'parent-msg-1', + showInChannel: false, + createdAt: DateTime.timestamp().subtract( + const Duration(seconds: cooldownDuration + 5), + ), + user: User(id: currentUserId), + ); + ch.state!.updateThreadInfo('parent-msg-1', [oldReply]); + + expect(ch.getRemainingCooldown(), equals(0)); + }, + ); + + test( + 'should not trigger cooldown for a thread reply from another user', + () { + final ch = _buildChannelWithCooldown(); + addTearDown(ch.dispose); + + final otherUserReply = Message( + id: 'thread-reply-other', + parentId: 'parent-msg-1', + showInChannel: false, + createdAt: DateTime.timestamp(), + user: User(id: 'other-user-id'), + ); + ch.state!.updateThreadInfo('parent-msg-1', [otherUserReply]); + + expect(ch.getRemainingCooldown(), equals(0)); + }, + ); + }); + group('Disposed channel state validation', () { late Channel channel; From 6f0c7004d3c54e227445387f02144776e0e3d5b1 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 9 Jun 2026 15:52:06 +0200 Subject: [PATCH 2/6] Use disabled UI for dm checkbox --- .../message_composer/message_composer_input_center.dart | 2 +- .../lib/src/message_input/dm_checkbox_list_tile.dart | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) 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 a52f36d0c7..caf94537b8 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 @@ -94,7 +94,7 @@ class DefaultStreamMessageComposerInputCenter extends StatelessWidget { left: context.streamSpacing.md, bottom: context.streamSpacing.md - 8, ), - onChanged: (value) => controller.showInChannel = value, + onChanged: props.isSlowModeActive ? null : (value) => controller.showInChannel = value, ), ], ); diff --git a/packages/stream_chat_flutter/lib/src/message_input/dm_checkbox_list_tile.dart b/packages/stream_chat_flutter/lib/src/message_input/dm_checkbox_list_tile.dart index 6a35eeaf58..44f29e8872 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/dm_checkbox_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/dm_checkbox_list_tile.dart @@ -59,7 +59,9 @@ class DmCheckboxListTile extends StatelessWidget { contentPadding: contentPadding, title: Text( context.translations.alsoSendAsDirectMessageLabel, - style: textTheme.metadataDefault.copyWith(color: colorScheme.textPrimary), + style: textTheme.metadataDefault.copyWith( + color: onChanged != null ? colorScheme.textPrimary : colorScheme.textDisabled, + ), ), ), ), From 567ebf14d18ec54be01e3a7f56d628065050af10 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 9 Jun 2026 15:56:06 +0200 Subject: [PATCH 3/6] update changelog for UI --- packages/stream_chat_flutter/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 224960c385..6f7ffe602a 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -2,6 +2,7 @@ šŸž Fixed +- Fixed the "Also send in Channel" checkbox not being disabled during slow mode. The checkbox and its label now render with disabled colors (`colorScheme.textDisabled`) when a cooldown is active, matching the rest of the composer. - `StreamUserAvatar` with `StreamAvatarSize.xxl` now uses `StreamOnlineIndicatorSize.xxl` (20px) instead of `xl` (16px), matching the Chat SDK design system spec. - Fixed the thread page flashing through a large scroll-up animation when opened from an in-channel reply with cached thread replies. - Fixed the thread page throwing `Bad state: No element` on every channel state update when the parent message wasn't in the channel's loaded window. From faab0b3b0c7b9f356c9f2c23f7749dd7bb667759 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 9 Jun 2026 16:02:09 +0200 Subject: [PATCH 4/6] formatting --- packages/stream_chat/lib/src/client/channel.dart | 1 - .../lib/src/message_input/dm_checkbox_list_tile.dart | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index c5794e51d2..0b515df8b2 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -4,7 +4,6 @@ import 'dart:async'; import 'dart:math' as math; import 'package:collection/collection.dart'; -import 'package:meta/meta.dart'; import 'package:rxdart/rxdart.dart'; import 'package:stream_chat/src/client/retry_queue.dart'; import 'package:stream_chat/src/core/util/utils.dart'; diff --git a/packages/stream_chat_flutter/lib/src/message_input/dm_checkbox_list_tile.dart b/packages/stream_chat_flutter/lib/src/message_input/dm_checkbox_list_tile.dart index 44f29e8872..f09a977d9f 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/dm_checkbox_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/dm_checkbox_list_tile.dart @@ -60,8 +60,8 @@ class DmCheckboxListTile extends StatelessWidget { title: Text( context.translations.alsoSendAsDirectMessageLabel, style: textTheme.metadataDefault.copyWith( - color: onChanged != null ? colorScheme.textPrimary : colorScheme.textDisabled, - ), + color: onChanged != null ? colorScheme.textPrimary : colorScheme.textDisabled, + ), ), ), ), From e203ecf7d043ca25bec910cb4127468b75285aec Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 12 Jun 2026 17:09:05 +0200 Subject: [PATCH 5/6] improve performance by caching maxCreatedAt --- packages/stream_chat/lib/src/client/channel.dart | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index 0b515df8b2..3c110aa10c 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -4290,14 +4290,20 @@ class ChannelClientState { final currentUserId = _client.state.currentUser?.id; if (currentUserId == null) return; + DateTime? maxCreatedAt; for (final message in messages) { if (message.user?.id != currentUserId) continue; if (message.isEphemeral) continue; - final current = _currentUserLastSentAtController.value; - if (current == null || message.createdAt.isAfter(current)) { - _currentUserLastSentAtController.safeAdd(message.createdAt); + if (maxCreatedAt == null || message.createdAt.isAfter(maxCreatedAt)) { + maxCreatedAt = message.createdAt; } } + + if (maxCreatedAt == null) return; + final current = _currentUserLastSentAtController.value; + if (current == null || maxCreatedAt.isAfter(current)) { + _currentUserLastSentAtController.safeAdd(maxCreatedAt); + } } /// Call this method to dispose this object. From 52884e0a605043477c0d0ff049d1c9fea1b0a626 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 22 Jun 2026 19:17:32 +0200 Subject: [PATCH 6/6] Optimize cooldown scan and add cross-composer cooldown sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LLC: switch _currentUserLastMessageAt to per-list lastIndexWhere short-circuit; structured input (channelMessages + threads map) for O(few) reads on large channels. - LLC: add optional lastMessageAt override on getRemainingCooldown so callers with the timestamp in hand can skip a re-scan. - Composer: subscribe to currentUserLastMessageAtStream and re-kick startCooldown on each emission — fixes thread-composer sends not propagating cooldown to the sibling channel composer. - Tests: extract new MessageComposer cooldown group with realistic channel-state-driven flow; add LLC tests for the override param and cross-source max aggregation. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../stream_chat/lib/src/client/channel.dart | 103 +++++----- .../test/src/client/channel_test.dart | 103 ++++++++++ .../stream_message_composer.dart | 12 +- .../src/message_input/message_input_test.dart | 185 ++++++++++++------ .../stream_chat_flutter/test/src/mocks.dart | 1 + 5 files changed, 293 insertions(+), 111 deletions(-) diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index 3c110aa10c..614f371f17 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -320,13 +320,16 @@ class Channel { /// Remaining cooldown duration in seconds for the channel. /// /// Returns 0 if there is no cooldown active. - int getRemainingCooldown() { + /// + /// Optionally, provide [lastMessageAt] to calculate the remaining cooldown based on a specific message timestamp + /// instead of the last message sent by the current user in this channel. + int getRemainingCooldown({DateTime? lastMessageAt}) { _checkInitialized(); final cooldownDuration = cooldown; if (cooldownDuration <= 0) return 0; - final userLastMessageAt = currentUserLastMessageAt; + final userLastMessageAt = lastMessageAt ?? currentUserLastMessageAt; if (userLastMessageAt == null) return 0; if (canSkipSlowMode) return 0; @@ -361,7 +364,38 @@ class Channel { return state!.channelStateStream.map((cs) => cs.channel?.lastMessageAt); } + DateTime? _currentUserLastMessageAt({ + required List? messages, + required Map> threads, + }) { + final currentUserId = client.state.currentUser?.id; + if (currentUserId == null) return null; + + bool ours(Message m) => !m.isEphemeral && m.user?.id == currentUserId; + + DateTime? max; + + if (messages != null) { + final idx = messages.lastIndexWhere(ours); + if (idx != -1) max = messages[idx].createdAt; + } + + for (final replies in threads.values) { + final idx = replies.lastIndexWhere(ours); + if (idx == -1) continue; + final createdAt = replies[idx].createdAt; + if (max == null || createdAt.isAfter(max)) max = createdAt; + } + + return max; + } + /// The date of the last message sent by the current user. + /// + /// Returns null if the channel is not up to date or + /// if the current user has not sent any messages in this channel. + /// + /// Note: This includes both regular messages and thread messages. DateTime? get currentUserLastMessageAt { _checkInitialized(); @@ -369,17 +403,32 @@ class Channel { // from the current user. if (!state!.isUpToDate) return null; - return state!._currentUserLastSentAt; + final threads = state!.threads; + final messages = state!.channelState.messages; + + return _currentUserLastMessageAt(messages: messages, threads: threads); } /// The date of the last message sent by the current user as a stream. + /// + /// Returns null if the channel is not up to date or + /// if the current user has not sent any messages in this channel. + /// + /// Note: This includes both regular messages and thread messages. Stream get currentUserLastMessageAtStream { _checkInitialized(); - return CombineLatestStream.combine2( + return CombineLatestStream.combine3( state!.isUpToDateStream, - state!._currentUserLastSentAtStream, - (isUpToDate, lastSentAt) => isUpToDate ? lastSentAt : null, + state!.channelStateStream.map((s) => s.messages).distinct(identical), + state!.threadsStream, + (isUpToDate, messages, threads) { + // If the channel is not up to date, we can't rely on the last message + // from the current user. + if (!isUpToDate) return null; + + return _currentUserLastMessageAt(messages: messages, threads: threads); + }, ); } @@ -2354,7 +2403,6 @@ class ChannelClientState { _channelStateController = BehaviorSubject.seeded(channelState); // Update the persistence storage with the seeded channel state. _debouncedUpdatePersistenceChannelState.call([channelState]); - _maybeUpdateCurrentUserLastSentAt(channelState.messages ?? const []); // region TYPING EVENTS _listenTypingEvents(); @@ -2437,10 +2485,7 @@ class ChannelClientState { ?.getChannelThreads(_channel.cid!) .then((threads) { // Load all the threads for the channel from the offline storage. - if (threads.isNotEmpty) { - _threads = threads; - _maybeUpdateCurrentUserLastSentAt(threads.values.flattened); - } + if (threads.isNotEmpty) _threads = threads; }) .then((_) => retryFailedMessages()); } @@ -3595,8 +3640,6 @@ class ChannelClientState { pushPreferences: updatedState.pushPreferences, activeLiveLocations: updatedState.activeLiveLocations, ); - - _maybeUpdateCurrentUserLastSentAt(updatedState.messages ?? const []); } int _sortByCreatedAt(Message a, Message b) => a.createdAt.compareTo(b.createdAt); @@ -3670,7 +3713,6 @@ class ChannelClientState { updatedThreads[parentId] = updatedThreadMessages.toList(); _threads = updatedThreads; - _maybeUpdateCurrentUserLastSentAt(messages); } Draft? _getThreadDraft(String parentId, List? messages) { @@ -3892,7 +3934,6 @@ class ChannelClientState { _updateChannelMessages(messages, update: update); _updatePinnedMessages(messages, update: update); _updateActiveLiveLocations(messages); - _maybeUpdateCurrentUserLastSentAt(messages); } void _updateThreadMessages( @@ -4275,37 +4316,6 @@ class ChannelClientState { ); } - /// The timestamp of the most recent non-ephemeral message sent by the - /// current user in this channel, including thread replies. - DateTime? get _currentUserLastSentAt => _currentUserLastSentAtController.value; - - /// Stream of [_currentUserLastSentAt]. - Stream get _currentUserLastSentAtStream => _currentUserLastSentAtController.stream; - - final _currentUserLastSentAtController = BehaviorSubject.seeded(null); - - /// Updates [_currentUserLastSentAt] if [messages] contains a newer - /// non-ephemeral message from the current user. - void _maybeUpdateCurrentUserLastSentAt(Iterable messages) { - final currentUserId = _client.state.currentUser?.id; - if (currentUserId == null) return; - - DateTime? maxCreatedAt; - for (final message in messages) { - if (message.user?.id != currentUserId) continue; - if (message.isEphemeral) continue; - if (maxCreatedAt == null || message.createdAt.isAfter(maxCreatedAt)) { - maxCreatedAt = message.createdAt; - } - } - - if (maxCreatedAt == null) return; - final current = _currentUserLastSentAtController.value; - if (current == null || maxCreatedAt.isAfter(current)) { - _currentUserLastSentAtController.safeAdd(maxCreatedAt); - } - } - /// Call this method to dispose this object. void dispose() { _debouncedUpdatePersistenceChannelThreads.cancel(); @@ -4315,7 +4325,6 @@ class ChannelClientState { _channelStateController.close(); _isUpToDateController.close(); _threadsController.close(); - _currentUserLastSentAtController.close(); _staleTypingEventsCleanerTimer?.cancel(); _stalePinnedMessagesCleanerTimer?.cancel(); _staleLiveLocationsCleanerTimer?.cancel(); diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index fec802acf1..8ddf7c781b 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -8697,6 +8697,109 @@ void main() { expect(ch.getRemainingCooldown(), equals(0)); }, ); + + test( + 'should clear cooldown when the most-recent own message is hard-deleted', + () { + final ch = _buildChannelWithCooldown(); + addTearDown(ch.dispose); + + final ownMessage = Message( + id: 'msg-1', + createdAt: DateTime.timestamp(), + user: User(id: currentUserId), + ); + ch.state!.updateMessage(ownMessage); + expect(ch.getRemainingCooldown(), greaterThan(0)); + + ch.state!.deleteMessage(ownMessage, hardDelete: true); + expect(ch.getRemainingCooldown(), equals(0)); + }, + ); + + test( + 'currentUserLastMessageAtStream emits a new timestamp when own message is added', + () async { + final ch = _buildChannelWithCooldown(); + addTearDown(ch.dispose); + + final emissions = []; + final sub = ch.currentUserLastMessageAtStream.listen(emissions.add); + addTearDown(sub.cancel); + + // Let the seed emission settle. + await Future.delayed(Duration.zero); + final seededLast = emissions.last; + + ch.state!.updateMessage( + Message( + id: 'msg-1', + createdAt: DateTime.timestamp(), + user: User(id: currentUserId), + ), + ); + await Future.delayed(Duration.zero); + + expect(emissions.last, isNotNull); + expect(emissions.last, isNot(equals(seededLast))); + }, + ); + + test( + 'getRemainingCooldown uses the explicit [lastMessageAt] override', + () { + final ch = _buildChannelWithCooldown(); + addTearDown(ch.dispose); + + // No messages in state, so the default path returns 0. + expect(ch.getRemainingCooldown(), equals(0)); + + // Override pointing inside the cooldown window → positive remaining. + final recent = DateTime.timestamp().subtract(const Duration(seconds: 5)); + expect(ch.getRemainingCooldown(lastMessageAt: recent), greaterThan(0)); + + // Override pointing outside the window → 0. + final old = DateTime.timestamp().subtract( + const Duration(seconds: cooldownDuration + 5), + ); + expect(ch.getRemainingCooldown(lastMessageAt: old), equals(0)); + }, + ); + + test( + 'currentUserLastMessageAt picks the latest across channel messages and threads', + () { + final ch = _buildChannelWithCooldown(); + addTearDown(ch.dispose); + + final older = DateTime.timestamp().subtract(const Duration(seconds: 20)); + final newer = DateTime.timestamp().subtract(const Duration(seconds: 5)); + + // Older message in the main channel. + ch.state!.updateMessage( + Message( + id: 'msg-1', + createdAt: older, + user: User(id: currentUserId), + ), + ); + // Newer reply in a thread. + ch.state!.updateThreadInfo('parent-msg-1', [ + Message( + id: 'thread-reply-1', + parentId: 'parent-msg-1', + showInChannel: false, + createdAt: newer, + user: User(id: currentUserId), + ), + ]); + + // Should pick the newer thread reply, not the older channel message. + final result = ch.currentUserLastMessageAt; + expect(result, isNotNull); + expect(result!.isAtSameMomentAs(newer), isTrue); + }, + ); }); group('Disposed channel state validation', () { 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 8a14a72e1e..27e249e856 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 @@ -494,6 +494,7 @@ class DefaultStreamMessageComposerState extends State? _lastSentAtSubscription; StreamSubscription? _draftStreamSubscription; StreamSubscription? _messageUpdatedSubscription; StreamSubscription? _messageDeletedSubscription; @@ -534,9 +535,14 @@ class DefaultStreamMessageComposerState extends State channel.sendMessage(message), }; - _effectiveController.startCooldown(channel.getRemainingCooldown()); widget.props.onMessageSent?.call(resp.message); } catch (e, stk) { if (widget.props.onError != null) { @@ -1393,6 +1398,7 @@ class DefaultStreamMessageComposerState extends State lastMessageAtSubject; + + setUp(() { + registerFallbackValue(Message()); + lastMessageAtSubject = BehaviorSubject.seeded(null); when(() => client.state).thenReturn(clientState); when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(OwnUser(id: 'user-id'))); + when(() => channel.state).thenReturn(channelState); - when(channel.getRemainingCooldown).thenReturn(10); when(() => channel.client).thenReturn(client); - when(() => channel.isMuted).thenReturn(false); - when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); - when(() => channel.extraDataStream).thenAnswer( - (i) => Stream.value({ - 'name': 'test', - }), - ); - when(() => channel.extraData).thenReturn({ - 'name': 'test', + when(() => channel.currentUserLastMessageAtStream).thenAnswer((_) => lastMessageAtSubject.stream); + + // Routes a message added to channel state through the same signal a + // real channel emits on: the current user's last-message-at stream + // gets the new timestamp. + when(() => channelState.addNewMessage(any())).thenAnswer((invocation) { + final message = invocation.positionalArguments[0] as Message; + if (message.user?.id == 'user-id' && !message.isEphemeral) { + lastMessageAtSubject.add(message.createdAt); + } }); - when(() => channelState.membersStream).thenAnswer( - (i) => 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([ - Message( - text: 'hello', - user: User(id: 'other-user'), - ), - ]); - when(() => channelState.messagesStream).thenAnswer( - (i) => Stream.value([ - Message( - text: 'hello', - user: User(id: 'other-user'), - ), - ]), - ); - await tester.pumpWidget( + // Real-flow cooldown: derived from the timestamp the LLC stream emits. + // null (no send yet) → 0, recent timestamp (current user just sent) → cooldownSeconds. + when(() => channel.getRemainingCooldown(lastMessageAt: any(named: 'lastMessageAt'))).thenAnswer((i) { + return i.namedArguments[#lastMessageAt] != null ? cooldownSeconds : 0; + }); + }); + + tearDown(() => lastMessageAtSubject.close()); + + Future pumpComposer(WidgetTester tester) { + return tester.pumpWidget( MaterialApp( home: StreamChat( client: client, child: StreamChannel( channel: channel, - child: Scaffold( - body: StreamMessageComposer(), - ), + child: Scaffold(body: StreamMessageComposer()), ), ), ), ); + } - // wait for the initial state to be rendered. - await tester.pumpAndSettle(); + Message ownMessage() { + return Message( + id: 'msg-1', + createdAt: DateTime.timestamp(), + user: User(id: 'user-id'), + ); + } - expect(find.text('Slow mode, wait 10s\u2026'), findsOneWidget); + testWidgets( + 'shows slow mode UI when the current user has recently sent a message', + (tester) async { + // The user sent a message before the composer mounts — channel cooldown + // is already active. + channelState.addNewMessage(ownMessage()); + + await pumpComposer(tester); + await tester.pumpAndSettle(); - // The text field is locked while slow mode is active. - final textField = tester.widget(find.byType(TextField)); - expect(textField.enabled, isFalse); + expect(find.text('Slow mode, wait ${cooldownSeconds}s…'), findsOneWidget); - // The trailing button shows the remaining cooldown instead of send / mic. - expect(find.text('10'), findsOneWidget); - }, - ); + final inputField = tester.widget( + find.byType(StreamMessageComposerInputField), + ); + + // Composer input field is locked while slow mode is active. + expect(inputField.enabled, isFalse); + + final attachmentButton = tester.widget( + find.descendant( + of: find.byType(DefaultStreamMessageComposerLeading), + matching: find.byType(StreamButton), + ), + ); + + // Attachment picker button is disabled while slow mode is active. + expect(attachmentButton.props.onPressed, isNull); + + // Trailing button shows the remaining cooldown instead of send / mic. + expect(find.text('$cooldownSeconds'), findsOneWidget); + }, + ); + + testWidgets( + 'does not show slow mode UI when the current user has not sent recently', + (tester) async { + // No prior send — channel cooldown is not active on mount. + await pumpComposer(tester); + await tester.pumpAndSettle(); + + expect(find.text('Slow mode, wait ${cooldownSeconds}s…'), findsNothing); + + final inputField = tester.widget( + find.byType(StreamMessageComposerInputField), + ); + + // Composer input field is enabled when slow mode is not active. + expect(inputField.enabled, isTrue); + + final attachmentButton = tester.widget( + find.descendant( + of: find.byType(DefaultStreamMessageComposerLeading), + matching: find.byType(StreamButton), + ), + ); + + // Attachment picker button is enabled when slow mode is not active. + expect(attachmentButton.props.onPressed, isNotNull); + }, + ); + + testWidgets( + 'starts cooldown when a sibling composer sends a message (e.g. thread composer)', + (tester) async { + await pumpComposer(tester); + await tester.pumpAndSettle(); + + // Before a message is added, no cooldown UI. + expect(find.text('Slow mode, wait ${cooldownSeconds}s…'), findsNothing); + + // Simulate a sibling composer (e.g. thread composer) adding a message + // to channel state. + channelState.addNewMessage(ownMessage()); + await tester.pumpAndSettle(); + + expect(find.text('Slow mode, wait ${cooldownSeconds}s…'), findsOneWidget); + }, + ); + }); group('MessageInput keyboard interactions', () { final client = MockClient(); diff --git a/packages/stream_chat_flutter/test/src/mocks.dart b/packages/stream_chat_flutter/test/src/mocks.dart index 1a42d0a948..72dd48ef43 100644 --- a/packages/stream_chat_flutter/test/src/mocks.dart +++ b/packages/stream_chat_flutter/test/src/mocks.dart @@ -27,6 +27,7 @@ class MockChannel extends Mock implements Channel { when(() => createDraft(any())).thenAnswer((_) async => CreateDraftResponse()); when(deleteDraft).thenAnswer((_) async => EmptyResponse()); when(() => deleteDraft(parentId: any(named: 'parentId'))).thenAnswer((_) async => EmptyResponse()); + when(() => currentUserLastMessageAtStream).thenAnswer((_) => Stream.value(null)); } @override