fix(ui): fix stopTyping unhandled exceptions when network is off or spotty. (#1296)

* feat(llc, ui): Introduce `keyStrokeHandler` to properly handle keyStrokes.

Signed-off-by: xsahil03x <[email protected]>

* chore(ui): update CHANGELOG.md

Signed-off-by: xsahil03x <[email protected]>

* test(llc): add key_stroke_handler_test.dart

Signed-off-by: xsahil03x <[email protected]>
This commit is contained in:
Sahil Kumar
2022-08-05 16:57:32 +02:00
committed by GitHub
parent b3a7ca2897
commit bd1876c159
8 changed files with 352 additions and 149 deletions
+125 -135
View File
@@ -8,6 +8,10 @@ import 'package:stream_chat/src/client/retry_queue.dart';
import 'package:stream_chat/src/core/util/utils.dart'; import 'package:stream_chat/src/core/util/utils.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
/// The maximum time the incoming [Event.typingStart] event is valid before a
/// [Event.typingStop] event is emitted automatically.
const incomingTypingStartEventTimeout = 7;
/// Class that manages a specific channel. /// Class that manages a specific channel.
/// ///
/// #### Channel name /// #### Channel name
@@ -1493,36 +1497,38 @@ class Channel {
) )
.where((e) => e.cid == cid); .where((e) => e.cid == cid);
DateTime? _lastTypingEvent; late final _keyStrokeHandler = KeyStrokeHandler(
onStartTyping: startTyping,
onStopTyping: stopTyping,
);
/// First of the [EventType.typingStart] and [EventType.typingStop] events /// Sends the [Event.typingStart] event and schedules a timer to invoke the
/// based on the users keystrokes. Call this on every keystroke. /// [Event.typingStop] event.
///
/// This is meant to be called every time the user presses a key.
Future<void> keyStroke([String? parentId]) async { Future<void> keyStroke([String? parentId]) async {
if (config?.typingEvents == false) { if (config?.typingEvents == false) return;
return;
}
client.logger.info('start typing'); client.logger.info('KeyStroke received');
final now = DateTime.now(); return _keyStrokeHandler(parentId);
if (_lastTypingEvent == null ||
now.difference(_lastTypingEvent!).inSeconds >= 2) {
_lastTypingEvent = now;
await sendEvent(Event(
type: EventType.typingStart,
parentId: parentId,
));
}
} }
/// Sets last typing to null and sends the typing.stop event. /// Sends the [EventType.typingStart] event.
Future<void> startTyping([String? parentId]) async {
if (config?.typingEvents == false) return;
client.logger.info('start typing');
await sendEvent(Event(
type: EventType.typingStart,
parentId: parentId,
));
}
/// Sends the [EventType.typingStop] event.
Future<void> stopTyping([String? parentId]) async { Future<void> stopTyping([String? parentId]) async {
if (config?.typingEvents == false) { if (config?.typingEvents == false) return;
return;
}
client.logger.info('stop typing'); client.logger.info('stop typing');
_lastTypingEvent = null;
await sendEvent(Event( await sendEvent(Event(
type: EventType.typingStop, type: EventType.typingStop,
parentId: parentId, parentId: parentId,
@@ -1532,6 +1538,7 @@ class Channel {
/// Call this method to dispose the channel client. /// Call this method to dispose the channel client.
void dispose() { void dispose() {
state?.dispose(); state?.dispose();
_keyStrokeHandler.cancel();
} }
void _checkInitialized() { void _checkInitialized() {
@@ -1593,9 +1600,9 @@ class ChannelClientState {
_listenMemberUnbanned(); _listenMemberUnbanned();
_startCleaning(); _startCleaningStaleTypingEvents();
_startCleaningPinnedMessages(); _startCleaningStalePinnedMessages();
_channel._client.chatPersistenceClient _channel._client.chatPersistenceClient
?.getChannelThreads(_channel.cid!) ?.getChannelThreads(_channel.cid!)
@@ -1613,7 +1620,8 @@ class ChannelClientState {
}); });
} }
final _subscriptions = <StreamSubscription>[]; final Channel _channel;
final _subscriptions = CompositeSubscription();
void _checkExpiredAttachmentMessages(ChannelState channelState) async { void _checkExpiredAttachmentMessages(ChannelState channelState) async {
final expiredAttachmentMessagesId = channelState.messages final expiredAttachmentMessagesId = channelState.messages
@@ -1786,20 +1794,15 @@ class ChannelClientState {
/// Retry failed message. /// Retry failed message.
Future<void> retryFailedMessages() async { Future<void> retryFailedMessages() async {
final failedMessages = final failedMessages = [...messages, ...threads.values.expand((v) => v)]
<Message>[...messages, ...threads.values.expand((v) => v)] .where(
.where( (message) =>
(message) => message.status != MessageSendingStatus.sent &&
message.status != MessageSendingStatus.sent && message.createdAt.isBefore(
message.createdAt.isBefore( DateTime.now().subtract(const Duration(seconds: 5)),
DateTime.now().subtract( ),
const Duration( )
seconds: 5, .toList();
),
),
),
)
.toList();
_retryQueue.add(failedMessages); _retryQueue.add(failedMessages);
} }
@@ -1986,12 +1989,7 @@ class ChannelClientState {
} }
_subscriptions.add( _subscriptions.add(
_channel _channel.on(EventType.messageRead, EventType.notificationMarkRead).listen(
.on(
EventType.messageRead,
EventType.notificationMarkRead,
)
.listen(
(event) { (event) {
final readList = List<Read>.from(_channelState.read ?? []); final readList = List<Read>.from(_channelState.read ?? []);
final userReadIndex = final userReadIndex =
@@ -2252,34 +2250,29 @@ class ChannelClientState {
); );
} }
/// Channel related typing users last value.
Map<User, Event> get typingEvents => _typingEventsController.value;
/// Channel related typing users stream. /// Channel related typing users stream.
Stream<Map<User, Event>> get typingEventsStream => Stream<Map<User, Event>> get typingEventsStream =>
_typingEventsController.stream; _typingEventsController.stream;
final BehaviorSubject<Map<User, Event>> _typingEventsController = /// Channel related typing users last value.
BehaviorSubject.seeded({}); Map<User, Event> get typingEvents => _typingEventsController.value;
final _typingEventsController = BehaviorSubject.seeded(<User, Event>{});
final Channel _channel;
final Map<User, Event> _typings = {};
void _listenTypingEvents() { void _listenTypingEvents() {
if (_channelState.channel?.config.typingEvents == false) { if (_channelState.channel?.config.typingEvents == false) return;
return;
} final currentUser = _channel.client.state.currentUser;
if (currentUser == null) return;
_subscriptions _subscriptions
..add( ..add(
_channel.on(EventType.typingStart).listen( _channel.on(EventType.typingStart).listen(
(event) { (event) {
if (event.user != null) { final user = event.user;
final user = event.user!; if (user != null && user.id != currentUser.id) {
if (user.id != _channel.client.state.currentUser?.id) { final events = {...typingEvents};
_typings[user] = event; events[user] = event;
_typingEventsController.add(_typings); _typingEventsController.add(events);
}
} }
}, },
), ),
@@ -2287,112 +2280,109 @@ class ChannelClientState {
..add( ..add(
_channel.on(EventType.typingStop).listen( _channel.on(EventType.typingStop).listen(
(event) { (event) {
if (event.user != null) { final user = event.user;
final user = event.user!; if (user != null && user.id != currentUser.id) {
if (user.id != _channel.client.state.currentUser?.id) { final events = {...typingEvents}..remove(user);
_typings.remove(event.user); _typingEventsController.add(events);
_typingEventsController.add(_typings);
}
} }
}, },
), ),
) )
..add( ..add(
_channel _channel.on().where((event) {
.on() final user = event.user;
.where((event) => if (user == null) return false;
event.user != null && return members.any((m) => m.userId == user.id);
members.any((m) => m.userId == event.user!.id)) }).listen(
.listen(
(event) { (event) {
final newMembers = List<Member>.from(members); final newMembers = List<Member>.from(members);
final oldMemberIndex = final oldMemberIndex =
newMembers.indexWhere((m) => m.userId == event.user!.id); newMembers.indexWhere((m) => m.userId == event.user!.id);
if (oldMemberIndex > -1) { if (oldMemberIndex > -1) {
final oldMember = newMembers.removeAt(oldMemberIndex); final oldMember = newMembers.removeAt(oldMemberIndex);
updateChannelState(ChannelState( updateChannelState(
members: [ ChannelState(
...newMembers, members: [
oldMember.copyWith( ...newMembers,
user: event.user, oldMember.copyWith(
), user: event.user,
], ),
)); ],
),
);
} }
}, },
), ),
); );
} }
Timer? _cleaningTimer; Timer? _staleTypingEventsCleanerTimer;
void _startCleaning() { // Checks and removes stale typing events that were not explicitly stopped by
if (_channelState.channel?.config.typingEvents == false) { // the sender due to technical difficulties. e.g. process death, loss of
return; // Internet connection or custom implementation.
} void _startCleaningStaleTypingEvents() {
if (_channelState.channel?.config.typingEvents == false) return;
_cleaningTimer = Timer.periodic(const Duration(seconds: 1), (_) { _staleTypingEventsCleanerTimer = Timer.periodic(
final now = DateTime.now(); const Duration(seconds: 1),
(_) {
if (_channel._lastTypingEvent != null && final now = DateTime.now();
now.difference(_channel._lastTypingEvent!).inSeconds > 1) { typingEvents.forEach((user, event) {
_channel.stopTyping(); if (now.difference(event.createdAt).inSeconds >
} incomingTypingStartEventTimeout) {
_channel.client.handleEvent(
_clean(); Event(
}); type: EventType.typingStop,
user: user,
cid: _channel.cid,
parentId: event.parentId,
),
);
}
});
},
);
} }
late Timer _pinnedMessagesTimer; Timer? _stalePinnedMessagesCleanerTimer;
void _startCleaningPinnedMessages() { // Checks and removes stale pinned messages that are not valid anymore.
_pinnedMessagesTimer = Timer.periodic(const Duration(seconds: 30), (_) { void _startCleaningStalePinnedMessages() {
final now = DateTime.now(); _stalePinnedMessagesCleanerTimer = Timer.periodic(
var expiredMessages = channelState.pinnedMessages const Duration(seconds: 30),
?.where((m) => m.pinExpires?.isBefore(now) == true) (_) {
.toList(); final now = DateTime.now();
if (expiredMessages != null && expiredMessages.isNotEmpty) { var expiredMessages = channelState.pinnedMessages
expiredMessages = expiredMessages ?.where((m) => m.pinExpires?.isBefore(now) == true)
.map((m) => m.copyWith(
pinExpires: null,
pinned: false,
))
.toList(); .toList();
if (expiredMessages != null && expiredMessages.isNotEmpty) {
expiredMessages = expiredMessages
.map((m) => m.copyWith(
pinExpires: null,
pinned: false,
))
.toList();
updateChannelState(_channelState.copyWith( updateChannelState(_channelState.copyWith(
pinnedMessages: pinnedMessages.where(_pinIsValid).toList(), pinnedMessages: pinnedMessages.where(_pinIsValid).toList(),
messages: expiredMessages, messages: expiredMessages,
)); ));
} }
}); },
} );
void _clean() {
final now = DateTime.now();
_typings.forEach((user, event) {
if (now.difference(event.createdAt).inSeconds > 7) {
_channel.client.handleEvent(
Event(
type: EventType.typingStop,
user: user,
cid: _channel.cid,
parentId: event.parentId,
),
);
}
});
} }
/// Call this method to dispose this object. /// Call this method to dispose this object.
void dispose() { void dispose() {
_debouncedUpdatePersistenceChannelState.cancel(); _debouncedUpdatePersistenceChannelState.cancel();
_retryQueue.dispose(); _retryQueue.dispose();
_subscriptions.forEach((s) => s.cancel()); _subscriptions.cancel();
_channelStateController.close(); _channelStateController.close();
_isUpToDateController.close(); _isUpToDateController.close();
_threadsController.close(); _threadsController.close();
_cleaningTimer?.cancel(); _staleTypingEventsCleanerTimer?.cancel();
_pinnedMessagesTimer.cancel(); _stalePinnedMessagesCleanerTimer?.cancel();
_typingEventsController.close(); _typingEventsController.close();
} }
} }
@@ -0,0 +1,114 @@
import 'dart:async';
/// A class that manages buffering typing events and call [onTypingStarted] and
/// [onTypingStopped] accordingly in a timed manner.
///
/// This class is used by [Channel] to manage typing events.
class KeyStrokeHandler {
/// Creates a new instance of [KeyStrokeHandler].
KeyStrokeHandler({
this.startTypingEventTimeout = 1,
this.startTypingResendInterval = 3,
required this.onStartTyping,
required this.onStopTyping,
});
/// The number of seconds from the last [onStartTyping] callback until
/// the [onStopTyping] callback is automatically invoked.
final int startTypingEventTimeout;
/// The number of seconds after the last [onStartTyping] callback before
/// the [onStartTyping] callback is automatically invoked again.
final int startTypingResendInterval;
/// Called when a `typingStart` event needs to be send.
final Future<void> Function([String? parentId]) onStartTyping;
/// Called when a `typingStop` event needs to be send.
final Future<void> Function([String? parentId]) onStopTyping;
Timer? _keyStrokeTimer;
String? _currentParentId;
DateTime? _lastTypingEvent;
Completer<void>? _keyStrokeCompleter;
Future<void> _startTyping(String? parentId) {
_currentParentId = parentId;
_lastTypingEvent = DateTime.now();
return onStartTyping(parentId);
}
Future<void> _stopTyping(String? parentId) {
_currentParentId = null;
_lastTypingEvent = null;
return onStopTyping(parentId);
}
// Completes the key stroke completer if it is not yet completed.
void _completeKeyStrokeCompleterIfRequired() {
final completer = _keyStrokeCompleter;
if (completer != null && !completer.isCompleted) completer.complete();
}
// Completes the completer if available and not yet completed then creates a
// new completer and returns it.
Completer<void> _resetKeyStrokeCompleter() {
_completeKeyStrokeCompleterIfRequired();
return _keyStrokeCompleter = Completer<void>();
}
// Cancels the key stroke timer if it is running.
void _cancelKeyStrokeTimer() {
_keyStrokeTimer?.cancel();
_keyStrokeTimer = null;
}
/// Cancels the handler and stops the typing event.
void cancel() {
// If the user is typing, stop typing.
// This is needed to prevent the user from being stuck in typing mode.
if (_lastTypingEvent != null) {
// We don't need to handle the error here
_stopTyping(_currentParentId).catchError((_) {});
}
_cancelKeyStrokeTimer();
_completeKeyStrokeCompleterIfRequired();
}
/// Invokes the [onStartTyping] callback and schedules a timer to invoke the
/// [onStopTyping] callback.
///
/// This is meant to be called every time the user presses a key. The method
/// will manage requests and timer as needed.
Future<void> call([String? parentId]) async {
final completer = _resetKeyStrokeCompleter();
_cancelKeyStrokeTimer();
_keyStrokeTimer = Timer(Duration(seconds: startTypingEventTimeout), () {
_stopTyping(parentId).then((_) {
if (completer.isCompleted) return;
completer.complete();
}).onError((error, stackTrace) {
if (completer.isCompleted) return;
completer.completeError(error!, stackTrace);
});
});
// If the user is typing too long, it should call [onStartTyping] again.
final now = DateTime.now();
final lastTypingEvent = _lastTypingEvent;
if (lastTypingEvent == null ||
now.difference(lastTypingEvent).inMilliseconds >
// startTypingResendInterval in milliseconds
startTypingResendInterval * 1000) {
_startTyping(parentId).onError((error, stackTrace) {
_cancelKeyStrokeTimer();
if (completer.isCompleted) return;
completer.completeError(error!, stackTrace);
});
}
return completer.future;
}
}
@@ -38,6 +38,7 @@ export './src/permission_type.dart';
export './src/ws/connection_status.dart'; export './src/ws/connection_status.dart';
export 'src/client/channel.dart'; export 'src/client/channel.dart';
export 'src/client/client.dart'; export 'src/client/client.dart';
export 'src/client/key_stroke_handler.dart';
export 'src/core/api/attachment_file_uploader.dart' show AttachmentFileUploader; export 'src/core/api/attachment_file_uploader.dart' show AttachmentFileUploader;
export 'src/core/api/requests.dart'; export 'src/core/api/requests.dart';
export 'src/core/api/responses.dart'; export 'src/core/api/responses.dart';
@@ -2658,16 +2658,22 @@ void main() {
}); });
test( test(
'''should send `typingStart` event if there is not already a typingEvent or the difference between the two is >= 2 seconds''', '''should send `typingStart` event if there is not already a typingEvent or the difference between the two is > 3 seconds''',
() async { () async {
final typingEvent = Event(type: EventType.typingStart); final startTypingEvent = Event(type: EventType.typingStart);
final stopTypingEvent = Event(type: EventType.typingStop);
when(() => channel.config?.typingEvents).thenReturn(true); when(() => channel.config?.typingEvents).thenReturn(true);
when(() => client.sendEvent( when(() => client.sendEvent(
channelId, channelId,
channelType, channelType,
any(that: isSameEventAs(typingEvent)), any(that: isSameEventAs(startTypingEvent)),
)).thenAnswer((_) async => EmptyResponse());
when(() => client.sendEvent(
channelId,
channelType,
any(that: isSameEventAs(stopTypingEvent)),
)).thenAnswer((_) async => EmptyResponse()); )).thenAnswer((_) async => EmptyResponse());
await channel.keyStroke(); await channel.keyStroke();
@@ -2675,7 +2681,12 @@ void main() {
verify(() => client.sendEvent( verify(() => client.sendEvent(
channelId, channelId,
channelType, channelType,
any(that: isSameEventAs(typingEvent)), any(that: isSameEventAs(startTypingEvent)),
)).called(1);
verify(() => client.sendEvent(
channelId,
channelType,
any(that: isSameEventAs(stopTypingEvent)),
)).called(1); )).called(1);
}, },
); );
@@ -2688,7 +2699,7 @@ void main() {
final typingStopEvent = Event(type: EventType.typingStop); final typingStopEvent = Event(type: EventType.typingStop);
await channel.keyStroke(); await channel.stopTyping();
verifyNever(() => client.sendEvent( verifyNever(() => client.sendEvent(
channelId, channelId,
@@ -0,0 +1,73 @@
// ignore_for_file: avoid_redundant_argument_values
import 'package:mocktail/mocktail.dart';
import 'package:stream_chat/stream_chat.dart';
import 'package:test/test.dart';
mixin OnKeyStrokeEvent {
Future<void> call([String? parentId]);
}
class OnStartTyping extends Mock implements OnKeyStrokeEvent {}
class OnStopTyping extends Mock implements OnKeyStrokeEvent {}
void main() {
final onStartTyping = OnStartTyping();
final onStopTyping = OnStopTyping();
late KeyStrokeHandler keyStrokeHandler;
const startTypingEventTimeout = 1;
const startTypingResendInterval = 2;
setUp(() {
when(() => onStartTyping(any())).thenAnswer((_) => Future.value());
when(() => onStopTyping(any())).thenAnswer((_) => Future.value());
keyStrokeHandler = KeyStrokeHandler(
onStartTyping: onStartTyping,
onStopTyping: onStopTyping,
startTypingEventTimeout: startTypingEventTimeout,
startTypingResendInterval: startTypingResendInterval,
);
});
tearDown(() {
keyStrokeHandler.cancel();
clearInteractions(onStartTyping);
clearInteractions(onStopTyping);
});
group('call', () {
test('should work fine', () {
expect(keyStrokeHandler.call(), completes);
});
test('should call onStartTyping', () async {
keyStrokeHandler.call();
verify(() => onStartTyping(any())).called(1);
});
test('should call onStopTyping', () async {
keyStrokeHandler
..call()
..cancel();
verify(() => onStopTyping(any())).called(1);
});
test('should call onStartTyping after startTypingResendInterval', () async {
final watch = Stopwatch()..start();
while (watch.elapsed.inSeconds <= startTypingResendInterval) {
keyStrokeHandler.call();
}
watch.stop();
verify(() => onStartTyping(any())).called(2);
});
test('should call onStopTyping after startTypingEventTimeout', () async {
keyStrokeHandler.call();
await Future.delayed(const Duration(seconds: startTypingEventTimeout));
verify(() => onStopTyping(any())).called(1);
});
});
}
@@ -1,3 +1,10 @@
## Upcoming
🐞 Fixed
- [[#882]](https://github.com/GetStream/stream-chat-flutter/issues/882) Lots of unhandled exceptions
when network is off or spotty.
## 4.4.1 ## 4.4.1
🐞 Fixed 🐞 Fixed
@@ -824,9 +824,14 @@ class MessageInputState extends State<MessageInput> {
value = value.trim(); value = value.trim();
final channel = StreamChannel.of(context).channel; final channel = StreamChannel.of(context).channel;
if (value.isNotEmpty) { if (value.isNotEmpty &&
// ignore: no-empty-block channel.ownCapabilities.contains(PermissionType.sendTypingEvents)) {
channel.keyStroke(widget.parentMessage?.id).catchError((e) {}); // Notify the server that the user started typing.
channel.keyStroke(widget.parentMessage?.id).onError(
(error, stackTrace) {
widget.onError?.call(error!, stackTrace);
},
);
} }
var actionsLength = widget.actions.length; var actionsLength = widget.actions.length;
@@ -945,12 +945,14 @@ class StreamMessageInputState extends State<StreamMessageInput>
value = value.trim(); value = value.trim();
final channel = StreamChannel.of(context).channel; final channel = StreamChannel.of(context).channel;
if (channel.ownCapabilities.contains(PermissionType.sendTypingEvents) && if (value.isNotEmpty &&
value.isNotEmpty) { channel.ownCapabilities.contains(PermissionType.sendTypingEvents)) {
channel // Notify the server that the user started typing.
.keyStroke(_effectiveController.value.parentId) channel.keyStroke(_effectiveController.message.parentId).onError(
// ignore: no-empty-block (error, stackTrace) {
.catchError((e) {}); widget.onError?.call(error!, stackTrace);
},
);
} }
var actionsLength = widget.actions.length; var actionsLength = widget.actions.length;