diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 809fb3a3..f8a5bee7 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -1,25 +1,45 @@ -## Upcoming +## 3.5.0 + +✅ Added + +- You can now pass `score` to `client.sendReaction` and `channel.sendReaction` functions. +- Added new `client.partialUpdateUsers` function in order to partially update users. + +🐞 Fixed + +- [[#890]](https://github.com/GetStream/stream-chat-flutter/pull/890) Fixed Reactions not updating on thread messages. + Thanks [bstolinski](https://github.com/bstolinski). +- [[#897]](https://github.com/GetStream/stream-chat-flutter/issues/897) Fixed error type mis-match in `AuthInterceptor`. +- [[#891]](https://github.com/GetStream/stream-chat-flutter/pull/891) Fixed reply counter for parent message not + updating correctly after deleting thread message. +- Fix `channelState.copyWith` with respect to pinnedMessages. + +## 3.4.0 🐞 Fixed - [[#857]](https://github.com/GetStream/stream-chat-flutter/issues/857) Channel now listens for member ban/unban and updates the channel state with the latest data. -- [[#748]](https://github.com/GetStream/stream-chat-flutter/issues/748) `Message.user` are now also included while saving users in persistence. +- [[#748]](https://github.com/GetStream/stream-chat-flutter/issues/748) `Message.user` is now also included while saving + users in persistence. - [[#871]](https://github.com/GetStream/stream-chat-flutter/issues/871) Fixed thread message deletion. -- [[#846]](https://github.com/GetStream/stream-chat-flutter/issues/846) Fixed `message.ownReactions` getting truncated when receiving a reaction event. +- [[#846]](https://github.com/GetStream/stream-chat-flutter/issues/846) Fixed `message.ownReactions` getting truncated + when receiving a reaction event. - Add check for invalid image URLs +- Fix `channelState.pinnedMessagesStream` getting reset to `0` after a channel update. +- Fixed `unreadCount` after removing user from a channel. 🔄 Changed - `client.location` is now deprecated in favor of the new [edge server](https://getstream.io/blog/chat-edge-infrastructure) and will be removed in v4.0.0. - `channel.banUser`, `channel.unbanUser` is now deprecated in favor of the new `channel.banMember` - and `channel.unbanMember` and will be removed in v4.0.0. + and `channel.unbanMember`. These deprecated methods will be removed in v4.0.0. +- Added `banExpires` property of type `DateTime` on the `Member`, `OwnUser`, and `User` models. ✅ Added - Added `client.enrichUrl` endpoint for enriching URLs with metadata. -- Fixed `unreadCount` after removing user from a channel. - Added `client.queryBannedUsers`, `channel.queryBannedUsers` endpoint for querying banned users. ## 3.3.1 @@ -44,6 +64,12 @@ - Fixed user presence indicator not updating correctly. - `ChannelEvent.membersCount` defaults to 0 avoiding parsing errors due to missing `members_count` field. +## 3.2.1 + +🐞 Fixed + +- Fixed `StreamChatClient.markAllRead` api call + ## 3.2.0 🐞 Fixed diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index 622cb151..e1673442 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -419,7 +419,7 @@ class Channel { if (index != -1) { final newAttachments = [...message!.attachments]..[index] = attachment; final updatedMessage = message!.copyWith(attachments: newAttachments); - state?.addMessage(updatedMessage); + state?.updateMessage(updatedMessage); // updating original message for next iteration message = message!.merge(updatedMessage); } @@ -525,7 +525,7 @@ class Channel { ).toList(), ); - state!.addMessage(message); + state!.updateMessage(message); try { if (message.attachments.any((it) => !it.uploadState.isSuccess)) { @@ -549,7 +549,7 @@ class Channel { skipPush: skipPush, skipEnrichUrl: skipEnrichUrl, ); - state!.addMessage(response.message); + state!.updateMessage(response.message); if (cooldown > 0) cooldownStartedAt = DateTime.now(); return response; } catch (e) { @@ -588,7 +588,7 @@ class Channel { ).toList(), ); - state?.addMessage(message); + state?.updateMessage(message); try { if (message.attachments.any((it) => !it.uploadState.isSuccess)) { @@ -614,7 +614,7 @@ class Channel { ownReactions: message.ownReactions, ); - state?.addMessage(m); + state?.updateMessage(m); return response; } catch (e) { @@ -622,7 +622,7 @@ class Channel { if (e.isRetriable) { state!._retryQueue.add([message]); } else { - state?.addMessage(originalMessage); + state?.updateMessage(originalMessage); } } rethrow; @@ -652,7 +652,7 @@ class Channel { ownReactions: message.ownReactions, ); - state?.addMessage(updatedMessage); + state?.updateMessage(updatedMessage); return response; } catch (e) { @@ -665,13 +665,18 @@ class Channel { /// Deletes the [message] from the channel. Future deleteMessage(Message message, {bool? hard}) async { + final hardDelete = hard ?? false; + // Directly deleting the local messages which are not yet sent to server if (message.status == MessageSendingStatus.sending || message.status == MessageSendingStatus.failed) { - state!.addMessage(message.copyWith( - type: 'deleted', - status: MessageSendingStatus.sent, - )); + state!.deleteMessage( + message.copyWith( + type: 'deleted', + status: MessageSendingStatus.sent, + ), + hardDelete: hardDelete, + ); // Removing the attachments upload completer to stop the `sendMessage` // waiting for attachments to complete. @@ -689,11 +694,14 @@ class Channel { deletedAt: message.deletedAt ?? DateTime.now(), ); - state?.addMessage(message); + state?.deleteMessage(message, hardDelete: hardDelete); final response = await _client.deleteMessage(message.id, hard: hard); - state?.addMessage(message.copyWith(status: MessageSendingStatus.sent)); + state?.deleteMessage( + message.copyWith(status: MessageSendingStatus.sent), + hardDelete: hardDelete, + ); return response; } catch (e) { @@ -833,6 +841,7 @@ class Channel { Future sendReaction( Message message, String type, { + int score = 1, Map extraData = const {}, bool enforceUnique = false, }) async { @@ -851,7 +860,7 @@ class Channel { createdAt: now, type: type, user: user, - score: 1, + score: score, extraData: extraData, ); @@ -882,19 +891,20 @@ class Channel { ownReactions: ownReactions, ); - state?.addMessage(newMessage); + state?.updateMessage(newMessage); try { final reactionResp = await _client.sendReaction( messageId, type, + score: score, extraData: extraData, enforceUnique: enforceUnique, ); return reactionResp; } catch (_) { // Reset the message if the update fails - state?.addMessage(message); + state?.updateMessage(message); rethrow; } } @@ -934,7 +944,7 @@ class Channel { ownReactions: ownReactions, ); - state?.addMessage(newMessage); + state?.updateMessage(newMessage); try { final deleteResponse = await _client.deleteReaction( @@ -944,7 +954,7 @@ class Channel { return deleteResponse; } catch (_) { // Reset the message if the update fails - state?.addMessage(message); + state?.updateMessage(message); rethrow; } } @@ -1101,7 +1111,7 @@ class Channel { // update the passed message with response message if (res.message != null) { - state!.addMessage(res.message!); + state!.updateMessage(res.message!); } else { // remove the passed message if response does // not contain message @@ -1321,7 +1331,7 @@ class Channel { } /// Bans the user with given [userID] from the channel. - @Deprecated("Use 'banMember' instead") + @Deprecated("Use 'banMember' instead. This method will be removed in v4.0.0") Future banUser( String userID, Map options, @@ -1343,7 +1353,9 @@ class Channel { } /// Remove the ban for the user with given [userID] in the channel. - @Deprecated("Use 'unbanMember' instead") + @Deprecated( + "Use 'unbanMember' instead. This method will be removed in v4.0.0", + ) Future unbanUser(String userID) => unbanMember(userID); /// Remove the ban for the member with given [userID] in the channel. @@ -1717,7 +1729,9 @@ class ChannelClientState { void _listenReactionDeleted() { _subscriptions.add(_channel.on(EventType.reactionDeleted).listen((event) { final oldMessage = - messages.firstWhereOrNull((it) => it.id == event.message?.id); + messages.firstWhereOrNull((it) => it.id == event.message?.id) ?? + threads[event.message?.parentId] + ?.firstWhereOrNull((e) => e.id == event.message?.id); final reaction = event.reaction; final ownReactions = oldMessage?.ownReactions ?.whereNot((it) => @@ -1730,18 +1744,20 @@ class ChannelClientState { final message = event.message!.copyWith( ownReactions: ownReactions, ); - addMessage(message); + updateMessage(message); })); } void _listenReactions() { _subscriptions.add(_channel.on(EventType.reactionNew).listen((event) { final oldMessage = - messages.firstWhereOrNull((it) => it.id == event.message?.id); + messages.firstWhereOrNull((it) => it.id == event.message?.id) ?? + threads[event.message?.parentId] + ?.firstWhereOrNull((e) => e.id == event.message?.id); final message = event.message!.copyWith( ownReactions: oldMessage?.ownReactions, ); - addMessage(message); + updateMessage(message); })); } @@ -1753,12 +1769,13 @@ class ChannelClientState { ) .listen((event) { final oldMessage = - messages.firstWhereOrNull((it) => it.id == event.message?.id); - + messages.firstWhereOrNull((it) => it.id == event.message?.id) ?? + threads[event.message?.parentId] + ?.firstWhereOrNull((e) => e.id == event.message?.id); final message = event.message!.copyWith( ownReactions: oldMessage?.ownReactions, ); - addMessage(message); + updateMessage(message); if (message.pinned) { _channelState = _channelState.copyWith( @@ -1775,9 +1792,9 @@ class ChannelClientState { _subscriptions.add(_channel.on(EventType.messageDeleted).listen((event) { final message = event.message!; if (event.hardDelete == true) { - removeMessage(message, hardDelete: true); + removeMessage(message); } else { - addMessage(message); + updateMessage(message); } })); } @@ -1792,7 +1809,7 @@ class ChannelClientState { final message = event.message!; if (isUpToDate || (message.parentId != null && message.showInChannel != true)) { - addMessage(message); + updateMessage(message); } if (_countMessageAsUnread(message)) { @@ -1802,9 +1819,13 @@ class ChannelClientState { } /// Add a [message] to this [channelState]. - void addMessage(Message message) { + @Deprecated('Use updateMessage instead') + void addMessage(Message message) => updateMessage(message); + + /// Updates the [message] in the state if it exists. Adds it otherwise. + void updateMessage(Message message) { if (message.parentId == null || message.showInChannel == true) { - final newMessages = List.from(_channelState.messages); + final newMessages = [...messages]; final oldIndex = newMessages.indexWhere((m) => m.id == message.id); if (oldIndex != -1) { Message? m; @@ -1819,8 +1840,24 @@ class ChannelClientState { newMessages.add(message); } + final newPinnedMessages = [...pinnedMessages]; + final oldPinnedIndex = + newPinnedMessages.indexWhere((m) => m.id == message.id); + + // Handle pinned messages + if (message.pinned) { + if (oldPinnedIndex != -1) { + newPinnedMessages[oldPinnedIndex] = message; + } else { + newPinnedMessages.add(message); + } + } else { + newPinnedMessages.removeWhere((m) => m.id == message.id); + } + _channelState = _channelState.copyWith( messages: newMessages..sort(_sortByCreatedAt), + pinnedMessages: newPinnedMessages, channel: _channelState.channel?.copyWith( lastMessageAt: message.createdAt, ), @@ -1833,41 +1870,35 @@ class ChannelClientState { } /// Remove a [message] from this [channelState]. - void removeMessage(Message message, {bool hardDelete = false}) { + void removeMessage(Message message) { final parentId = message.parentId; - // i.e. it's a thread message - // 1. Remove the thread message - // 2. Reduce total reply count of parent message + // i.e. it's a thread message, Remove it if (parentId != null) { - final allMessages = [...messages]; - final parentMessage = allMessages.firstWhereOrNull( - (it) => it.id == parentId, - ); + final newThreads = {...threads}; + // Early return in case the thread is not available + if (!newThreads.containsKey(parentId)) return; - // return if message not available in the memory - if (parentMessage == null) return; - final replyCount = parentMessage.replyCount; - // return if reply count is null or zero - if (replyCount == null || replyCount == 0) return; + _threads = newThreads + ..update( + parentId, + (messages) => messages..removeWhere((e) => e.id == message.id), + ); - addMessage(parentMessage.copyWith(replyCount: replyCount - 1)); - updateThreadInfo( - parentId, - threads[parentId]! - ..removeWhere( - (e) => e.id == message.id, - ), - ); - } else { - // Remove regular message - final allMessages = [...messages]; - if (hardDelete) { - allMessages.removeWhere((e) => e.id == message.id); - _channelState = _channelState.copyWith(messages: allMessages); - } else if (allMessages.remove(message)) { - _channelState = _channelState.copyWith(messages: allMessages); - } + // Early return if the thread message is not shown in channel. + if (message.showInChannel == false) return; } + + // Remove regular message, thread message shown in channel + final allMessages = [...messages]; + _channelState = _channelState.copyWith( + messages: allMessages..removeWhere((e) => e.id == message.id), + ); + } + + /// Removes/Updates the [message] based on the [hardDelete] value. + void deleteMessage(Message message, {bool hardDelete = false}) { + if (hardDelete) return removeMessage(message); + return updateMessage(message); } void _listenReadEvents() { @@ -1913,11 +1944,12 @@ class ChannelClientState { .distinct(const ListEquality().equals); /// Channel pinned message list. - List get pinnedMessages => _channelState.pinnedMessages.toList(); + List get pinnedMessages => _channelState.pinnedMessages; /// Channel pinned message list as a stream. - Stream> get pinnedMessagesStream => - channelStateStream.map((cs) => cs.pinnedMessages.toList()); + Stream> get pinnedMessagesStream => channelStateStream + .map((cs) => cs.pinnedMessages) + .distinct(const ListEquality().equals); /// Get channel last message. Message? get lastMessage => @@ -2228,7 +2260,7 @@ class ChannelClientState { .toList(); updateChannelState(_channelState.copyWith( - pinnedMessages: pinnedMessages.where(_pinIsValid()).toList(), + pinnedMessages: pinnedMessages.where(_pinIsValid).toList(), messages: expiredMessages, )); } @@ -2265,7 +2297,7 @@ class ChannelClientState { } } -bool Function(Message) _pinIsValid() { +bool _pinIsValid(Message message) { final now = DateTime.now(); - return (Message m) => m.pinExpires!.isAfter(now); + return message.pinExpires!.isAfter(now); } diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 52d72620..cab864b9 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -1073,6 +1073,28 @@ class StreamChatClient { Future updateUsers(List users) => _chatApi.user.updateUsers(users); + /// Partially update the given user with [id]. + /// Use [set] to define values to be set. + /// Use [unset] to define values to be unset. + Future partialUpdateUser( + String id, { + Map? set, + List? unset, + }) { + final user = PartialUpdateUserRequest( + id: id, + set: set, + unset: unset, + ); + return partialUpdateUsers([user]); + } + + /// Batch partial updates the [users]. + Future partialUpdateUsers( + List users, + ) => + _chatApi.user.partialUpdateUsers(users); + /// Bans a user from all channels Future banUser( String targetUserId, [ @@ -1157,15 +1179,22 @@ class StreamChatClient { Future sendReaction( String messageId, String reactionType, { + int score = 1, Map extraData = const {}, bool enforceUnique = false, - }) => - _chatApi.message.sendReaction( - messageId, - reactionType, - extraData: extraData, - enforceUnique: enforceUnique, - ); + }) { + final _extraData = { + 'score': score, + ...extraData, + }; + + return _chatApi.message.sendReaction( + messageId, + reactionType, + extraData: _extraData, + enforceUnique: enforceUnique, + ); + } /// Delete a [reactionType] from this [messageId] Future deleteReaction( diff --git a/packages/stream_chat/lib/src/client/retry_queue.dart b/packages/stream_chat/lib/src/client/retry_queue.dart index ad140ad6..388dda57 100644 --- a/packages/stream_chat/lib/src/client/retry_queue.dart +++ b/packages/stream_chat/lib/src/client/retry_queue.dart @@ -159,7 +159,7 @@ class RetryQueue { : message.status == MessageSendingStatus.updating ? MessageSendingStatus.failed_update : MessageSendingStatus.failed_delete; - channel.state?.addMessage(message.copyWith(status: newStatus)); + channel.state?.updateMessage(message.copyWith(status: newStatus)); } Future _retryMessage(Message message) async { diff --git a/packages/stream_chat/lib/src/core/api/channel_api.dart b/packages/stream_chat/lib/src/core/api/channel_api.dart index 3285ae32..0b299d95 100644 --- a/packages/stream_chat/lib/src/core/api/channel_api.dart +++ b/packages/stream_chat/lib/src/core/api/channel_api.dart @@ -84,7 +84,10 @@ class ChannelApi { /// Mark all channels for this user as read Future markAllRead() async { - final response = await _client.post('/channels/read'); + final response = await _client.post( + '/channels/read', + data: {}, + ); return EmptyResponse.fromJson(response.data); } diff --git a/packages/stream_chat/lib/src/core/api/requests.dart b/packages/stream_chat/lib/src/core/api/requests.dart index 6f00b374..3f4e79de 100644 --- a/packages/stream_chat/lib/src/core/api/requests.dart +++ b/packages/stream_chat/lib/src/core/api/requests.dart @@ -156,3 +156,29 @@ class PaginationParams extends Equatable { lessThanOrEqual, ]; } + +/// Request model for the [client.partialUpdateUser] api call. +@JsonSerializable(createFactory: false) +class PartialUpdateUserRequest extends Equatable { + /// Creates a new PartialUpdateUserRequest instance. + const PartialUpdateUserRequest({ + required this.id, + this.set, + this.unset, + }); + + /// User ID. + final String id; + + /// Fields to set. + final Map? set; + + /// Fields to unset. + final List? unset; + + /// Serialize model to json + Map toJson() => _$PartialUpdateUserRequestToJson(this); + + @override + List get props => [id, set, unset]; +} diff --git a/packages/stream_chat/lib/src/core/api/requests.g.dart b/packages/stream_chat/lib/src/core/api/requests.g.dart index 7d45ee86..62ec12b3 100644 --- a/packages/stream_chat/lib/src/core/api/requests.g.dart +++ b/packages/stream_chat/lib/src/core/api/requests.g.dart @@ -54,3 +54,14 @@ Map _$PaginationParamsToJson(PaginationParams instance) { writeNotNull('id_lte', instance.lessThanOrEqual); return val; } + +Map _$PartialUpdateUserRequestToJson( + PartialUpdateUserRequest instance) => + { + 'stringify': instance.stringify, + 'hash_code': instance.hashCode, + 'id': instance.id, + 'set': instance.set, + 'unset': instance.unset, + 'props': instance.props, + }; diff --git a/packages/stream_chat/lib/src/core/api/user_api.dart b/packages/stream_chat/lib/src/core/api/user_api.dart index 61159731..916976c8 100644 --- a/packages/stream_chat/lib/src/core/api/user_api.dart +++ b/packages/stream_chat/lib/src/core/api/user_api.dart @@ -46,4 +46,17 @@ class UserApi { ); return UpdateUsersResponse.fromJson(response.data); } + + /// Batch partial update of [users]. + Future partialUpdateUsers( + List users, + ) async { + final response = await _client.patch( + '/users', + data: { + 'users': users, + }, + ); + return UpdateUsersResponse.fromJson(response.data); + } } diff --git a/packages/stream_chat/lib/src/core/http/interceptor/auth_interceptor.dart b/packages/stream_chat/lib/src/core/http/interceptor/auth_interceptor.dart index 37f618f5..0ef1a408 100644 --- a/packages/stream_chat/lib/src/core/http/interceptor/auth_interceptor.dart +++ b/packages/stream_chat/lib/src/core/http/interceptor/auth_interceptor.dart @@ -49,10 +49,13 @@ class AuthInterceptor extends Interceptor { DioError err, ErrorInterceptorHandler handler, ) async { - ErrorResponse? error; final data = err.response?.data; - if (data != null) error = ErrorResponse.fromJson(data); - if (error?.code == ChatErrorCode.tokenExpired.code) { + if (data == null || data is! Map) { + return handler.next(err); + } + + final error = ErrorResponse.fromJson(data); + if (error.code == ChatErrorCode.tokenExpired.code) { if (_tokenManager.isStatic) return handler.next(err); _client.lock(); await _tokenManager.loadToken(refresh: true); diff --git a/packages/stream_chat/lib/src/core/models/channel_state.dart b/packages/stream_chat/lib/src/core/models/channel_state.dart index fb5fc64f..17439dc0 100644 --- a/packages/stream_chat/lib/src/core/models/channel_state.dart +++ b/packages/stream_chat/lib/src/core/models/channel_state.dart @@ -7,6 +7,8 @@ import 'package:stream_chat/src/core/models/user.dart'; part 'channel_state.g.dart'; +const _emptyPinnedMessages = []; + /// The class that contains the information about a channel @JsonSerializable() class ChannelState { @@ -15,7 +17,7 @@ class ChannelState { this.channel, this.messages = const [], this.members = const [], - this.pinnedMessages = const [], + this.pinnedMessages = _emptyPinnedMessages, this.watcherCount, this.watchers = const [], this.read = const [], @@ -54,7 +56,7 @@ class ChannelState { ChannelModel? channel, List? messages, List? members, - List? pinnedMessages, + List pinnedMessages = _emptyPinnedMessages, int? watcherCount, List? watchers, List? read, @@ -63,7 +65,11 @@ class ChannelState { channel: channel ?? this.channel, messages: messages ?? this.messages, members: members ?? this.members, - pinnedMessages: pinnedMessages ?? this.pinnedMessages, + // Hack to avoid using the default value in case nothing is provided. + // FIXME: Use non-nullable by default instead of empty list. + pinnedMessages: pinnedMessages == _emptyPinnedMessages + ? this.pinnedMessages + : pinnedMessages, watcherCount: watcherCount ?? this.watcherCount, watchers: watchers ?? this.watchers, read: read ?? this.read, diff --git a/packages/stream_chat/lib/src/core/models/channel_state.g.dart b/packages/stream_chat/lib/src/core/models/channel_state.g.dart index 0da51768..5100932c 100644 --- a/packages/stream_chat/lib/src/core/models/channel_state.g.dart +++ b/packages/stream_chat/lib/src/core/models/channel_state.g.dart @@ -21,7 +21,7 @@ ChannelState _$ChannelStateFromJson(Map json) => ChannelState( pinnedMessages: (json['pinned_messages'] as List?) ?.map((e) => Message.fromJson(e as Map)) .toList() ?? - const [], + _emptyPinnedMessages, watcherCount: json['watcher_count'] as int?, watchers: (json['watchers'] as List?) ?.map((e) => User.fromJson(e as Map)) diff --git a/packages/stream_chat/lib/src/core/models/user.dart b/packages/stream_chat/lib/src/core/models/user.dart index 83b2a502..0d19d59d 100644 --- a/packages/stream_chat/lib/src/core/models/user.dart +++ b/packages/stream_chat/lib/src/core/models/user.dart @@ -69,7 +69,6 @@ class User extends Equatable { 'online', 'banned', 'ban_expires', - 'dashboard_ban_channel_cid', 'teams', 'language', ]; diff --git a/packages/stream_chat/lib/version.dart b/packages/stream_chat/lib/version.dart index ed6d9b09..0ae6a20a 100644 --- a/packages/stream_chat/lib/version.dart +++ b/packages/stream_chat/lib/version.dart @@ -3,4 +3,4 @@ import 'package:stream_chat/src/client/client.dart'; /// Current package version /// Used in [StreamChatClient] to build the `x-stream-client` header // ignore: constant_identifier_names -const PACKAGE_VERSION = '3.3.1'; +const PACKAGE_VERSION = '3.5.0'; diff --git a/packages/stream_chat/pubspec.yaml b/packages/stream_chat/pubspec.yaml index 599b6ed8..6e6893c1 100644 --- a/packages/stream_chat/pubspec.yaml +++ b/packages/stream_chat/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat homepage: https://getstream.io/ description: The official Dart client for Stream Chat, a service for building chat applications. -version: 3.3.1 +version: 3.5.0 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index 49310d05..fe38af5f 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -1130,6 +1130,135 @@ void main() { verify(() => client.sendReaction(message.id, type)).called(1); }); + test('should work fine with score passed explicitly', () async { + const type = 'test-reaction-type'; + final message = Message(id: 'test-message-id'); + + const score = 5; + final reaction = Reaction( + type: type, + messageId: message.id, + score: score, + ); + + when(() => client.sendReaction( + message.id, + type, + score: score, + )).thenAnswer( + (_) async => SendReactionResponse() + ..message = message + ..reaction = reaction, + ); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + status: MessageSendingStatus.sent, + reactionCounts: {type: 1}, + reactionScores: {type: score}, + latestReactions: [reaction], + ownReactions: [reaction], + ), + matchReactions: true, + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.sendReaction( + message, + type, + score: score, + ); + + expect(res, isNotNull); + expect(res.reaction.type, type); + expect(res.reaction.messageId, message.id); + expect(res.reaction.score, score); + + verify(() => client.sendReaction( + message.id, + type, + score: score, + )).called(1); + }); + + test('should work fine with score passed explicitly and in extraData', + () async { + const type = 'test-reaction-type'; + final message = Message(id: 'test-message-id'); + + const score = 5; + const extraDataScore = 3; + const extraData = { + 'score': extraDataScore, + }; + final reaction = Reaction( + type: type, + messageId: message.id, + score: extraDataScore, + ); + + when(() => client.sendReaction( + message.id, + type, + score: score, + extraData: extraData, + )).thenAnswer( + (_) async => SendReactionResponse() + ..message = message + ..reaction = reaction, + ); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + status: MessageSendingStatus.sent, + reactionCounts: {type: 1}, + reactionScores: {type: extraDataScore}, + latestReactions: [reaction], + ownReactions: [reaction], + ), + matchReactions: true, + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.sendReaction( + message, + type, + score: score, + extraData: extraData, + ); + + expect(res, isNotNull); + expect(res.reaction.type, type); + expect(res.reaction.messageId, message.id); + expect( + res.reaction.score, + extraDataScore, + ); + + verify(() => client.sendReaction( + message.id, + type, + score: score, + extraData: extraData, + )).called(1); + }); + test( 'should restore previous message if `client.sendReaction` throws', () async { @@ -1257,6 +1386,189 @@ void main() { ); }); + group('`.sendReaction in thread`', () { + test('should work fine', () async { + const type = 'test-reaction-type'; + final message = Message( + id: 'test-message-id', + parentId: 'test-parent-id', // is thread message + ); + + final reaction = Reaction(type: type, messageId: message.id); + + when(() => client.sendReaction(message.id, type)).thenAnswer( + (_) async => SendReactionResponse() + ..message = message + ..reaction = reaction, + ); + + expectLater( + channel.state?.threadsStream + // skipping first seed message list -> [] messages + .skip(1) + .map((event) => event['test-parent-id']), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + status: MessageSendingStatus.sent, + reactionCounts: {type: 1}, + reactionScores: {type: 1}, + latestReactions: [reaction], + ownReactions: [reaction], + ), + matchReactions: true, + matchSendingStatus: true, + matchParentId: true, + ), + ], + ]), + ); + + final res = await channel.sendReaction(message, type); + + expect(res, isNotNull); + expect(res.reaction.type, type); + expect(res.reaction.messageId, message.id); + + verify(() => client.sendReaction(message.id, type)).called(1); + }); + + test( + '''should restore previous thread message if `client.sendReaction` throws''', + () async { + const type = 'test-reaction-type'; + final message = Message( + id: 'test-message-id', + parentId: 'test-parent-id', // is thread message + ); + + final reaction = Reaction(type: type, messageId: message.id); + + when(() => client.sendReaction(message.id, type)) + .thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.threadsStream + .skip(1) + .map((event) => event['test-parent-id']), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + status: MessageSendingStatus.sent, + reactionCounts: {type: 1}, + reactionScores: {type: 1}, + latestReactions: [reaction], + ownReactions: [reaction], + ), + matchReactions: true, + matchSendingStatus: true, + matchParentId: true, + ), + ], + [ + isSameMessageAs( + message, + matchReactions: true, + matchSendingStatus: true, + matchParentId: true, + ), + ], + ]), + ); + + try { + await channel.sendReaction(message, type); + } catch (e) { + expect(e, isA()); + } + + verify(() => client.sendReaction(message.id, type)).called(1); + }, + ); + + test( + '''should override previous thread reaction if present and `enforceUnique` is true''', + () async { + const userId = 'test-user-id'; + const messageId = 'test-message-id'; + const parentId = 'test-parent-id'; + const prevType = 'test-reaction-type'; + final prevReaction = Reaction( + type: prevType, + messageId: messageId, + userId: userId, + ); + final message = Message( + id: messageId, + parentId: parentId, + ownReactions: [prevReaction], + latestReactions: [prevReaction], + reactionScores: const {prevType: 1}, + reactionCounts: const {prevType: 1}, + ); + + const type = 'test-reaction-type-2'; + final newReaction = Reaction( + type: type, + messageId: messageId, + userId: userId, + ); + final newMessage = message.copyWith( + ownReactions: [newReaction], + latestReactions: [newReaction], + ); + + const enforceUnique = true; + + when(() => client.sendReaction( + messageId, + type, + enforceUnique: enforceUnique, + )).thenAnswer( + (_) async => SendReactionResponse() + ..message = newMessage + ..reaction = newReaction, + ); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.threadsStream + .skip(1) + .map((event) => event['test-parent-id']), + emitsInOrder([ + [ + isSameMessageAs( + newMessage.copyWith(status: MessageSendingStatus.sent), + matchReactions: true, + matchSendingStatus: true, + matchParentId: true, + ), + ], + ]), + ); + + final res = await channel.sendReaction( + message, + type, + enforceUnique: enforceUnique, + ); + + expect(res, isNotNull); + expect(res.reaction.type, type); + expect(res.reaction.messageId, messageId); + + verify(() => client.sendReaction( + messageId, + type, + enforceUnique: enforceUnique, + )).called(1); + }, + ); + }); + group('`.deleteReaction`', () { test('should work fine', () async { const userId = 'test-user-id'; @@ -1363,6 +1675,121 @@ void main() { ); }); + group('`.deleteReaction in thread`', () { + test('should work fine', () async { + const userId = 'test-user-id'; + const messageId = 'test-message-id'; + const parentId = 'test-parent-id'; + const type = 'test-reaction-type'; + final reaction = Reaction( + type: type, + messageId: messageId, + userId: userId, + ); + final message = Message( + id: messageId, + parentId: parentId, // is thread + ownReactions: [reaction], + latestReactions: [reaction], + reactionScores: const {type: 1}, + reactionCounts: const {type: 1}, + ); + + when(() => client.deleteReaction(messageId, type)) + .thenAnswer((_) async => EmptyResponse()); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.threadsStream + .skip(1) + .map((event) => event['test-parent-id']), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + status: MessageSendingStatus.sent, + latestReactions: [], + ownReactions: [], + ), + matchReactions: true, + matchSendingStatus: true, + matchParentId: true, + ), + ], + ]), + ); + + final res = await channel.deleteReaction(message, reaction); + + expect(res, isNotNull); + + verify(() => client.deleteReaction(messageId, type)).called(1); + }); + + test( + 'should restore prev message state if `client.deleteReaction` throws', + () async { + const userId = 'test-user-id'; + const messageId = 'test-message-id'; + const parentId = 'test-parent-id'; + const type = 'test-reaction-type'; + final reaction = Reaction( + type: type, + messageId: messageId, + userId: userId, + ); + final message = Message( + id: messageId, + parentId: parentId, + ownReactions: [reaction], + latestReactions: [reaction], + reactionScores: const {type: 1}, + reactionCounts: const {type: 1}, + ); + + when(() => client.deleteReaction(messageId, type)) + .thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.threadsStream + .skip(1) + .map((event) => event['test-parent-id']), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + status: MessageSendingStatus.sent, + latestReactions: [], + ownReactions: [], + ), + matchReactions: true, + matchSendingStatus: true, + matchParentId: true, + ), + ], + [ + isSameMessageAs( + message, + matchReactions: true, + matchSendingStatus: true, + matchParentId: true, + ), + ], + ]), + ); + + try { + await channel.deleteReaction(message, reaction); + } catch (e) { + expect(e, isA()); + } + + verify(() => client.deleteReaction(messageId, type)).called(1); + }, + ); + }); + test('`.update`', () async { const channelData = { 'name': 'Stream Team', diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index b89db0dc..b59937bb 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -1772,6 +1772,46 @@ void main() { verifyNoMoreInteractions(api.user); }); + test('`.partialUpdateUser`', () async { + const userId = 'test-user-id'; + + final set = {'color': 'yellow'}; + final unset = []; + + final partialUpdateRequest = PartialUpdateUserRequest( + id: userId, + set: set, + unset: unset, + ); + + final updatedUser = User( + id: userId, + extraData: {'color': set['color']}, + ); + + when(() => api.user.partialUpdateUsers([partialUpdateRequest])) + .thenAnswer( + (_) async => UpdateUsersResponse() + ..users = { + updatedUser.id: updatedUser, + }, + ); + + final res = await client.partialUpdateUser( + userId, + set: set, + unset: unset, + ); + + expect(res, isNotNull); + expect(res.users, {updatedUser.id: updatedUser}); + + verify( + () => api.user.partialUpdateUsers([partialUpdateRequest]), + ).called(1); + verifyNoMoreInteractions(api.user); + }); + test('`.banUser`', () async { const userId = 'test-user-id'; @@ -1956,23 +1996,109 @@ void main() { verifyNoMoreInteractions(api.channel); }); - test('`.sendReaction`', () async { - const messageId = 'test-message-id'; - const reactionType = 'like'; + group('`.sendReaction`', () { + test('`.sendReaction with default params`', () async { + const messageId = 'test-message-id'; + const reactionType = 'like'; + const extraData = {'score': 1}; - when(() => api.message.sendReaction(messageId, reactionType)) - .thenAnswer((_) async => SendReactionResponse() - ..message = Message(id: messageId) - ..reaction = Reaction(type: reactionType, messageId: messageId)); + when(() => api.message.sendReaction( + messageId, + reactionType, + extraData: extraData, + )).thenAnswer((_) async => SendReactionResponse() + ..message = Message(id: messageId) + ..reaction = Reaction(type: reactionType, messageId: messageId)); - final res = await client.sendReaction(messageId, reactionType); - expect(res, isNotNull); - expect(res.message.id, messageId); - expect(res.reaction.type, reactionType); - expect(res.reaction.messageId, messageId); + final res = await client.sendReaction(messageId, reactionType); + expect(res, isNotNull); + expect(res.message.id, messageId); + expect(res.reaction.type, reactionType); + expect(res.reaction.messageId, messageId); - verify(() => api.message.sendReaction(messageId, reactionType)).called(1); - verifyNoMoreInteractions(api.message); + verify(() => api.message.sendReaction( + messageId, + reactionType, + extraData: extraData, + )).called(1); + verifyNoMoreInteractions(api.message); + }); + + test('`.sendReaction with score`', () async { + const messageId = 'test-message-id'; + const reactionType = 'like'; + const score = 3; + const extraData = {'score': score}; + + when(() => api.message.sendReaction( + messageId, + reactionType, + extraData: extraData, + )).thenAnswer((_) async => SendReactionResponse() + ..message = Message(id: messageId) + ..reaction = Reaction( + type: reactionType, + messageId: messageId, + score: score, + )); + + final res = await client.sendReaction( + messageId, + reactionType, + score: score, + ); + expect(res, isNotNull); + expect(res.message.id, messageId); + expect(res.reaction.type, reactionType); + expect(res.reaction.messageId, messageId); + expect(res.reaction.score, score); + + verify(() => api.message.sendReaction( + messageId, + reactionType, + extraData: extraData, + )).called(1); + verifyNoMoreInteractions(api.message); + }); + + test('`.sendReaction with score passed in extradata also`', () async { + const messageId = 'test-message-id'; + const reactionType = 'like'; + const score = 3; + const extraDataScore = 5; + const extraData = {'score': extraDataScore}; + + when(() => api.message.sendReaction( + messageId, + reactionType, + extraData: extraData, + )).thenAnswer((_) async => SendReactionResponse() + ..message = Message(id: messageId) + ..reaction = Reaction( + type: reactionType, + messageId: messageId, + score: extraDataScore, + )); + + final res = await client.sendReaction( + messageId, + reactionType, + score: score, + extraData: extraData, + ); + expect(res, isNotNull); + expect(res.message.id, messageId); + expect(res.reaction.type, reactionType); + expect(res.reaction.messageId, messageId); + expect(res.reaction.score, extraDataScore); + + verify(() => api.message.sendReaction( + messageId, + reactionType, + extraData: extraData, + )).called(1); + verifyNoMoreInteractions(api.message); + }); }); test('`.deleteReaction`', () async { diff --git a/packages/stream_chat/test/src/core/api/channel_api_test.dart b/packages/stream_chat/test/src/core/api/channel_api_test.dart index 2403b44e..a7856c92 100644 --- a/packages/stream_chat/test/src/core/api/channel_api_test.dart +++ b/packages/stream_chat/test/src/core/api/channel_api_test.dart @@ -175,14 +175,14 @@ void main() { test('markAllRead', () async { const path = '/channels/read'; - when(() => client.post(path)).thenAnswer( + when(() => client.post(path, data: {})).thenAnswer( (_) async => successResponse(path, data: {})); final res = await channelApi.markAllRead(); expect(res, isNotNull); - verify(() => client.post(path)).called(1); + verify(() => client.post(path, data: {})).called(1); verifyNoMoreInteractions(client); }); diff --git a/packages/stream_chat/test/src/core/api/user_api_test.dart b/packages/stream_chat/test/src/core/api/user_api_test.dart index dbf83f2c..764386f8 100644 --- a/packages/stream_chat/test/src/core/api/user_api_test.dart +++ b/packages/stream_chat/test/src/core/api/user_api_test.dart @@ -82,4 +82,37 @@ void main() { verify(() => client.post(path, data: any(named: 'data'))).called(1); verifyNoMoreInteractions(client); }); + + test('partialUpdateUsers', () async { + const user = PartialUpdateUserRequest( + id: 'test-user-id', + set: {'color': 'yellow'}, + ); + + const path = '/users'; + + final updatedUser = {user.id: User(id: user.id, extraData: user.set!)}; + + when(() => client.patch(path, data: { + 'users': [user], + })).thenAnswer( + (_) async => successResponse( + path, + data: { + 'users': + updatedUser.map((key, value) => MapEntry(key, value.toJson())) + }, + ), + ); + + final res = await userApi.partialUpdateUsers([user]); + + expect(res, isNotNull); + expect(res.users.length, updatedUser.length); + + verify(() => client.patch(path, data: { + 'users': [user] + })).called(1); + verifyNoMoreInteractions(client); + }); } diff --git a/packages/stream_chat/test/src/matchers.dart b/packages/stream_chat/test/src/matchers.dart index e54b113d..102480cd 100644 --- a/packages/stream_chat/test/src/matchers.dart +++ b/packages/stream_chat/test/src/matchers.dart @@ -49,6 +49,7 @@ Matcher isSameMessageAs( bool matchSendingStatus = false, bool matchAttachments = false, bool matchAttachmentsUploadState = false, + bool matchParentId = false, }) => _IsSameMessageAs( targetMessage: targetMessage, @@ -57,6 +58,7 @@ Matcher isSameMessageAs( matchSendingStatus: matchSendingStatus, matchAttachments: matchAttachments, matchAttachmentsUploadState: matchAttachmentsUploadState, + matchParentId: matchParentId, ); class _IsSameMessageAs extends Matcher { @@ -67,6 +69,7 @@ class _IsSameMessageAs extends Matcher { this.matchSendingStatus = false, this.matchAttachments = false, this.matchAttachmentsUploadState = false, + this.matchParentId = false, }); final Message targetMessage; @@ -75,6 +78,7 @@ class _IsSameMessageAs extends Matcher { final bool matchSendingStatus; final bool matchAttachments; final bool matchAttachmentsUploadState; + final bool matchParentId; @override Description describe(Description description) => @@ -123,6 +127,9 @@ class _IsSameMessageAs extends Matcher { matches &= matchAttachments(); } + if (matchParentId) { + matches &= message.parentId == targetMessage.parentId; + } return matches; } } diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 6ecc7e60..3187e001 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -7,6 +7,22 @@ 🐞 Fixed +- Mentions overlay now doesn't overflow when not enough height available + +## 3.5.0 + +🐞 Fixed + +- [[#888]](https://github.com/GetStream/stream-chat-flutter/issues/888) Fix `unban` command not working in `MessageInput`. +- [[#805]](https://github.com/GetStream/stream-chat-flutter/issues/805) Updated chewie dependency version to 1.3.0 +- Fix `showScrollToBottom` in `MessageListView` not respecting false value. +- Fix default `Channel` route not opening from `ChannelListView` when `ChannelAvatar` is tapped + +## 3.4.0 +- Updated `stream_chat_flutter_core` dependency to [`3.4.0`](https://pub.dev/packages/stream_chat_flutter_core/changelog). + +🐞 Fixed + - SVG rendering fixes. - Use file extension instead of mimeType for downloading files. - [[#860]](https://github.com/GetStream/stream-chat-flutter/issues/860) CastError while compressing Videos. diff --git a/packages/stream_chat_flutter/example/android/app/build.gradle b/packages/stream_chat_flutter/example/android/app/build.gradle index fbd6268e..3974f949 100644 --- a/packages/stream_chat_flutter/example/android/app/build.gradle +++ b/packages/stream_chat_flutter/example/android/app/build.gradle @@ -26,7 +26,7 @@ apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion 30 + compileSdkVersion 31 sourceSets { main.java.srcDirs += 'src/main/kotlin' @@ -41,7 +41,7 @@ android { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.example.example" minSdkVersion 21 - targetSdkVersion 30 + targetSdkVersion 31 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } diff --git a/packages/stream_chat_flutter/example/android/app/src/main/AndroidManifest.xml b/packages/stream_chat_flutter/example/android/app/src/main/AndroidManifest.xml index 42c5fa1e..5e2f5ec0 100644 --- a/packages/stream_chat_flutter/example/android/app/src/main/AndroidManifest.xml +++ b/packages/stream_chat_flutter/example/android/app/src/main/AndroidManifest.xml @@ -16,6 +16,7 @@ android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" + android:exported="true" android:windowSoftInputMode="adjustResize">