diff --git a/docs/docs_screenshots/pubspec.yaml b/docs/docs_screenshots/pubspec.yaml index 1de1559939..6a9fb5c822 100644 --- a/docs/docs_screenshots/pubspec.yaml +++ b/docs/docs_screenshots/pubspec.yaml @@ -22,7 +22,7 @@ dependencies: stream_core_flutter: git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 1546087d1a3dac29e824853fb2a9f05dcd2d41e6 + ref: bd6e2546fa25e2b605cefef4494e0be056d8a0dc path: packages/stream_core_flutter dev_dependencies: diff --git a/melos.yaml b/melos.yaml index 50f61c33d7..b75838b009 100644 --- a/melos.yaml +++ b/melos.yaml @@ -107,7 +107,7 @@ command: stream_core_flutter: git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 1546087d1a3dac29e824853fb2a9f05dcd2d41e6 + ref: bd6e2546fa25e2b605cefef4494e0be056d8a0dc path: packages/stream_core_flutter stream_thumbnail: ^0.2.0 synchronized: ^3.4.0 diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 68bcb4e6a9..c28372520b 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -14,6 +14,7 @@ - Fixed `Event.aiState` reading `AI_STATE_EXTERNAL_SOURCES` as `idle` instead of `checkingSources`. - Fixed `Channel.disableSlowMode` and `StreamChatClient.disableSlowdown` always failing. +- Fixed users still showing as typing after sending or editing a message, or after switching between a thread and the channel. ## 10.5.0 diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index 8a0b26cc80..e007e28c62 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -749,6 +749,8 @@ class Channel { /// Send a [message] to this channel. /// + /// Also stops the current user's typing. + /// /// If [skipPush] is true the message will not send a push notification. /// /// Waits for a [_messageAttachmentsUploadCompleter] to complete @@ -760,6 +762,8 @@ class Channel { }) async { _checkInitialized(); + _keyStrokeHandler.cancel(); + // Clean up stale error messages before sending a new message. state?.cleanUpStaleErrorMessages(); @@ -859,6 +863,8 @@ class Channel { /// Updates the [message] in this channel. /// + /// Also stops the current user's typing. + /// /// Waits for a [_messageAttachmentsUploadCompleter] to complete /// before actually updating the message. Future updateMessage( @@ -868,6 +874,8 @@ class Channel { }) async { _checkInitialized(); + _keyStrokeHandler.cancel(); + // Cancelling previous completer in case it's called again in the process // Eg. Updating the message while the previous call is in progress. _messageAttachmentsUploadCompleter.remove(message.id)?.completeError(const StreamChatError('Message cancelled')); diff --git a/packages/stream_chat/lib/src/client/channel/channel_event_handler.dart b/packages/stream_chat/lib/src/client/channel/channel_event_handler.dart index b267ad38dd..2cf32b3e4b 100644 --- a/packages/stream_chat/lib/src/client/channel/channel_event_handler.dart +++ b/packages/stream_chat/lib/src/client/channel/channel_event_handler.dart @@ -211,7 +211,7 @@ class ChannelEventHandler { final message = event.message; if (message == null) return; - return _mutations.onMessageUpdated(message); + return _mutations.onMessageUpdated(message, editor: event.user); } void _onDraftUpdated(Event event) { diff --git a/packages/stream_chat/lib/src/client/channel/channel_state_mutations.dart b/packages/stream_chat/lib/src/client/channel/channel_state_mutations.dart index 05a8c81975..e825134faf 100644 --- a/packages/stream_chat/lib/src/client/channel/channel_state_mutations.dart +++ b/packages/stream_chat/lib/src/client/channel/channel_state_mutations.dart @@ -45,9 +45,14 @@ class ChannelStateMutations { void onTypingStop(User user) => _removeTypingEvent(user); /// Adds the new [message], updating the watcher count when provided. + /// + /// Also ends the author's typing in the [message]'s channel or thread. void onMessageNew(Message message, {int? watcherCount}) { _state.addNewMessage(message); + final author = message.user; + if (author != null) _endTypingFor(author, message, at: message.createdAt); + if (watcherCount != null) { _state.updateChannelState( _state.channelState.copyWith(watcherCount: watcherCount), @@ -55,6 +60,19 @@ class ChannelStateMutations { } } + // Ends [user]'s typing in [message]'s context that started by [at]. Their + // `EventType.typingStop` may arrive late, or never. + void _endTypingFor(User user, Message message, {required DateTime at}) { + // Matched by id: the stored user may be an older snapshot. + final typing = _state.typingEvents.entries.firstWhereOrNull((it) => it.key.id == user.id); + if (typing == null) return; + + final MapEntry(key: typingUser, value: typingEvent) = typing; + final isSameContext = typingEvent.parentId == message.parentId; + final startedBefore = !typingEvent.createdAt.isAfter(at); + if (isSameContext && startedBefore) onTypingStop(typingUser); + } + /// Marks the [message] as deleted. void onMessageDeleted(Message message, {bool hardDelete = false}) { // Decrement the locally-tracked unread count for hard-deleted @@ -70,8 +88,19 @@ class ChannelStateMutations { } /// Applies the updated [message] without inserting it when absent. - void onMessageUpdated(Message message) { - return _state.updateMessage(message, upsert: false); + /// + /// A text edit also ends the [editor]'s typing in the [message]'s channel or + /// thread. + void onMessageUpdated(Message message, {User? editor}) { + _state.updateMessage(message, upsert: false); + + if (editor == null) return; + + // Not every update is an edit: reply counts, link previews, translations. + final editedAt = message.messageTextUpdatedAt; + if (editedAt == null) return; + + _endTypingFor(editor, message, at: editedAt); } /// Applies the updated [draft]. diff --git a/packages/stream_chat/lib/src/client/key_stroke_handler.dart b/packages/stream_chat/lib/src/client/key_stroke_handler.dart index 501c1f944d..17d29bb39f 100644 --- a/packages/stream_chat/lib/src/client/key_stroke_handler.dart +++ b/packages/stream_chat/lib/src/client/key_stroke_handler.dart @@ -81,7 +81,14 @@ class KeyStrokeHandler { /// /// This is meant to be called every time the user presses a key. The method /// will manage requests and timer as needed. + /// + /// Switching [parentId] stops typing under the previous one. Future call([String? parentId]) async { + if (_lastTypingEvent != null && parentId != _currentParentId) { + // ignore: no-empty-block + _stopTyping(_currentParentId).catchError((_) {}); + } + final completer = _resetKeyStrokeCompleter(); _cancelKeyStrokeTimer(); diff --git a/packages/stream_chat/test/src/client/channel/channel_event_handler_test.dart b/packages/stream_chat/test/src/client/channel/channel_event_handler_test.dart index 7247d46aae..20b78a6a52 100644 --- a/packages/stream_chat/test/src/client/channel/channel_event_handler_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_event_handler_test.dart @@ -241,6 +241,15 @@ void main() { verify(() => mutations.onMessageUpdated(message)).called(1); }); + test('message.updated passes the user who made the edit', () { + final editor = User(id: 'editor'); + final event = Event(type: EventType.messageUpdated, message: message, user: editor); + + handler.handleEvent(event); + + verify(() => mutations.onMessageUpdated(message, editor: editor)).called(1); + }); + test('message.updated ignores events without a message', () { handler.handleEvent(Event(type: EventType.messageUpdated)); diff --git a/packages/stream_chat/test/src/client/channel/channel_state_mutations_test.dart b/packages/stream_chat/test/src/client/channel/channel_state_mutations_test.dart index 15550f8a71..2ce329271f 100644 --- a/packages/stream_chat/test/src/client/channel/channel_state_mutations_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_state_mutations_test.dart @@ -38,6 +38,7 @@ void main() { state = MockChannelClientState(); when(() => client.logger).thenReturn(MockLogger()); + when(() => state.typingEvents).thenReturn({}); when(() => client.state).thenReturn(FakeClientState()); // A real channel (without an attached state) so that the capability and @@ -100,6 +101,53 @@ void main() { verifyNever(() => state.updateChannelState(any())); }); + test("onMessageNew clears its author's typing in the same channel", () { + final sentAt = DateTime.utc(2026, 10, 13, 12); + final typing = Event( + type: EventType.typingStart, + user: otherUser, + createdAt: sentAt.subtract(const Duration(seconds: 2)), + ); + when(() => state.typingEvents).thenReturn({otherUser: typing}); + + mutations.onMessageNew(message.copyWith(createdAt: sentAt)); + + expect(removeTypingEventCalls, [otherUser]); + }); + + test("onMessageNew keeps its author's typing in a thread", () { + final threadTyping = Event(type: EventType.typingStart, user: otherUser, parentId: 'parent-id'); + when(() => state.typingEvents).thenReturn({otherUser: threadTyping}); + + mutations.onMessageNew(message); + + expect(removeTypingEventCalls, isEmpty); + }); + + test("onMessageNew keeps its author's typing that started after the message", () { + final sentAt = DateTime.utc(2026, 10, 13, 12); + final sentMessage = message.copyWith(createdAt: sentAt); + final nextTyping = Event( + type: EventType.typingStart, + user: otherUser, + createdAt: sentAt.add(const Duration(milliseconds: 200)), + ); + when(() => state.typingEvents).thenReturn({otherUser: nextTyping}); + + mutations.onMessageNew(sentMessage); + + expect(removeTypingEventCalls, isEmpty); + }); + + test('onMessageNew keeps other users typing', () { + final someoneElse = User(id: 'someone-else'); + when(() => state.typingEvents).thenReturn({someoneElse: Event(type: EventType.typingStart, user: someoneElse)}); + + mutations.onMessageNew(message); + + expect(removeTypingEventCalls, isEmpty); + }); + test('onMessageNew also applies the watcher count when provided', () { stubChannelState(ChannelState(channel: ChannelModel(cid: 'messaging:test'))); @@ -144,6 +192,48 @@ void main() { verify(() => state.deleteMessage(message, hardDelete: true)).called(1); }); + test("onMessageUpdated clears the editor's typing in the same channel", () { + final editedAt = DateTime.utc(2026, 10, 13, 12); + final typing = Event( + type: EventType.typingStart, + user: otherUser, + createdAt: editedAt.subtract(const Duration(seconds: 2)), + ); + when(() => state.typingEvents).thenReturn({otherUser: typing}); + + mutations.onMessageUpdated(message.copyWith(messageTextUpdatedAt: editedAt), editor: otherUser); + + expect(removeTypingEventCalls, [otherUser]); + }); + + test("onMessageUpdated keeps the editor's typing when the text was not edited", () { + final updatedAt = DateTime.utc(2026, 10, 13, 12); + final typing = Event( + type: EventType.typingStart, + user: otherUser, + createdAt: updatedAt.subtract(const Duration(seconds: 2)), + ); + when(() => state.typingEvents).thenReturn({otherUser: typing}); + + mutations.onMessageUpdated(message.copyWith(updatedAt: updatedAt), editor: otherUser); + + expect(removeTypingEventCalls, isEmpty); + }); + + test("onMessageUpdated keeps the editor's typing that started after the edit", () { + final editedAt = DateTime.utc(2026, 10, 13, 12); + final nextTyping = Event( + type: EventType.typingStart, + user: otherUser, + createdAt: editedAt.add(const Duration(milliseconds: 200)), + ); + when(() => state.typingEvents).thenReturn({otherUser: nextTyping}); + + mutations.onMessageUpdated(message.copyWith(messageTextUpdatedAt: editedAt), editor: otherUser); + + expect(removeTypingEventCalls, isEmpty); + }); + test('onMessageUpdated applies the message without upserting', () { mutations.onMessageUpdated(message); diff --git a/packages/stream_chat/test/src/client/channel/channel_test.dart b/packages/stream_chat/test/src/client/channel/channel_test.dart index 21aa70487c..df78b97cae 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -5834,6 +5834,103 @@ void main() { }, ); + test('.sendMessage stops typing when the user is typing', () async { + // The group shares one client mock. + addTearDown(() => clearInteractions(client)); + + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.typingEvents, ChannelCapability.sendMessage], + ); + + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + final startTypingEvent = Event(type: EventType.typingStart); + final stopTypingEvent = Event(type: EventType.typingStop); + final message = Message(id: 'test-message-id', text: 'Hello world!'); + + when( + () => client.sendEvent(channelId, channelType, any(that: isSameEventAs(startTypingEvent))), + ).thenAnswer((_) async => EmptyResponse()); + when( + () => client.sendEvent(channelId, channelType, any(that: isSameEventAs(stopTypingEvent))), + ).thenAnswer((_) async => EmptyResponse()); + when( + () => client.sendMessage(any(that: isSameMessageAs(message)), channelId, channelType), + ).thenAnswer((_) async => SendMessageResponse()..message = message); + + channel.keyStroke().ignore(); + await channel.sendMessage(message); + + verify( + () => client.sendEvent(channelId, channelType, any(that: isSameEventAs(stopTypingEvent))), + ).called(1); + }); + + test('.updateMessage stops typing when the user is typing', () async { + // The group shares one client mock. + addTearDown(() => clearInteractions(client)); + + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.typingEvents, ChannelCapability.updateOwnMessage], + ); + + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + final startTypingEvent = Event(type: EventType.typingStart); + final stopTypingEvent = Event(type: EventType.typingStop); + final message = Message(id: 'test-message-id', text: 'Edited'); + + when( + () => client.sendEvent(channelId, channelType, any(that: isSameEventAs(startTypingEvent))), + ).thenAnswer((_) async => EmptyResponse()); + when( + () => client.sendEvent(channelId, channelType, any(that: isSameEventAs(stopTypingEvent))), + ).thenAnswer((_) async => EmptyResponse()); + when( + () => client.updateMessage(any(that: isSameMessageAs(message))), + ).thenAnswer((_) async => UpdateMessageResponse()..message = message); + + channel.keyStroke().ignore(); + await channel.updateMessage(message); + + verify( + () => client.sendEvent(channelId, channelType, any(that: isSameEventAs(stopTypingEvent))), + ).called(1); + }); + + test('.sendMessage does not send a typing stop when the user is not typing', () async { + // The group shares one client mock. + addTearDown(() => clearInteractions(client)); + + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.typingEvents, ChannelCapability.sendMessage], + ); + + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + final stopTypingEvent = Event(type: EventType.typingStop); + final message = Message(id: 'test-message-id', text: 'Hello world!'); + + when( + () => client.sendMessage(any(that: isSameMessageAs(message)), channelId, channelType), + ).thenAnswer((_) async => SendMessageResponse()..message = message); + + await channel.sendMessage(message); + + verifyNever( + () => client.sendEvent(channelId, channelType, any(that: isSameEventAs(stopTypingEvent))), + ); + }); + test( ".startTyping should return if we don't have the capability", () async { diff --git a/packages/stream_chat/test/src/client/key_stroke_handler_test.dart b/packages/stream_chat/test/src/client/key_stroke_handler_test.dart index d0a13352eb..a863570fc7 100644 --- a/packages/stream_chat/test/src/client/key_stroke_handler_test.dart +++ b/packages/stream_chat/test/src/client/key_stroke_handler_test.dart @@ -64,6 +64,39 @@ void main() { verify(() => onStartTyping(any())).called(2); }); + test('moving from the channel to a thread stops typing in the channel and starts it in the thread', () { + keyStrokeHandler + ..call() + ..call('parent-id'); + + verifyInOrder([ + () => onStartTyping(null), + () => onStopTyping(null), + () => onStartTyping('parent-id'), + ]); + }); + + test('moving from a thread to the channel stops typing in the thread and starts it in the channel', () { + keyStrokeHandler + ..call('parent-id') + ..call(); + + verifyInOrder([ + () => onStartTyping('parent-id'), + () => onStopTyping('parent-id'), + () => onStartTyping(null), + ]); + }); + + test('typing again in the same context does not restart typing', () { + keyStrokeHandler + ..call('parent-id') + ..call('parent-id'); + + verify(() => onStartTyping('parent-id')).called(1); + verifyNever(() => onStopTyping(any())); + }); + test('should call onStopTyping after startTypingEventTimeout', () async { keyStrokeHandler.call(); await Future.delayed(const Duration(seconds: startTypingEventTimeout)); diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index ee9469167e..3163cbdee8 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -5,6 +5,8 @@ - Polls are now shown translated into the current user's language, following `StreamMessageTranslationConfiguration` like the message text. - Added `PollTranslationX`, `PollOptionTranslationX` and `PollVoteTranslationX`, to read the translations of a poll, its options and its answers. - Added `MessageTranslationLanguage`, the language `StreamMessageContent` shows a message translated into, for custom attachments to show translations too. +- `StreamMessageListView` now shows who is typing at the newest end of the list with `StreamMessageListTypingIndicator`, replacing the indicator above `StreamChannelPage`'s composer. Replace it with `StreamMessageListViewBuilders.typingIndicator`, or hide it with `StreamMessageListViewConfiguration.showTypingIndicator`. +- Added `StreamTypingUsersBuilder`, to build a widget from the users typing in a channel or thread. ⚠️ Deprecated @@ -17,6 +19,7 @@ - `StreamMessageListView` now rebuilds only the message rows that changed; a custom row builder should read changing outside state through an inherited widget. - `StreamMessageComposer` now hides the picker's upload options and voice recording, and refuses dropped files, when the user lacks the `upload-file` capability. The attachment button is hidden when nothing else is left to offer. - `StreamMessageComposer` now refuses dropped files when `disableAttachments` is set or `allowedAttachmentPickerTypes` leaves out `AttachmentPickerType.files`. +- `StreamTypingIndicator` now uses the design system's typing dots; the `lottie` dependency is removed. 🐞 Fixed diff --git a/packages/stream_chat_flutter/lib/assets/animations/typing_dots.json b/packages/stream_chat_flutter/lib/assets/animations/typing_dots.json deleted file mode 100644 index b210c24b0e..0000000000 --- a/packages/stream_chat_flutter/lib/assets/animations/typing_dots.json +++ /dev/null @@ -1 +0,0 @@ -{"v":"5.7.1","fr":29.9700012207031,"ip":0,"op":95.0000038694293,"w":132,"h":34,"nm":"Typing indicator","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 6","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65.938,24.75,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[],"ip":0,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[200.438,19.75,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[142.227,142.227,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[44.691,44.691],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.511611519608,0.511611519608,0.511611519608,1],"ix":4},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":53,"s":[33]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":53.5,"s":[33.66]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":54,"s":[34.333]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":54.5,"s":[35.019]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":55,"s":[35.72]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":55.5,"s":[36.434]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":56,"s":[37.162]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":56.5,"s":[37.905]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":57,"s":[38.663]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":57.5,"s":[39.436]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":58,"s":[40.225]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":58.5,"s":[41.029]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":59,"s":[41.849]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":59.5,"s":[42.686]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":60,"s":[43.54]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":60.5,"s":[44.41]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":61,"s":[45.298]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":61.5,"s":[46.204]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":62,"s":[47.128]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":62.5,"s":[48.07]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":63,"s":[49.031]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":63.5,"s":[50.012]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":64,"s":[51.012]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":64.5,"s":[52.032]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":65,"s":[53.072]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":65.5,"s":[54.133]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":66,"s":[55.216]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":66.5,"s":[56.32]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":67,"s":[57.446]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":67.5,"s":[58.594]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":68,"s":[59.766]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":68.5,"s":[60.961]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":69,"s":[62.18]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":69.5,"s":[63.423]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":70,"s":[64.691]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":70.5,"s":[65.985]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":71,"s":[67.304]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":71.5,"s":[68.65]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":72,"s":[70.022]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":72.5,"s":[71.422]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":73,"s":[72.851]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":73.5,"s":[74.307]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":74,"s":[75.793]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":74.5,"s":[77.308]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":75,"s":[78.854]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":75.5,"s":[80.431]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":76,"s":[82.039]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":76.5,"s":[83.679]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":77,"s":[85.353]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":77.5,"s":[87.059]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":78,"s":[88.8]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":78.5,"s":[90.575]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":79,"s":[92.386]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":79.5,"s":[94.234]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":80,"s":[96.118]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":80.5,"s":[98.04]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":81,"s":[100]},{"t":91.000003706506,"s":[33]}],"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-59.729,-1.971],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[50.039,50.039],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[151.438,19.75,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[142.227,142.227,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[44.691,44.691],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.511611519608,0.511611519608,0.511611519608,1],"ix":4},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":23,"s":[33]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":23.5,"s":[33.66]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":24,"s":[34.333]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":24.5,"s":[35.019]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":25,"s":[35.72]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":25.5,"s":[36.434]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":26,"s":[37.162]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":26.5,"s":[37.905]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":27,"s":[38.663]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":27.5,"s":[39.436]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":28,"s":[40.225]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":28.5,"s":[41.029]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":29,"s":[41.849]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":29.5,"s":[42.686]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[43.54]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":30.5,"s":[44.41]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":31,"s":[45.298]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":31.5,"s":[46.204]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":32,"s":[47.128]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":32.5,"s":[48.07]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":33,"s":[49.031]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":33.5,"s":[50.012]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":34,"s":[51.012]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":34.5,"s":[52.032]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":35,"s":[53.072]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":35.5,"s":[54.133]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":36,"s":[55.216]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":36.5,"s":[56.32]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":37,"s":[57.446]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":37.5,"s":[58.594]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":38,"s":[59.766]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":38.5,"s":[60.961]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":39,"s":[62.18]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":39.5,"s":[63.423]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":40,"s":[64.691]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":40.5,"s":[65.985]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":41,"s":[67.304]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":41.5,"s":[68.65]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":42,"s":[70.022]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":42.5,"s":[71.422]},{"i":{"x":[0.686],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":43,"s":[72.851]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0]},"t":44,"s":[89]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":44.5,"s":[77.308]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":45,"s":[78.854]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":45.5,"s":[80.431]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":46,"s":[82.039]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":46.5,"s":[83.679]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":47,"s":[85.353]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":47.5,"s":[87.059]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":48,"s":[88.8]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":48.5,"s":[90.575]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":49,"s":[92.386]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":49.5,"s":[94.234]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":50,"s":[96.118]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":50.5,"s":[98.04]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":51,"s":[100]},{"t":85.000003462121,"s":[33]}],"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-59.729,-1.971],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[50.039,50.039],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[101.938,19.75,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[142.227,142.227,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[44.691,44.691],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.511611519608,0.511611519608,0.511611519608,1],"ix":4},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[33]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0.5,"s":[33.66]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1,"s":[34.333]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1.5,"s":[35.019]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":2,"s":[35.72]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":2.5,"s":[36.434]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":3,"s":[37.162]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":3.5,"s":[37.905]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":4,"s":[38.663]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":4.5,"s":[39.436]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":5,"s":[40.225]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":5.5,"s":[41.029]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":6,"s":[41.849]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":6.5,"s":[42.686]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":7,"s":[43.54]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":7.5,"s":[44.41]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":8,"s":[45.298]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":8.5,"s":[46.204]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":9,"s":[47.128]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":9.5,"s":[48.07]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[49.031]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10.5,"s":[50.012]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":11,"s":[51.012]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":11.5,"s":[52.032]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":12,"s":[53.072]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":12.5,"s":[54.133]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":13,"s":[55.216]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":13.5,"s":[56.32]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":14,"s":[57.446]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":14.5,"s":[58.594]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":15,"s":[59.766]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":15.5,"s":[60.961]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":16,"s":[62.18]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":16.5,"s":[63.423]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":17,"s":[64.691]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":17.5,"s":[65.985]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":18,"s":[67.304]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":18.5,"s":[68.65]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":19,"s":[70.022]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":19.5,"s":[71.422]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":20,"s":[72.851]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":20.5,"s":[74.307]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":21,"s":[75.793]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":21.5,"s":[77.308]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":22,"s":[78.854]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":22.5,"s":[80.431]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":23,"s":[82.039]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":23.5,"s":[83.679]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":24,"s":[85.353]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":24.5,"s":[87.059]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":25,"s":[88.8]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":25.5,"s":[90.575]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":26,"s":[92.386]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":26.5,"s":[94.234]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":27,"s":[96.118]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":27.5,"s":[98.04]},{"i":{"x":[0.816],"y":[0.991]},"o":{"x":[0.328],"y":[0]},"t":28,"s":[100]},{"i":{"x":[0.686],"y":[1]},"o":{"x":[0.352],"y":[0.66]},"t":89,"s":[33.03]},{"t":90.0000036657751,"s":[33]}],"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-59.729,-1.971],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[50.039,50.039],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":900.000036657751,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/packages/stream_chat_flutter/lib/src/indicators/typing_indicator.dart b/packages/stream_chat_flutter/lib/src/indicators/typing_indicator.dart index 87e98195e4..480d8fac5e 100644 --- a/packages/stream_chat_flutter/lib/src/indicators/typing_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/indicators/typing_indicator.dart @@ -1,100 +1,107 @@ -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import 'package:lottie/lottie.dart'; import '../../stream_chat_flutter.dart'; import '../misc/empty_widget.dart'; -/// {@template streamTypingIndicator} -/// Shows the list of user who are actively typing. -/// {@endtemplate} +/// Shows who is typing as a line of text followed by animated dots. +/// +/// Shows [alternativeWidget] when nobody is typing. +/// +/// ```dart +/// StreamTypingIndicator(alternativeWidget: Text(lastMessageText)) +/// ``` +/// +/// See also: +/// +/// * [StreamMessageListTypingIndicator], for the message list. +/// * [StreamTypingIndicatorDots], for the dots alone. +/// * [StreamTypingUsersBuilder], to build a custom one. class StreamTypingIndicator extends StatelessWidget { - /// {@macro streamTypingIndicator} + /// Creates a typing indicator for [channel], or a thread of it. const StreamTypingIndicator({ super.key, this.channel, + this.parentId, this.alternativeWidget, this.style, this.padding = EdgeInsets.zero, - this.parentId, }); - /// Style of the text widget - final TextStyle? style; - - /// List of typing users + /// The channel to watch. + /// + /// If null, uses the closest [StreamChannel]. final Channel? channel; - /// The widget to build when no typing is happening + /// The parent message id of the thread to watch. + /// + /// If null, watches the channel itself. + final String? parentId; + + /// Shown when nobody is typing. final Widget? alternativeWidget; - /// The padding of this widget - final EdgeInsets padding; + /// The style of the typing text. + /// + /// If null, uses the surrounding [DefaultTextStyle]. + final TextStyle? style; - /// Id of the parent message in case of a thread - final String? parentId; + /// The padding around the typing text and dots. + final EdgeInsets padding; - // Compares the typing users by id so the indicator only rebuilds when the - // set of typing users changes. The stream maps to a new list on every typing - // event (and on the stale-typing cleanup), so identity equality would rebuild - // even when the same users are still typing. Order-sensitive because only the - // first user is shown by name. - bool _typingUsersEquals(List? previous, List? current) { - final previousIds = previous?.map((it) => it.id) ?? const []; - final currentIds = current?.map((it) => it.id) ?? const []; - return const IterableEquality().equals(previousIds, currentIds); + @override + Widget build(BuildContext context) { + return StreamTypingUsersBuilder( + channel: channel, + parentId: parentId, + builder: (context, users) => AnimatedSwitcher( + layoutBuilder: (currentChild, previousChildren) => Stack( + children: [...previousChildren, ?currentChild], + ), + duration: const Duration(milliseconds: 300), + child: switch (users) { + [] => alternativeWidget ?? const Empty(), + _ => _TypingText(users: users, style: style, padding: padding), + }, + ), + ); } +} - // Keeps only the users typing in the same context as this indicator: the - // thread identified by [parentId], or the main channel when it's null. - List _typingUsers(Map typingEvents) { - return typingEvents.entries.where((element) => element.value.parentId == parentId).map((e) => e.key).toList(); - } +class _TypingText extends StatelessWidget { + const _TypingText({ + required this.users, + this.style, + required this.padding, + }); + + final List users; + final TextStyle? style; + final EdgeInsets padding; @override Widget build(BuildContext context) { - final channelState = channel?.state ?? StreamChannel.of(context).channel.state!; - - final altWidget = alternativeWidget ?? const Empty(); + final typingText = context.translations.userTypingText(users); - return BetterStreamBuilder>( - initialData: _typingUsers(channelState.typingEvents), - stream: channelState.typingEventsStream.map(_typingUsers), - comparator: _typingUsersEquals, - builder: (context, users) => AnimatedSwitcher( - layoutBuilder: (currentChild, previousChildren) => Stack( - children: [ - ...previousChildren, - if (currentChild != null) currentChild, + return Semantics( + container: true, + label: typingText, + excludeSemantics: true, + child: Padding( + padding: padding, + child: Row( + mainAxisSize: .min, + spacing: context.streamSpacing.xs, + children: [ + Flexible( + child: Text(typingText, maxLines: 1, style: style), + ), + Padding( + // Centers the dots on the text. + padding: const EdgeInsets.only(top: 2), + child: StreamTypingIndicatorDots(), + ), ], ), - duration: const Duration(milliseconds: 300), - child: users.isNotEmpty - ? Padding( - padding: padding, - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 4, - children: [ - Flexible( - child: Text( - context.translations.userTypingText(users), - maxLines: 1, - style: style, - ), - ), - Padding( - padding: const EdgeInsets.only(top: 2), - child: Lottie.asset( - 'lib/assets/animations/typing_dots.json', - package: 'stream_chat_flutter', - height: 5, - ), - ), - ], - ), - ) - : altWidget, ), ); } diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index 2221e21aa6..1ef528a0c5 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -847,10 +847,12 @@ class _StreamMessageListViewState extends State { } if (i == 1) { - return _buildPaginationLoadingIndicator( + final loader = _buildPaginationLoadingIndicator( context: context, direction: QueryDirection.bottom, ); + if (!_config.showTypingIndicator) return loader; + return _buildTypingIndicator(context, otherwise: loader); } if (i == 0) { @@ -990,6 +992,31 @@ class _StreamMessageListViewState extends State { ); } + // Shows [otherwise] while nobody is typing, or until the newest messages + // are loaded. + Widget _buildTypingIndicator( + BuildContext context, { + required Widget otherwise, + }) { + return StreamTypingUsersBuilder( + channel: streamChannel!.channel, + parentId: widget.parentMessage?.id, + builder: (context, users) { + if (users.isEmpty) return otherwise; + if (!_isThreadConversation && !_upToDate) return otherwise; + + final gap = context.streamSpacing.md; + return Padding( + padding: _config.reverse ? .only(top: gap) : .only(bottom: gap), + child: switch (widget.builders.typingIndicator) { + final builder? => builder(context, users), + _ => StreamMessageListTypingIndicator(users: users), + }, + ); + }, + ); + } + Widget _buildUnreadMessagesSeparator(int unreadCount) { if (widget.builders.unreadMessagesSeparator case final builder?) { return builder(context, unreadCount); diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_typing_indicator.dart b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_typing_indicator.dart new file mode 100644 index 0000000000..396a7764d2 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_typing_indicator.dart @@ -0,0 +1,58 @@ +import 'package:flutter/widgets.dart'; + +import '../../stream_chat_flutter.dart'; + +/// Shows who is typing as an incoming message. +/// +/// ```dart +/// StreamMessageListTypingIndicator(users: typingUsers) +/// ``` +/// +/// See also: +/// +/// * [StreamMessageListViewBuilders.typingIndicator], to replace it. +/// * [StreamMessageListViewConfiguration.showTypingIndicator], to hide it. +class StreamMessageListTypingIndicator extends StatelessWidget { + /// Creates a typing indicator row for [users]. + const StreamMessageListTypingIndicator({super.key, required this.users}); + + // Same height as a single-line message. + static const _bubbleHeight = 36.0; + + /// The users currently typing. + final List users; + + @override + Widget build(BuildContext context) { + final theme = StreamMessageItemTheme.of(context); + final spacing = context.streamSpacing; + + final effectivePadding = theme.padding ?? EdgeInsets.symmetric(horizontal: spacing.md); + final effectiveSpacing = theme.spacing ?? spacing.xs; + + return Semantics( + container: true, + label: context.translations.userTypingText(users), + excludeSemantics: true, + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Padding( + padding: effectivePadding, + child: Row( + mainAxisSize: .min, + spacing: effectiveSpacing, + crossAxisAlignment: .end, + children: [ + StreamUserAvatarStack(users: users, size: .md, max: 3), + StreamMessageBubble( + padding: .symmetric(horizontal: spacing.sm, vertical: spacing.xs), + style: .new(constraints: .all(const .tightFor(height: _bubbleHeight))), + child: Center(child: StreamTypingIndicatorDots()), + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_builders.dart b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_builders.dart index 793af16c61..35757ca141 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_builders.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_builders.dart @@ -28,6 +28,7 @@ class StreamMessageListViewBuilders { this.floatingDateDivider, this.threadSeparator, this.unreadMessagesSeparator, + this.typingIndicator, this.scrollToBottomButton, this.paginationLoadingIndicator, this.spacing, @@ -83,6 +84,12 @@ class StreamMessageListViewBuilders { /// Receives the number of unread messages below the separator. final Widget Function(BuildContext context, int unreadCount)? unreadMessagesSeparator; + /// Custom builder for the row that shows who is typing. + /// + /// Receives the users currently typing, never empty. Defaults to + /// [StreamMessageListTypingIndicator]. + final TypingUsersWidgetBuilder? typingIndicator; + /// Custom builder for the scroll-to-bottom button. /// /// Receives the current unread count and the default tap action. @@ -136,6 +143,7 @@ class StreamMessageListViewBuilders { Widget Function(DateTime dateTime)? floatingDateDivider, Widget Function(BuildContext context, Message parentMessage)? threadSeparator, Widget Function(BuildContext context, int unreadCount)? unreadMessagesSeparator, + TypingUsersWidgetBuilder? typingIndicator, Widget Function(int unreadCount, Future Function(int) defaultTapAction)? scrollToBottomButton, WidgetBuilder? paginationLoadingIndicator, SpacingWidgetBuilder? spacing, @@ -155,6 +163,7 @@ class StreamMessageListViewBuilders { floatingDateDivider: floatingDateDivider ?? this.floatingDateDivider, threadSeparator: threadSeparator ?? this.threadSeparator, unreadMessagesSeparator: unreadMessagesSeparator ?? this.unreadMessagesSeparator, + typingIndicator: typingIndicator ?? this.typingIndicator, scrollToBottomButton: scrollToBottomButton ?? this.scrollToBottomButton, paginationLoadingIndicator: paginationLoadingIndicator ?? this.paginationLoadingIndicator, spacing: spacing ?? this.spacing, @@ -179,6 +188,7 @@ class StreamMessageListViewBuilders { other.floatingDateDivider == floatingDateDivider && other.threadSeparator == threadSeparator && other.unreadMessagesSeparator == unreadMessagesSeparator && + other.typingIndicator == typingIndicator && other.scrollToBottomButton == scrollToBottomButton && other.paginationLoadingIndicator == paginationLoadingIndicator && other.spacing == spacing && @@ -200,6 +210,7 @@ class StreamMessageListViewBuilders { floatingDateDivider, threadSeparator, unreadMessagesSeparator, + typingIndicator, scrollToBottomButton, paginationLoadingIndicator, spacing, diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart index ec8f7d9f1a..5396ae29d5 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart @@ -28,6 +28,7 @@ class StreamMessageListViewConfiguration { this.showConnectionStateTile = false, this.showFloatingDateDivider = true, this.fadeFloatingDateDividerNearInline = true, + this.showTypingIndicator = true, this.reverse = true, this.shrinkWrap = false, this.paginationLimit = 25, @@ -97,6 +98,11 @@ class StreamMessageListViewConfiguration { /// Defaults to true. final bool fadeFloatingDateDividerNearInline; + /// Whether to show who is typing at the newest end of the list. + /// + /// Defaults to true. + final bool showTypingIndicator; + /// Whether the list scrolls in the reverse reading direction (newest message /// at the bottom). /// @@ -162,6 +168,7 @@ class StreamMessageListViewConfiguration { bool? showConnectionStateTile, bool? showFloatingDateDivider, bool? fadeFloatingDateDividerNearInline, + bool? showTypingIndicator, bool? reverse, bool? shrinkWrap, int? paginationLimit, @@ -181,6 +188,7 @@ class StreamMessageListViewConfiguration { showConnectionStateTile: showConnectionStateTile ?? this.showConnectionStateTile, showFloatingDateDivider: showFloatingDateDivider ?? this.showFloatingDateDivider, fadeFloatingDateDividerNearInline: fadeFloatingDateDividerNearInline ?? this.fadeFloatingDateDividerNearInline, + showTypingIndicator: showTypingIndicator ?? this.showTypingIndicator, reverse: reverse ?? this.reverse, shrinkWrap: shrinkWrap ?? this.shrinkWrap, paginationLimit: paginationLimit ?? this.paginationLimit, @@ -205,6 +213,7 @@ class StreamMessageListViewConfiguration { other.showConnectionStateTile == showConnectionStateTile && other.showFloatingDateDivider == showFloatingDateDivider && other.fadeFloatingDateDividerNearInline == fadeFloatingDateDividerNearInline && + other.showTypingIndicator == showTypingIndicator && other.reverse == reverse && other.shrinkWrap == shrinkWrap && other.paginationLimit == paginationLimit && @@ -226,6 +235,7 @@ class StreamMessageListViewConfiguration { showConnectionStateTile, showFloatingDateDivider, fadeFloatingDateDividerNearInline, + showTypingIndicator, reverse, shrinkWrap, paginationLimit, diff --git a/packages/stream_chat_flutter/lib/src/misc/typing_users_builder.dart b/packages/stream_chat_flutter/lib/src/misc/typing_users_builder.dart new file mode 100644 index 0000000000..a6f70347f8 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/misc/typing_users_builder.dart @@ -0,0 +1,74 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/widgets.dart'; + +import '../../stream_chat_flutter.dart'; + +/// Signature for building a widget from the users currently typing. +typedef TypingUsersWidgetBuilder = Widget Function(BuildContext context, List users); + +/// Builds a widget from the users typing in a channel or thread. +/// +/// The [builder] runs with the users typing in [channel], or in the thread of +/// [parentId], whenever those users change. Nobody typing gives an empty list. +/// +/// ```dart +/// StreamTypingUsersBuilder( +/// builder: (context, users) { +/// if (users.isEmpty) return const SizedBox.shrink(); +/// return Text(context.translations.userTypingText(users)); +/// }, +/// ) +/// ``` +class StreamTypingUsersBuilder extends StatelessWidget { + /// Creates a builder for the users typing in [channel] or a thread of it. + const StreamTypingUsersBuilder({ + super.key, + this.channel, + this.parentId, + required this.builder, + }); + + /// The channel to watch. + /// + /// If null, uses the closest [StreamChannel]. + final Channel? channel; + + /// The parent message id of the thread to watch. + /// + /// If null, watches the channel itself. + final String? parentId; + + /// Builds the widget from the users currently typing. + final TypingUsersWidgetBuilder builder; + + @override + Widget build(BuildContext context) { + final effectiveChannel = channel ?? StreamChannel.of(context).channel; + final channelState = effectiveChannel.state; + assert(channelState != null, 'Channel ${effectiveChannel.cid} is not initialized.'); + + if (channelState == null) return builder(context, const []); + + return BetterStreamBuilder>( + stream: channelState.typingEventsStream, + initialData: channelState.typingEvents, + comparator: _hasSameTypingUsers, + builder: (context, typingEvents) => builder(context, _typingUsersIn(typingEvents)), + ); + } + + bool _hasSameTypingUsers(Map? previous, Map? current) { + final previousIds = _typingUsersIn(previous).map((user) => user.id); + final currentIds = _typingUsersIn(current).map((user) => user.id); + return const IterableEquality().equals(previousIds, currentIds); + } + + List _typingUsersIn(Map? typingEvents) { + if (typingEvents == null || typingEvents.isEmpty) return []; + + return [ + for (final MapEntry(key: user, value: event) in typingEvents.entries) + if (event.parentId == parentId) user, + ]; + } +} diff --git a/packages/stream_chat_flutter/lib/src/pages/channel_page.dart b/packages/stream_chat_flutter/lib/src/pages/channel_page.dart index 88d713f3bf..8faf2192d1 100644 --- a/packages/stream_chat_flutter/lib/src/pages/channel_page.dart +++ b/packages/stream_chat_flutter/lib/src/pages/channel_page.dart @@ -126,16 +126,6 @@ class _StreamChannelPageState extends State { enableVoiceRecording: true, ); - final typingIndicator = StreamTypingIndicator( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - style: context.streamTextTheme.captionDefault.copyWith( - color: context.streamColorScheme.textSecondary, - ), - ); - return StreamScaffold( appBar: appBar, bottom: composer, @@ -147,28 +137,20 @@ class _StreamChannelPageState extends State { initialAlignment: widget.initialAlignment, onReply: _reply, onEditMessage: _editMessage, - typingIndicator: typingIndicator, ), ); } } // The body of a channel page. -// -// Positions the typing indicator just above the composer (floating or docked) -// via a bottom SafeArea over the floating-bar insets in MediaQuery.padding -// (injected by StreamScaffold). StreamMessageListView reads the same insets -// directly to pad its scroll content. class _ChannelPageBody extends StatelessWidget { const _ChannelPageBody({ - required this.typingIndicator, required this.onReply, required this.onEditMessage, this.initialScrollIndex, this.initialAlignment, }); - final Widget typingIndicator; final void Function(Message) onReply; final void Function(Message) onEditMessage; final int? initialScrollIndex; @@ -176,27 +158,15 @@ class _ChannelPageBody extends StatelessWidget { @override Widget build(BuildContext context) { - return Stack( - children: [ - StreamMessageListView( - initialScrollIndex: initialScrollIndex, - initialAlignment: initialAlignment, - onEditMessageTap: onEditMessage, - onReplyTap: onReply, - threadBuilder: (_, parentMessage) { - return StreamThreadPage(parent: parentMessage!); - }, - enableSafeArea: true, - ), - Positioned( - left: 0, - right: 0, - bottom: 0, - // A bottom SafeArea lifts the indicator above the composer / bottom - // bar — the scaffold injects its extent into MediaQuery.padding.bottom. - child: SafeArea(top: false, child: typingIndicator), - ), - ], + return StreamMessageListView( + initialScrollIndex: initialScrollIndex, + initialAlignment: initialAlignment, + onEditMessageTap: onEditMessage, + onReplyTap: onReply, + threadBuilder: (_, parentMessage) { + return StreamThreadPage(parent: parentMessage!); + }, + enableSafeArea: true, ); } } diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 477c4a9d51..58820bc810 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -87,6 +87,7 @@ export 'src/message_input/stream_message_composer_attachment_list.dart'; export 'src/message_input/stream_message_text_field.dart'; export 'src/message_list_view/auto_scroll_policy.dart'; export 'src/message_list_view/message_list_view.dart'; +export 'src/message_list_view/stream_message_list_typing_indicator.dart'; export 'src/message_list_view/stream_message_list_view_builders.dart'; export 'src/message_list_view/stream_message_list_view_configuration.dart'; export 'src/message_list_view/unread_indicator_button.dart'; @@ -119,6 +120,7 @@ export 'src/misc/stream_neumorphic_button.dart'; export 'src/misc/swipeable.dart'; export 'src/misc/thread_header.dart'; export 'src/misc/timestamp.dart'; +export 'src/misc/typing_users_builder.dart'; export 'src/pages/channel_page.dart'; export 'src/pages/thread_page.dart'; export 'src/poll/creator/stream_poll_creator_sheet.dart'; diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 32d4ccc528..93b152c1bd 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -45,7 +45,6 @@ dependencies: intl: ^0.20.2 jiffy: ^6.4.5 just_audio: ^0.10.5 - lottie: ^3.3.3 markdown: ^7.3.0 meta: ^1.9.1 path_provider: ^2.1.5 @@ -67,7 +66,7 @@ dependencies: # ignore: invalid_dependency git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 1546087d1a3dac29e824853fb2a9f05dcd2d41e6 + ref: bd6e2546fa25e2b605cefef4494e0be056d8a0dc path: packages/stream_core_flutter stream_thumbnail: ^0.2.0 svg_icon_widget: ^0.0.1 @@ -92,7 +91,6 @@ dev_dependencies: flutter: assets: - - lib/assets/animations/ - lib/assets/icons/ - lib/assets/icons/colored/ - lib/assets/images/ diff --git a/packages/stream_chat_flutter/test/src/channel/channel_page_test.dart b/packages/stream_chat_flutter/test/src/channel/channel_page_test.dart index c593fe15b6..0daddfcc47 100644 --- a/packages/stream_chat_flutter/test/src/channel/channel_page_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/channel_page_test.dart @@ -17,15 +17,6 @@ void main() { expect(find.byType(StreamMessageComposer), findsOneWidget); }); - testWidgets('renders a typing indicator above the composer', (tester) async { - await _pumpChannelPage(tester, surfaceStyle: StreamSurfaceStyle.floating); - - final indicator = tester.getRect(_bodyTypingIndicator()); - final composer = tester.getRect(find.byType(StreamMessageComposer)); - - expect(indicator.bottom, lessThanOrEqualTo(composer.top)); - }); - testWidgets('tapping the channel avatar invokes onChannelAvatarPressed', (tester) async { Channel? pressedChannel; await _pumpChannelPage(tester, onChannelAvatarPressed: (_, channel) => pressedChannel = channel); @@ -228,21 +219,6 @@ StreamMessageListView _messageListView(WidgetTester tester) { return tester.widget(find.byType(StreamMessageListView)); } -// The typing indicator the page renders in its body. -// -// The header renders one of its own as part of the channel subtitle, so the -// plain type finder is ambiguous. -Finder _bodyTypingIndicator() { - final inHeader = find - .descendant(of: find.byType(StreamChannelHeader), matching: find.byType(StreamTypingIndicator)) - .evaluate() - .toSet(); - - return find.byElementPredicate((element) { - return element.widget is StreamTypingIndicator && !inHeader.contains(element); - }); -} - // The gesture detector wrapping the header's channel avatar. Finder _channelAvatarTapTarget() { return find.ancestor( diff --git a/packages/stream_chat_flutter/test/src/indicators/typing_indicator_test.dart b/packages/stream_chat_flutter/test/src/indicators/typing_indicator_test.dart index bd85cee1a3..4cc7471060 100644 --- a/packages/stream_chat_flutter/test/src/indicators/typing_indicator_test.dart +++ b/packages/stream_chat_flutter/test/src/indicators/typing_indicator_test.dart @@ -1,214 +1,139 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:rxdart/rxdart.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import '../mocks.dart'; void main() { - late MockClient client; - late MockClientState clientState; - late MockChannel channel; - late MockChannelState channelState; - - setUp(() { - client = MockClient(); - clientState = MockClientState(); - channel = MockChannel(); - channelState = MockChannelState(); - final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => channel.lastMessageAt).thenReturn(lastMessageAt); - when(() => channel.state).thenReturn(channelState); - 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', + final ada = User(id: 'ada', name: 'Ada'); + final grace = User(id: 'grace', name: 'Grace'); + + testWidgets('StreamTypingIndicator shows who is typing in the channel', (tester) async { + final typingEvents = BehaviorSubject.seeded({ada: _typingEvent(ada)}); + addTearDown(typingEvents.close); + + await _pumpIndicator(tester, typingEvents: typingEvents); + + expect(find.text('Ada is typing'), findsOneWidget); + }); + + testWidgets('StreamTypingIndicator summarizes several typing users', (tester) async { + final typingEvents = BehaviorSubject.seeded({ada: _typingEvent(ada), grace: _typingEvent(grace)}); + addTearDown(typingEvents.close); + + await _pumpIndicator(tester, typingEvents: typingEvents); + + expect(find.text('Ada and 1 more are typing'), findsOneWidget); + }); + + testWidgets('StreamTypingIndicator shows alternativeWidget when nobody is typing', (tester) async { + final typingEvents = BehaviorSubject.seeded({}); + addTearDown(typingEvents.close); + + await _pumpIndicator(tester, typingEvents: typingEvents, alternativeWidget: const Text('Last message')); + + expect(find.text('Last message'), findsOneWidget); + }); + + testWidgets('StreamTypingIndicator returns to alternativeWidget once typing stops', (tester) async { + final typingEvents = BehaviorSubject.seeded({ada: _typingEvent(ada)}); + addTearDown(typingEvents.close); + await _pumpIndicator(tester, typingEvents: typingEvents, alternativeWidget: const Text('Last message')); + + typingEvents.add({}); + await tester.pumpAndSettle(); + + expect(find.text('Ada is typing'), findsNothing); + expect(find.text('Last message'), findsOneWidget); + }); + + testWidgets('StreamTypingIndicator shows only users typing in the thread of parentId', (tester) async { + final typingEvents = BehaviorSubject.seeded({ + ada: _typingEvent(ada), + grace: _typingEvent(grace, parentId: 'parent-id'), }); - 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'), - ), - ]), - ); + addTearDown(typingEvents.close); + + await _pumpIndicator(tester, typingEvents: typingEvents, parentId: 'parent-id'); + + expect(find.text('Grace is typing'), findsOneWidget); + }); + + testWidgets('StreamTypingIndicator announces who is typing', (tester) async { + final handle = tester.ensureSemantics(); + final typingEvents = BehaviorSubject.seeded({ada: _typingEvent(ada), grace: _typingEvent(grace)}); + addTearDown(typingEvents.close); + + await _pumpIndicator(tester, typingEvents: typingEvents); + + expect(find.bySemanticsLabel('Ada and 1 more are typing'), findsOneWidget); + handle.dispose(); }); - Widget buildTarget({Key? key}) { - return MaterialApp( - home: StreamChat( - client: client, - child: StreamChannel( - channel: channel, - child: Scaffold( - body: StreamTypingIndicator( - key: key, - ), - ), + testWidgets('StreamTypingIndicator uses the closest StreamChannel when channel is null', (tester) async { + final typingEvents = BehaviorSubject.seeded({ada: _typingEvent(ada)}); + addTearDown(typingEvents.close); + final channel = _channelWith(typingEvents); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((_) => Stream.value(false)); + + await tester.pumpWidget( + _app( + StreamChat( + client: channel.client, + child: StreamChannel(channel: channel, child: const StreamTypingIndicator()), ), ), ); - } - - testWidgets( - 'it should show channel typing', - (WidgetTester tester) async { - when(() => channelState.typingEvents).thenAnswer( - (i) => { - User(id: 'other-user', name: 'demo'): Event(type: EventType.typingStart), - }, - ); - when(() => channelState.typingEventsStream).thenAnswer( - (i) => Stream.value({ - User(id: 'other-user', name: 'demo'): Event(type: EventType.typingStart), - }), - ); - - const typingKey = Key('typing'); - - await tester.pumpWidget(buildTarget(key: typingKey)); - - // wait for the initial state to be rendered. - await tester.pump(Duration.zero); - - expect(find.byKey(typingKey), findsOneWidget); - expect(find.byType(Flexible), findsOneWidget); - }, - ); + await tester.pumpAndSettle(); - // Advances the stream event and the 300ms AnimatedSwitcher transition - // without settling (the typing dots animation repeats forever). - Future emit( - WidgetTester tester, - StreamController> controller, - Map typingEvents, - ) async { - controller.add(typingEvents); - await tester.pump(); - await tester.pump(const Duration(seconds: 1)); - } - - testWidgets( - 'it updates as the set of typing users changes', - (WidgetTester tester) async { - final typingController = StreamController>.broadcast(); - addTearDown(typingController.close); - - when(() => channelState.typingEvents).thenReturn(const {}); - when(() => channelState.typingEventsStream).thenAnswer( - (_) => typingController.stream, - ); - - Event event() => Event(type: EventType.typingStart); - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: StreamTypingIndicator(channel: channel), - ), - ), - ); - await tester.pump(); - - // No one is typing -> no typing row is shown. - expect(find.byType(Flexible), findsNothing); - - // A single user is typing -> their name is shown. - await emit(tester, typingController, { - User(id: 'user-a', name: 'Alice'): event(), - }); - expect(find.text('Alice is typing'), findsOneWidget); - - // A second user joins -> the summarized text is shown. - await emit(tester, typingController, { - User(id: 'user-a', name: 'Alice'): event(), - User(id: 'user-b', name: 'Bob'): event(), - }); - expect(find.text('Alice and 1 more are typing'), findsOneWidget); - - // Everyone stops typing -> the typing row is removed again. - await emit(tester, typingController, const {}); - expect(find.byType(Flexible), findsNothing); - }, + expect(find.text('Ada is typing'), findsOneWidget); + }); +} + +Event _typingEvent(User user, {String? parentId}) { + return Event(type: EventType.typingStart, user: user, parentId: parentId); +} + +MockChannel _channelWith(BehaviorSubject> typingEvents) { + final client = MockClient(); + final clientState = MockClientState(); + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'current-user')); + + final channel = MockChannel(); + final channelState = MockChannelState(); + when(() => channel.client).thenReturn(client); + when(() => channel.state).thenReturn(channelState); + when(() => channelState.typingEvents).thenAnswer((_) => typingEvents.value); + when(() => channelState.typingEventsStream).thenAnswer((_) => typingEvents.stream); + return channel; +} + +// Animations off, so the dots hold still and the tree can settle. +Widget _app(Widget child) { + return MaterialApp( + home: MediaQuery( + data: const MediaQueryData(disableAnimations: true), + child: Scaffold(body: child), + ), ); +} - testWidgets( - 'it only shows typing users matching its parentId', - (WidgetTester tester) async { - final typingController = StreamController>.broadcast(); - addTearDown(typingController.close); - - // A main-channel (no parentId) typing event is already present when the - // indicator mounts. - when(() => channelState.typingEvents).thenReturn({ - User(id: 'user-a', name: 'Alice'): Event(type: EventType.typingStart), - }); - when(() => channelState.typingEventsStream).thenAnswer( - (_) => typingController.stream, - ); - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: StreamTypingIndicator( - channel: channel, - parentId: 'thread-1', - ), - ), - ), - ); - await tester.pump(); - - // The pre-existing main-channel typer is filtered out of the initial - // data, so the first frame shows nothing. - expect(find.byType(Flexible), findsNothing); - - // Typing in the main channel (no parentId) is ignored by a thread - // indicator. - await emit(tester, typingController, { - User(id: 'user-a', name: 'Alice'): Event(type: EventType.typingStart), - }); - expect(find.byType(Flexible), findsNothing); - - // Typing within the thread is shown. - await emit(tester, typingController, { - User(id: 'user-b', name: 'Bob'): Event( - type: EventType.typingStart, - parentId: 'thread-1', - ), - }); - expect(find.text('Bob is typing'), findsOneWidget); - }, +Future _pumpIndicator( + WidgetTester tester, { + required BehaviorSubject> typingEvents, + String? parentId, + Widget? alternativeWidget, +}) async { + final indicator = StreamTypingIndicator( + channel: _channelWith(typingEvents), + parentId: parentId, + alternativeWidget: alternativeWidget, ); + + await tester.pumpWidget(_app(indicator)); + await tester.pumpAndSettle(); } diff --git a/packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_1_users_dark.png b/packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_1_users_dark.png new file mode 100644 index 0000000000..4f0b0c2b4a Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_1_users_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_1_users_light.png b/packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_1_users_light.png new file mode 100644 index 0000000000..dacbf6fa7c Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_1_users_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_2_users_dark.png b/packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_2_users_dark.png new file mode 100644 index 0000000000..ed788a5088 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_2_users_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_2_users_light.png b/packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_2_users_light.png new file mode 100644 index 0000000000..191430220f Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_2_users_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_3_users_dark.png b/packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_3_users_dark.png new file mode 100644 index 0000000000..0ac4f7e5d9 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_3_users_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_3_users_light.png b/packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_3_users_light.png new file mode 100644 index 0000000000..586128a612 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_3_users_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_5_users_dark.png b/packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_5_users_dark.png new file mode 100644 index 0000000000..6956f15d35 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_5_users_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_5_users_light.png b/packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_5_users_light.png new file mode 100644 index 0000000000..ac27dc122f Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_5_users_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_list_view/stream_message_list_typing_indicator_golden_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/stream_message_list_typing_indicator_golden_test.dart new file mode 100644 index 0000000000..4e3c7e3e1f --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_list_view/stream_message_list_typing_indicator_golden_test.dart @@ -0,0 +1,49 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + for (final brightness in Brightness.values) { + for (final count in [1, 2, 3, 5]) { + final theme = brightness.name; + goldenTest( + '[$theme] -> StreamMessageListTypingIndicator shows $count typing users', + fileName: 'stream_message_list_typing_indicator_${count}_users_$theme', + constraints: const BoxConstraints.tightFor(width: 300, height: 80), + builder: () => _wrapWithStreamChatApp( + StreamMessageListTypingIndicator(users: _users(count)), + brightness: brightness, + ), + ); + } + } +} + +List _users(int count) { + const names = ['Ada Lovelace', 'Grace Hopper', 'Alan Turing', 'Katherine Johnson', 'Linus Torvalds']; + return [for (var i = 0; i < count; i += 1) User(id: 'user-$i', name: names[i])]; +} + +// Animations off, so the dots hold a still frame. +Widget _wrapWithStreamChatApp( + Widget widget, { + Brightness brightness = Brightness.light, +}) { + return MaterialApp( + theme: ThemeData(brightness: brightness), + home: StreamChatTheme( + data: StreamChatThemeData(), + child: Builder( + builder: (context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(disableAnimations: true), + child: Scaffold( + backgroundColor: context.streamColorScheme.backgroundApp, + body: Center(child: widget), + ), + ); + }, + ), + ), + ); +} diff --git a/packages/stream_chat_flutter/test/src/message_list_view/typing_indicator_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/typing_indicator_test.dart new file mode 100644 index 0000000000..8ca7016609 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_list_view/typing_indicator_test.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../mocks.dart'; + +void main() { + testWidgets('StreamMessageListView shows who is typing in the channel', (tester) async { + final ada = User(id: 'ada', name: 'Ada'); + + await _pumpMessageListView(tester, typingEvents: {ada: _typingEvent(ada)}); + + final indicator = tester.widget(find.byType(StreamMessageListTypingIndicator)); + expect(indicator.users, [ada]); + }); + + testWidgets('StreamMessageListView hides the typing row when nobody is typing', (tester) async { + await _pumpMessageListView(tester, typingEvents: {}); + + expect(find.byType(StreamMessageListTypingIndicator), findsNothing); + }); + + testWidgets('StreamMessageListView hides the typing row once typing stops', (tester) async { + final ada = User(id: 'ada', name: 'Ada'); + final typingEvents = BehaviorSubject>.seeded({ada: _typingEvent(ada)}); + addTearDown(typingEvents.close); + + await _pumpMessageListView(tester, typingEventsSubject: typingEvents); + typingEvents.add({}); + await tester.pumpAndSettle(); + + expect(find.byType(StreamMessageListTypingIndicator), findsNothing); + }); + + testWidgets('StreamMessageListView ignores users typing in a thread', (tester) async { + final ada = User(id: 'ada', name: 'Ada'); + + await _pumpMessageListView(tester, typingEvents: {ada: _typingEvent(ada, parentId: 'parent')}); + + expect(find.byType(StreamMessageListTypingIndicator), findsNothing); + }); + + testWidgets('StreamMessageListView hides the typing row while newer messages are not loaded', (tester) async { + final ada = User(id: 'ada', name: 'Ada'); + + await _pumpMessageListView(tester, typingEvents: {ada: _typingEvent(ada)}, isUpToDate: false); + + expect(find.byType(StreamMessageListTypingIndicator), findsNothing); + }); + + testWidgets('StreamMessageListView hides the typing row when showTypingIndicator is false', (tester) async { + final ada = User(id: 'ada', name: 'Ada'); + const config = StreamMessageListViewConfiguration(showTypingIndicator: false); + + await _pumpMessageListView(tester, typingEvents: {ada: _typingEvent(ada)}, config: config); + + expect(find.byType(StreamMessageListTypingIndicator), findsNothing); + }); + + testWidgets('StreamMessageListView builds the typing row with builders.typingIndicator', (tester) async { + final ada = User(id: 'ada', name: 'Ada'); + final builders = StreamMessageListViewBuilders( + typingIndicator: (context, users) => Text('${users.length} typing'), + ); + + await _pumpMessageListView(tester, typingEvents: {ada: _typingEvent(ada)}, builders: builders); + + expect(find.text('1 typing'), findsOneWidget); + expect(find.byType(StreamMessageListTypingIndicator), findsNothing); + }); + + testWidgets('StreamMessageListTypingIndicator announces who is typing', (tester) async { + final handle = tester.ensureSemantics(); + final ada = User(id: 'ada', name: 'Ada'); + + await _pumpMessageListView(tester, typingEvents: {ada: _typingEvent(ada)}); + + expect(find.bySemanticsLabel('Ada is typing'), findsOneWidget); + handle.dispose(); + }); + + testWidgets('StreamMessageListTypingIndicator announces only who is typing, not the avatar overflow', (tester) async { + final handle = tester.ensureSemantics(); + final users = [ + for (final name in ['Ada', 'Grace', 'Linus', 'Alan', 'Barbara']) User(id: name, name: name), + ]; + + await _pumpMessageListView(tester, typingEvents: {for (final user in users) user: _typingEvent(user)}); + + expect(find.bySemanticsLabel('Ada and 4 more are typing'), findsOneWidget); + expect(find.bySemanticsLabel('+2'), findsNothing); + handle.dispose(); + }); +} + +Event _typingEvent(User user, {String? parentId}) { + return Event(type: EventType.typingStart, user: user, parentId: parentId); +} + +Future _pumpMessageListView( + WidgetTester tester, { + Map typingEvents = const {}, + BehaviorSubject>? typingEventsSubject, + bool isUpToDate = true, + StreamMessageListViewConfiguration config = const StreamMessageListViewConfiguration(), + StreamMessageListViewBuilders builders = const StreamMessageListViewBuilders(), +}) async { + final client = MockClient(); + final clientState = MockClientState(); + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'current-user')); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(OwnUser(id: 'current-user'))); + + final channel = MockChannel(); + final channelState = MockChannelState(); + when(() => channel.client).thenReturn(client); + when(() => channel.state).thenReturn(channelState); + when(() => channelState.threadsStream).thenAnswer((_) => const Stream.empty()); + final messages = [ + Message( + id: 'message', + text: 'Hi', + user: User(id: 'grace', name: 'Grace'), + ), + ]; + when(() => channelState.messagesStream).thenAnswer((_) => Stream.value(messages)); + when(() => channelState.messages).thenReturn(messages); + when(() => channelState.isUpToDate).thenReturn(isUpToDate); + when(() => channelState.isUpToDateStream).thenAnswer((_) => Stream.value(isUpToDate)); + when(() => channelState.unreadCountStream).thenAnswer((_) => Stream.value(0)); + when(() => channelState.readStream).thenAnswer((_) => const Stream.empty()); + when(() => channelState.membersStream).thenAnswer((_) => const Stream.empty()); + when(() => channelState.members).thenReturn([]); + when(() => channelState.currentUserRead).thenReturn(null); + when(() => channelState.currentUserReadStream).thenAnswer((_) => const Stream.empty()); + final typingEventsState = typingEventsSubject ?? BehaviorSubject.seeded(typingEvents); + addTearDown(typingEventsState.close); + when(() => channelState.typingEvents).thenAnswer((_) => typingEventsState.value); + when(() => channelState.typingEventsStream).thenAnswer((_) => typingEventsState.stream); + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(disableAnimations: true), + child: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: StreamMessageListView( + config: config, + builders: builders, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} diff --git a/packages/stream_chat_flutter/test/src/misc/typing_users_builder_test.dart b/packages/stream_chat_flutter/test/src/misc/typing_users_builder_test.dart new file mode 100644 index 0000000000..bdb02c6c92 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/misc/typing_users_builder_test.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../mocks.dart'; + +void main() { + final ada = User(id: 'ada', name: 'Ada'); + final grace = User(id: 'grace', name: 'Grace'); + + testWidgets('StreamTypingUsersBuilder passes the users typing in the channel', (tester) async { + final typingEvents = BehaviorSubject.seeded({ada: _typingEvent(ada)}); + addTearDown(typingEvents.close); + + final builds = await _pumpBuilder(tester, typingEvents: typingEvents); + + expect(builds.last, [ada]); + }); + + testWidgets('StreamTypingUsersBuilder passes an empty list when nobody is typing', (tester) async { + final typingEvents = BehaviorSubject.seeded({}); + addTearDown(typingEvents.close); + + final builds = await _pumpBuilder(tester, typingEvents: typingEvents); + + expect(builds.last, isEmpty); + }); + + testWidgets('StreamTypingUsersBuilder leaves out users typing in a thread', (tester) async { + final typingEvents = BehaviorSubject.seeded({ + ada: _typingEvent(ada), + grace: _typingEvent(grace, parentId: 'parent-id'), + }); + addTearDown(typingEvents.close); + + final builds = await _pumpBuilder(tester, typingEvents: typingEvents); + + expect(builds.last, [ada]); + }); + + testWidgets('StreamTypingUsersBuilder passes only users typing in the thread of parentId', (tester) async { + final typingEvents = BehaviorSubject.seeded({ + ada: _typingEvent(ada), + grace: _typingEvent(grace, parentId: 'parent-id'), + }); + addTearDown(typingEvents.close); + + final builds = await _pumpBuilder(tester, typingEvents: typingEvents, parentId: 'parent-id'); + + expect(builds.last, [grace]); + }); + + testWidgets('StreamTypingUsersBuilder rebuilds when the users typing change', (tester) async { + final typingEvents = BehaviorSubject.seeded({ada: _typingEvent(ada)}); + addTearDown(typingEvents.close); + final builds = await _pumpBuilder(tester, typingEvents: typingEvents); + + typingEvents.add({ada: _typingEvent(ada), grace: _typingEvent(grace)}); + await tester.pumpAndSettle(); + + expect(builds.last, [ada, grace]); + }); + + testWidgets('StreamTypingUsersBuilder does not rebuild when the same users keep typing', (tester) async { + final typingEvents = BehaviorSubject.seeded({ada: _typingEvent(ada)}); + addTearDown(typingEvents.close); + final builds = await _pumpBuilder(tester, typingEvents: typingEvents); + final buildCount = builds.length; + + typingEvents.add({ada: _typingEvent(ada)}); + await tester.pumpAndSettle(); + + expect(builds, hasLength(buildCount)); + }); + + testWidgets('StreamTypingUsersBuilder uses the given channel without a StreamChannel', (tester) async { + final typingEvents = BehaviorSubject.seeded({ada: _typingEvent(ada)}); + addTearDown(typingEvents.close); + final channel = _channelWith(typingEvents); + final builds = >[]; + + await tester.pumpWidget( + MaterialApp( + home: StreamTypingUsersBuilder( + channel: channel, + builder: (context, users) { + builds.add(users); + return const SizedBox(); + }, + ), + ), + ); + + expect(builds.last, [ada]); + }); +} + +Event _typingEvent(User user, {String? parentId}) { + return Event(type: EventType.typingStart, user: user, parentId: parentId); +} + +MockChannel _channelWith(BehaviorSubject> typingEvents) { + final client = MockClient(); + final clientState = MockClientState(); + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'current-user')); + + final channel = MockChannel(); + final channelState = MockChannelState(); + when(() => channel.client).thenReturn(client); + when(() => channel.state).thenReturn(channelState); + when(() => channelState.typingEvents).thenAnswer((_) => typingEvents.value); + when(() => channelState.typingEventsStream).thenAnswer((_) => typingEvents.stream); + return channel; +} + +// Pumps the builder under a [StreamChannel], returning every list it was +// built with. +Future>> _pumpBuilder( + WidgetTester tester, { + required BehaviorSubject> typingEvents, + String? parentId, +}) async { + final channel = _channelWith(typingEvents); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((_) => Stream.value(false)); + final builds = >[]; + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: channel.client, + child: StreamChannel( + channel: channel, + child: StreamTypingUsersBuilder( + parentId: parentId, + builder: (context, users) { + builds.add(users); + return const SizedBox(); + }, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + return builds; +}