Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/stream_chat/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
58 changes: 44 additions & 14 deletions packages/stream_chat/lib/src/client/channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -361,43 +364,70 @@ class Channel {
return state!.channelStateStream.map((cs) => cs.channel?.lastMessageAt);
}

DateTime? _currentUserLastMessageAt(List<Message>? messages) {
DateTime? _currentUserLastMessageAt({
required List<Message>? messages,
required Map<String, List<Message>> 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();

// If the channel is not up to date, we can't rely on the last message
// 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<DateTime?> get currentUserLastMessageAtStream {
_checkInitialized();

return CombineLatestStream.combine2<bool, List<Message>?, 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);
},
);
}
Expand Down
182 changes: 182 additions & 0 deletions packages/stream_chat/test/src/client/channel_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <DateTime?>[];
final sub = ch.currentUserLastMessageAtStream.listen(emissions.add);
addTearDown(sub.cancel);

// Let the seed emission settle.
await Future<void>.delayed(Duration.zero);
final seededLast = emissions.last;

ch.state!.updateMessage(
Message(
id: 'msg-1',
createdAt: DateTime.timestamp(),
user: User(id: currentUserId),
),
);
await Future<void>.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;

Expand Down
1 change: 1 addition & 0 deletions packages/stream_chat_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
],
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
),
),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,7 @@ class DefaultStreamMessageComposerState extends State<DefaultStreamMessageCompos
..addListener(_onChangedDebounced);
}

StreamSubscription<DateTime?>? _lastSentAtSubscription;
StreamSubscription<Draft?>? _draftStreamSubscription;
StreamSubscription<Event>? _messageUpdatedSubscription;
StreamSubscription<Event>? _messageDeletedSubscription;
Expand Down Expand Up @@ -631,9 +632,14 @@ class DefaultStreamMessageComposerState extends State<DefaultStreamMessageCompos
final channel = StreamChannel.of(context).channel;
final config = StreamChatConfiguration.of(context);

// Resumes the cooldown if the channel has currently an active cooldown.
// Keeps the composer cooldown in sync with channel sends.
if (!_isEditing && channel.state != null) {
_effectiveController.startCooldown(channel.getRemainingCooldown());
_lastSentAtSubscription = channel.currentUserLastMessageAtStream.listen(
(lastMessageAt) {
final remainingCooldown = channel.getRemainingCooldown(lastMessageAt: lastMessageAt);
return _effectiveController.startCooldown(remainingCooldown);
},
);
}

// Starts listening to the draft stream for the current channel/thread.
Expand Down Expand Up @@ -1445,7 +1451,6 @@ class DefaultStreamMessageComposerState extends State<DefaultStreamMessageCompos
false => channel.sendMessage(message),
};

_effectiveController.startCooldown(channel.getRemainingCooldown());
widget.props.onMessageSent?.call(resp.message);
} catch (e, stk) {
if (widget.props.onError != null) {
Expand Down Expand Up @@ -1543,6 +1548,7 @@ class DefaultStreamMessageComposerState extends State<DefaultStreamMessageCompos
_onChangedDebounced.cancel();
_onChangedThrottled.cancel();
_audioRecorderController.dispose();
_lastSentAtSubscription?.cancel();
_draftStreamSubscription?.cancel();
_messageUpdatedSubscription?.cancel();
_messageDeletedSubscription?.cancel();
Expand Down
Loading
Loading