From 05d7acd11f19c30658223c74bea99df0a347b8d5 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 14 Oct 2026 13:29:12 +0200 Subject: [PATCH 1/5] fix(llc): stop showing users as typing after they send or edit A new message, or a text edit, now ends its author's typing in that channel or thread, so the indicator no longer lingers when the stop event is late or lost. Sending or editing also stops the current user's own typing, and switching between a thread and the channel stops typing in the previous one. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/stream_chat/CHANGELOG.md | 1 + .../lib/src/client/channel/channel.dart | 8 ++ .../client/channel/channel_event_handler.dart | 2 +- .../channel/channel_state_mutations.dart | 33 ++++++- .../lib/src/client/key_stroke_handler.dart | 7 ++ .../channel/channel_event_handler_test.dart | 9 ++ .../channel/channel_state_mutations_test.dart | 90 +++++++++++++++++ .../test/src/client/channel/channel_test.dart | 97 +++++++++++++++++++ .../src/client/key_stroke_handler_test.dart | 33 +++++++ 9 files changed, 277 insertions(+), 3 deletions(-) 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)); From ff38c234d6a4e471dec3ec57c45332df66f2f3ae Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 14 Oct 2026 13:29:21 +0200 Subject: [PATCH 2/5] feat(ui): show who is typing in the message list StreamMessageListView now shows who is typing as an incoming message at the newest end of the list, in the slot it uses for the newer-messages loader, so item indices are unchanged. It replaces the indicator StreamChannelPage drew above the composer, and can be replaced through StreamMessageListViewBuilders.typingIndicator or hidden with StreamMessageListViewConfiguration.showTypingIndicator. StreamTypingUsersBuilder exposes the users typing in a channel or thread, and StreamTypingIndicator is rebuilt on it with the design system's typing dots, dropping the lottie dependency. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/docs_screenshots/pubspec.yaml | 2 +- melos.yaml | 2 +- packages/stream_chat_flutter/CHANGELOG.md | 3 + .../lib/assets/animations/typing_dots.json | 1 - .../lib/src/indicators/typing_indicator.dart | 145 ++++---- .../message_list_view/message_list_view.dart | 29 +- .../stream_message_list_typing_indicator.dart | 58 ++++ .../stream_message_list_view_builders.dart | 11 + ...tream_message_list_view_configuration.dart | 10 + .../lib/src/misc/typing_users_builder.dart | 74 ++++ .../lib/src/pages/channel_page.dart | 48 +-- .../lib/stream_chat_flutter.dart | 2 + packages/stream_chat_flutter/pubspec.yaml | 4 +- .../test/src/channel/channel_page_test.dart | 24 -- .../src/indicators/typing_indicator_test.dart | 315 +++++++----------- ...age_list_typing_indicator_golden_test.dart | 49 +++ .../typing_indicator_test.dart | 161 +++++++++ .../src/misc/typing_users_builder_test.dart | 151 +++++++++ 18 files changed, 755 insertions(+), 334 deletions(-) delete mode 100644 packages/stream_chat_flutter/lib/assets/animations/typing_dots.json create mode 100644 packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_typing_indicator.dart create mode 100644 packages/stream_chat_flutter/lib/src/misc/typing_users_builder.dart create mode 100644 packages/stream_chat_flutter/test/src/message_list_view/stream_message_list_typing_indicator_golden_test.dart create mode 100644 packages/stream_chat_flutter/test/src/message_list_view/typing_indicator_test.dart create mode 100644 packages/stream_chat_flutter/test/src/misc/typing_users_builder_test.dart diff --git a/docs/docs_screenshots/pubspec.yaml b/docs/docs_screenshots/pubspec.yaml index 1de1559939..ee284ad4b4 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: b853d03e6b38847e128ae4a425b1e929af88dd89 path: packages/stream_core_flutter dev_dependencies: diff --git a/melos.yaml b/melos.yaml index 50f61c33d7..8d7c27dcfb 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: b853d03e6b38847e128ae4a425b1e929af88dd89 path: packages/stream_core_flutter stream_thumbnail: ^0.2.0 synchronized: ^3.4.0 diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index f9dc4c2fa4..8d18e65728 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..821bc4b365 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 ? EdgeInsets.only(top: gap) : EdgeInsets.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..47edf8d647 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: b853d03e6b38847e128ae4a425b1e929af88dd89 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/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; +} From cf7d00bed59c78a20f42607594af3622facf1e1d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 14 Oct 2026 13:38:16 +0200 Subject: [PATCH 3/5] refactor(ui): use dot shorthands for the typing row gap Co-Authored-By: Claude Opus 5.5 (1M context) --- .../lib/src/message_list_view/message_list_view.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 821bc4b365..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 @@ -1007,7 +1007,7 @@ class _StreamMessageListViewState extends State { final gap = context.streamSpacing.md; return Padding( - padding: _config.reverse ? EdgeInsets.only(top: gap) : EdgeInsets.only(bottom: gap), + padding: _config.reverse ? .only(top: gap) : .only(bottom: gap), child: switch (widget.builders.typingIndicator) { final builder? => builder(context, users), _ => StreamMessageListTypingIndicator(users: users), From f6fbef7270b61eba5dd78d0fb2345812f5c4fc4c Mon Sep 17 00:00:00 2001 From: xsahil03x <25670178+xsahil03x@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:35:46 +0000 Subject: [PATCH 4/5] chore: Update Goldens --- ...essage_list_typing_indicator_1_users_dark.png | Bin 0 -> 2187 bytes ...ssage_list_typing_indicator_1_users_light.png | Bin 0 -> 2273 bytes ...essage_list_typing_indicator_2_users_dark.png | Bin 0 -> 2800 bytes ...ssage_list_typing_indicator_2_users_light.png | Bin 0 -> 2866 bytes ...essage_list_typing_indicator_3_users_dark.png | Bin 0 -> 3313 bytes ...ssage_list_typing_indicator_3_users_light.png | Bin 0 -> 3386 bytes ...essage_list_typing_indicator_5_users_dark.png | Bin 0 -> 3871 bytes ...ssage_list_typing_indicator_5_users_light.png | Bin 0 -> 3981 bytes 8 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_1_users_dark.png create mode 100644 packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_1_users_light.png create mode 100644 packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_2_users_dark.png create mode 100644 packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_2_users_light.png create mode 100644 packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_3_users_dark.png create mode 100644 packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_3_users_light.png create mode 100644 packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_5_users_dark.png create mode 100644 packages/stream_chat_flutter/test/src/message_list_view/goldens/ci/stream_message_list_typing_indicator_5_users_light.png 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 0000000000000000000000000000000000000000..4f0b0c2b4a6c658ace4d8573879d91d42b8878b4 GIT binary patch literal 2187 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 O0BkQipsP_n_x}Og!7sP~ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ed788a5088d566ff4f4d65788847672e0d0408d0 GIT binary patch literal 2800 zcmbW3`9IX(7suaBStb!NCd81vA!|rdj55kTmaH>UwlMYyGYG}lniLuP62`s^3VlrZ zh-8^Qh%h0Vd>Z?n`tY6l6TbI{d+zh`IQN`;?&IFac_mv}TssaH0|NkX9BGQM0RRp- zdu_$T#hy#Qn?|wCkq{#!iibU-c|4QY%n@R9%?PL)lK2+@z+@!i3JRCE%y7MHJI=wq z)`j<S1q0q~aFl2nQ(8kw^_1^!c<1=HGUhgs`f9_v6oVkt&j$R2$ zIMmyn-kbxW?`745Lv*B5pYCM&q2*R zwOTp-T4|pc7c-Rckrn78=Jbmo@$`18BA7~U37v;JBFxJ^8K=aVjCvv-e0)}A6a(F++I2I}UN;gO|vEyj$Eulkp866#k9oMK_JuyQxSmV{i3_ zr1OIH4VqiN7K%#4DSLG<|#o`S=9Sk&lFx%^#9`7rNCx63TtVP1h~q)hC~uv2-Z6$m%J2ohU%*$z#=g&2O46BOQWVZIWCXH z4#)s7) zo|rPk>dX+J?M{J`j-JoD|IV|?W5cV|*WcenRw$_B)Myzq?DVQ|~2yfKEL5S(18fV125OO08zOT+PFM-wQ-kKf$G8?3{-K!X% z6Ppn$MSi{fznBi^cqt=Qah`zvnd(=sUcIdqtS>dD#h)lqwl`)~!jB24sK<>l6w1Va zy+uYB2avvHV6*F7mn3C+wALgmJAm8aUgO|``DS~1%Gu(U5RkDTD=}-622KR7WNh1+ zyjD2e(+6Pc`f_C!9pNTbU82axck4y&`uq264jX2iRIsS&P`m(1nOpo&iu;nR0DHdy zdf(TSdT>D4+S!qz#&x+xJDF}bc|u=28~oxZD+8zI8D@&ACt`Z``gqI|Im5TNE_Z1v z)wt>J%Me`NU5r*uW)w}d`il=08g@ZY^_?4FH^PtG3!4#zX7WP3d3qstRIJja4AVot z4HY|f>gSg}e|}WKvcRQ-pqkx1I}1bEwM=JWbzOp7OPriE_&PHOzo6g`1UBy7nAqzg zKBaTUih8Jb<`04wF+DxUi8B@z9Kg)?(7+nGSx^;2WD-BQGoU$)+Nx>|oJp@JB(#?d z%x4(7efWudZlfEzXl0U9P*6Z@NWEIxntf!w_0e~KZ7O1mn#Y|5UxCc;gz7cOT!~P) z#Z@)EvTkhYnNdj(mUJXRdhKeQmNEY8FFQ;eD*FR_R&4Sz3=g18gX{ce&xKUo|8lWr zejlqAxh-t+noehDNls2C#<1GcxB6EHlG4G{D$5_pr^|;K$ zG6ugr=Aet;*GiYr<213xe(e_-7#gyFl9QA3`ct!4W5lXkWfgI54&$kjL)6H*!6mBd za@oN_G}N_sYm2(PUTH(|tiDJxwdiHnV1LQ|^CQdH(f*6YC3692B|C_5pl9Re)|Qs4 zYx@>mqq(_xZ)?O-%1J(V&;y5?*x7anqUBFBWvO{HLXf}lfX!4Z2at7rrQnF|vTh0P zqFY;QQW-0Hg z_0|)pNqvFu-j&deMJpyyl6m*0rauMX6y5P5@cJE_6)BxF!MTjz_e$jCiQE_!N}2DY z0RXzu|KXzx%jAZJ+(OYPM`FnY8Eu9-7jATYfJ|OjmmULX5ix5Lz^tLp2%Wlr>Haw= z?SA4(Zb$H(8$Dti76TZJk~j)cB)o&B|jL_^-=PYOTy@ zY1U{=07C|2h{aVW1bm9b;z^ooH|^`&x5~*^!8K#9;y4Kd?T!~S?iDmA5k3w|>&6Pd z^P8_h%EsK_qeUoq{q?-uqbSxxwSD~MDEhm9+`Gkv_M&IABW4xaCd4JCncRX z;DQL=;{v55U5t-2K@_}ZYoB2>T9D+W_FD_FW!AzewgB4T`~Thc3?Uj!Dh%dvn=M%Z Mq=^Nh&Io<~e@BftH2?qr literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..191430220f66fe02d7d9b36d63ab5c3969970dbf GIT binary patch literal 2866 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0ac4f7e5d9b6c12da2091a78a73239ed18d0c286 GIT binary patch literal 3313 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ac27dc122f2d567c5159fc519c845af703a61952 GIT binary patch literal 3981 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 P=xKnKI$RA6wT}27pW=-6 literal 0 HcmV?d00001 From 8b1ec2cfddac20306b4f20d0a4981b52a19a858b Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 14 Oct 2026 15:58:41 +0200 Subject: [PATCH 5/5] chore(repo): pin stream_core_flutter to the merged typing indicator dots Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/docs_screenshots/pubspec.yaml | 2 +- melos.yaml | 2 +- packages/stream_chat_flutter/pubspec.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/docs_screenshots/pubspec.yaml b/docs/docs_screenshots/pubspec.yaml index ee284ad4b4..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: b853d03e6b38847e128ae4a425b1e929af88dd89 + ref: bd6e2546fa25e2b605cefef4494e0be056d8a0dc path: packages/stream_core_flutter dev_dependencies: diff --git a/melos.yaml b/melos.yaml index 8d7c27dcfb..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: b853d03e6b38847e128ae4a425b1e929af88dd89 + ref: bd6e2546fa25e2b605cefef4494e0be056d8a0dc path: packages/stream_core_flutter stream_thumbnail: ^0.2.0 synchronized: ^3.4.0 diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 47edf8d647..93b152c1bd 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -66,7 +66,7 @@ dependencies: # ignore: invalid_dependency git: url: https://github.com/GetStream/stream-core-flutter.git - ref: b853d03e6b38847e128ae4a425b1e929af88dd89 + ref: bd6e2546fa25e2b605cefef4494e0be056d8a0dc path: packages/stream_core_flutter stream_thumbnail: ^0.2.0 svg_icon_widget: ^0.0.1