Skip to content

Commit 1ede309

Browse files
authored
refactor(llc, ui): move slowMode logic inside StreamMessageInputController (#2142)
* refactor(llc, ui): move slowMode logic inside StreamMessageInputController * chore: revert permissionState changes * fix: fix possible context related exception * test: fix tests * test: fix more tests * chore: convert the remainingCooldown getter into a method. * chore: remove case check from _isEditing
1 parent 1539002 commit 1ede309

File tree

7 files changed

+182
-65
lines changed

7 files changed

+182
-65
lines changed

packages/stream_chat/lib/src/client/channel.dart

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,8 +258,34 @@ class Channel {
258258
return state!.channelStateStream.map((cs) => cs.channel?.cooldown ?? 0);
259259
}
260260

261+
/// Remaining cooldown duration in seconds for the channel.
262+
///
263+
/// Returns 0 if there is no cooldown active.
264+
int getRemainingCooldown() {
265+
_checkInitialized();
266+
267+
final cooldownDuration = cooldown;
268+
if (cooldownDuration <= 0) return 0;
269+
270+
final userLastMessageAt = currentUserLastMessageAt;
271+
if (userLastMessageAt == null) return 0;
272+
273+
if (ownCapabilities.contains(PermissionType.skipSlowMode)) return 0;
274+
275+
final currentTime = DateTime.timestamp();
276+
final elapsedTime = currentTime.difference(userLastMessageAt).inSeconds;
277+
278+
return max(0, cooldownDuration - elapsedTime);
279+
}
280+
261281
/// Stores time at which cooldown was started
262-
DateTime? cooldownStartedAt;
282+
@Deprecated(
283+
"Use a combination of 'remainingCooldown' and 'currentUserLastMessageAt'",
284+
)
285+
DateTime? get cooldownStartedAt {
286+
if (getRemainingCooldown() <= 0) return null;
287+
return currentUserLastMessageAt;
288+
}
263289

264290
/// Channel creation date.
265291
DateTime? get createdAt {
@@ -285,6 +311,47 @@ class Channel {
285311
return state!.channelStateStream.map((cs) => cs.channel?.lastMessageAt);
286312
}
287313

314+
DateTime? _currentUserLastMessageAt(List<Message>? messages) {
315+
final currentUserId = client.state.currentUser?.id;
316+
if (currentUserId == null) return null;
317+
318+
final validMessages = messages?.where((message) {
319+
if (message.isEphemeral) return false;
320+
if (message.user?.id != currentUserId) return false;
321+
return true;
322+
});
323+
324+
return validMessages?.map((m) => m.createdAt).max;
325+
}
326+
327+
/// The date of the last message sent by the current user.
328+
DateTime? get currentUserLastMessageAt {
329+
_checkInitialized();
330+
331+
// If the channel is not up to date, we can't rely on the last message
332+
// from the current user.
333+
if (!state!.isUpToDate) return null;
334+
335+
final messages = state!.channelState.messages;
336+
return _currentUserLastMessageAt(messages);
337+
}
338+
339+
/// The date of the last message sent by the current user as a stream.
340+
Stream<DateTime?> get currentUserLastMessageAtStream {
341+
_checkInitialized();
342+
343+
return CombineLatestStream.combine2<bool, List<Message>?, DateTime?>(
344+
state!.isUpToDateStream,
345+
state!.channelStateStream.map((state) => state.messages),
346+
(isUpToDate, messages) {
347+
// If the channel is not up to date, we can't rely on the last message
348+
// from the current user.
349+
if (!isUpToDate) return null;
350+
return _currentUserLastMessageAt(messages);
351+
},
352+
);
353+
}
354+
288355
/// Channel updated date.
289356
DateTime? get updatedAt {
290357
_checkInitialized();
@@ -635,7 +702,7 @@ class Channel {
635702
);
636703

637704
state!.updateMessage(sentMessage);
638-
if (cooldown > 0) cooldownStartedAt = DateTime.now();
705+
639706
return response;
640707
} catch (e) {
641708
if (e is StreamChatNetworkError && e.isRetriable) {

packages/stream_chat/lib/src/permission_type.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ class PermissionType {
3232
/// Allows to enable/disable slow mode in the channel
3333
static const String setChannelCooldown = 'set-channel-cooldown';
3434

35+
/// User has the ability to skip slow mode when it's active.
36+
static const String skipSlowMode = 'skip-slow-mode';
37+
3538
/// User has RemoveOwnChannelMembership or UpdateChannelMembers permission
3639
static const String leaveChannel = 'leave-channel';
3740

packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart

Lines changed: 23 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -509,12 +509,6 @@ class StreamMessageInputState extends State<StreamMessageInput>
509509
_effectiveController
510510
..removeListener(_onChangedDebounced)
511511
..addListener(_onChangedDebounced);
512-
513-
// Call the listener once to make sure the initial state is reflected
514-
// correctly in the UI.
515-
_onChangedDebounced.call();
516-
517-
if (!_isEditing && _timeOut <= 0) _startSlowMode();
518512
}
519513

520514
@override
@@ -527,6 +521,20 @@ class StreamMessageInputState extends State<StreamMessageInput>
527521
_initialiseEffectiveController();
528522
}
529523
_effectiveFocusNode.addListener(_focusNodeListener);
524+
525+
WidgetsBinding.instance.endOfFrame.then((_) {
526+
if (!mounted) return;
527+
528+
// Call the listener once to make sure the initial state is reflected
529+
// correctly in the UI.
530+
_onChangedDebounced.call();
531+
532+
// Resumes the cooldown if the channel has currently an active cooldown.
533+
if (!_isEditing) {
534+
final channel = StreamChannel.of(context).channel;
535+
_effectiveController.startCooldown(channel.getRemainingCooldown());
536+
}
537+
});
530538
}
531539

532540
@override
@@ -593,38 +601,8 @@ class StreamMessageInputState extends State<StreamMessageInput>
593601
// ignore: no-empty-block
594602
void _focusNodeListener() {}
595603

596-
int _timeOut = 0;
597-
Timer? _slowModeTimer;
598-
599604
PermissionState? _permissionState;
600605

601-
void _startSlowMode() {
602-
if (!mounted) {
603-
return;
604-
}
605-
final channel = StreamChannel.of(context).channel;
606-
final cooldownStartedAt = channel.cooldownStartedAt;
607-
if (cooldownStartedAt != null) {
608-
final diff = DateTime.now().difference(cooldownStartedAt).inSeconds;
609-
if (diff < channel.cooldown) {
610-
_timeOut = channel.cooldown - diff;
611-
if (_timeOut > 0) {
612-
_slowModeTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
613-
if (_timeOut == 0) {
614-
timer.cancel();
615-
} else {
616-
if (mounted) {
617-
setState(() => _timeOut -= 1);
618-
}
619-
}
620-
});
621-
}
622-
}
623-
}
624-
}
625-
626-
void _stopSlowMode() => _slowModeTimer?.cancel();
627-
628606
@override
629607
Widget build(BuildContext context) {
630608
final channel = StreamChannel.of(context).channel;
@@ -868,7 +846,7 @@ class StreamMessageInputState extends State<StreamMessageInput>
868846

869847
return StreamMessageSendButton(
870848
onSendMessage: sendMessage,
871-
timeOut: _timeOut,
849+
timeOut: _effectiveController.cooldownTimeOut,
872850
isIdle: !widget.validator(_effectiveController.message),
873851
idleSendButton: widget.idleSendButton,
874852
activeSendButton: widget.activeSendButton,
@@ -1265,7 +1243,7 @@ class StreamMessageInputState extends State<StreamMessageInput>
12651243
hintType = HintType.searchGif;
12661244
} else if (_effectiveController.attachments.isNotEmpty) {
12671245
hintType = HintType.addACommentOrSend;
1268-
} else if (_timeOut != 0) {
1246+
} else if (_effectiveController.cooldownTimeOut > 0) {
12691247
hintType = HintType.slowModeOn;
12701248
} else {
12711249
hintType = HintType.writeAMessage;
@@ -1479,7 +1457,7 @@ class StreamMessageInputState extends State<StreamMessageInput>
14791457

14801458
/// Sends the current message
14811459
Future<void> sendMessage() async {
1482-
if (_timeOut > 0 ||
1460+
if (_effectiveController.cooldownTimeOut > 0 ||
14831461
(_effectiveController.text.trim().isEmpty &&
14841462
_effectiveController.attachments.isEmpty)) {
14851463
return;
@@ -1562,7 +1540,12 @@ class StreamMessageInputState extends State<StreamMessageInput>
15621540
_effectiveController.message = message;
15631541
}
15641542

1565-
_startSlowMode();
1543+
// We don't want to start the cooldown if an already sent message is
1544+
// being edited.
1545+
if (!_isEditing) {
1546+
_effectiveController.startCooldown(channel.getRemainingCooldown());
1547+
}
1548+
15661549
widget.onMessageSent?.call(resp.message);
15671550
} catch (e, stk) {
15681551
if (widget.onError != null) {
@@ -1595,7 +1578,6 @@ class StreamMessageInputState extends State<StreamMessageInput>
15951578
_controller?.dispose();
15961579
_effectiveFocusNode.removeListener(_focusNodeListener);
15971580
_focusNode?.dispose();
1598-
_stopSlowMode();
15991581
_onChangedDebounced.cancel();
16001582
_audioRecorderController.dispose();
16011583
WidgetsBinding.instance.removeObserver(this);

packages/stream_chat_flutter/test/src/bottom_sheets/edit_message_sheet_test.dart

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'package:alchemist/alchemist.dart';
22
import 'package:flutter/material.dart';
33
import 'package:flutter/services.dart';
44
import 'package:flutter_test/flutter_test.dart';
5+
import 'package:mocktail/mocktail.dart';
56
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
67

78
import '../material_app_wrapper.dart';
@@ -33,6 +34,9 @@ void main() {
3334
});
3435

3536
testWidgets('appears on tap', (tester) async {
37+
final channel = MockChannel();
38+
when(channel.getRemainingCooldown).thenReturn(0);
39+
3640
await tester.pumpWidget(
3741
MaterialApp(
3842
builder: (context, child) => StreamChat(
@@ -48,7 +52,7 @@ void main() {
4852
onPressed: () => showModalBottomSheet(
4953
context: context,
5054
builder: (_) => EditMessageSheet(
51-
channel: MockChannel(),
55+
channel: channel,
5256
message: Message(id: 'msg123', text: 'Hello World!'),
5357
),
5458
),
@@ -72,18 +76,23 @@ void main() {
7276
'golden test for EditMessageSheet',
7377
fileName: 'edit_message_sheet_0',
7478
constraints: const BoxConstraints.tightFor(width: 300, height: 300),
75-
builder: () => MaterialAppWrapper(
76-
builder: (context, child) => StreamChat(
77-
client: MockClient(),
78-
child: child,
79-
),
80-
home: Scaffold(
81-
bottomSheet: EditMessageSheet(
82-
channel: MockChannel(),
83-
message: Message(id: 'msg123', text: 'Hello World!'),
79+
builder: () {
80+
final channel = MockChannel();
81+
when(channel.getRemainingCooldown).thenReturn(0);
82+
83+
return MaterialAppWrapper(
84+
builder: (context, child) => StreamChat(
85+
client: MockClient(),
86+
child: child,
8487
),
85-
),
86-
),
88+
home: Scaffold(
89+
bottomSheet: EditMessageSheet(
90+
channel: channel,
91+
message: Message(id: 'msg123', text: 'Hello World!'),
92+
),
93+
),
94+
);
95+
},
8796
);
8897

8998
tearDown(() {

packages/stream_chat_flutter/test/src/message_actions_modal/message_actions_modal_test.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,7 @@ void main() {
386386
when(() => client.state).thenReturn(clientState);
387387
when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id'));
388388
when(() => channel.state).thenReturn(channelState);
389+
when(channel.getRemainingCooldown).thenReturn(0);
389390

390391
final themeData = ThemeData();
391392
final streamTheme = StreamChatThemeData.fromTheme(themeData);

packages/stream_chat_flutter/test/src/message_input/message_input_test.dart

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ void main() {
2020
when(() => channel.lastMessageAt).thenReturn(lastMessageAt);
2121
when(() => channel.state).thenReturn(channelState);
2222
when(() => channel.client).thenReturn(client);
23+
when(channel.getRemainingCooldown).thenReturn(0);
2324
when(() => channel.isMuted).thenReturn(false);
2425
when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false));
2526
when(() => channel.extraDataStream).thenAnswer(
@@ -89,8 +90,7 @@ void main() {
8990
when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id'));
9091
when(() => channel.lastMessageAt).thenReturn(lastMessageAt);
9192
when(() => channel.state).thenReturn(channelState);
92-
when(() => channel.cooldown).thenReturn(10);
93-
when(() => channel.cooldownStartedAt).thenReturn(DateTime.now());
93+
when(channel.getRemainingCooldown).thenReturn(10);
9494
when(() => channel.client).thenReturn(client);
9595
when(() => channel.isMuted).thenReturn(false);
9696
when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false));
@@ -131,17 +131,21 @@ void main() {
131131
]),
132132
);
133133

134-
await tester.pumpWidget(MaterialApp(
135-
home: StreamChat(
136-
client: client,
137-
child: StreamChannel(
138-
channel: channel,
139-
child: const Scaffold(
140-
body: StreamMessageInput(),
134+
await tester.pumpWidget(
135+
MaterialApp(
136+
home: StreamChat(
137+
client: client,
138+
child: StreamChannel(
139+
channel: channel,
140+
child: const Scaffold(
141+
body: StreamMessageInput(),
142+
),
141143
),
142144
),
143145
),
144-
));
146+
);
147+
148+
await tester.pump(Duration.zero);
145149

146150
expect(find.text('Slow mode ON'), findsOneWidget);
147151
},

0 commit comments

Comments
 (0)