diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 82c41a7d5..49a70c857 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -55,6 +55,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 3974dede6..993f720ea 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,20 +364,38 @@ class Channel { return state!.channelStateStream.map((cs) => cs.channel?.lastMessageAt); } - DateTime? _currentUserLastMessageAt(List? messages) { + DateTime? _currentUserLastMessageAt({ + required List? messages, + required Map> threads, + }) { 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; - }); + bool ours(Message m) => !m.isEphemeral && m.user?.id == currentUserId; - return validMessages?.map((m) => m.createdAt).max; + 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(); @@ -382,22 +403,31 @@ class Channel { // from the current user. if (!state!.isUpToDate) return null; + final threads = state!.threads; final messages = state!.channelState.messages; - return _currentUserLastMessageAt(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?, DateTime?>( + return CombineLatestStream.combine3( state!.isUpToDateStream, - state!.channelStateStream.map((state) => state.messages), - (isUpToDate, messages) { + 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); + + return _currentUserLastMessageAt(messages: messages, threads: threads); }, ); } diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index e04c6799b..99dedca49 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -8644,6 +8644,188 @@ 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)); + }, + ); + + 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', () { late Channel channel; diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index b8e76a472..559bd04f7 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -35,6 +35,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. 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 a52f36d0c..caf94537b 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 6a35eeaf5..f09a977d9 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, + ), ), ), ), 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 60943f9e9..e786ca509 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 @@ -591,6 +591,7 @@ class DefaultStreamMessageComposerState extends State? _lastSentAtSubscription; StreamSubscription? _draftStreamSubscription; StreamSubscription? _messageUpdatedSubscription; StreamSubscription? _messageDeletedSubscription; @@ -631,9 +632,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) { @@ -1543,6 +1548,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 1a42d0a94..72dd48ef4 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