Merge branch 'develop' into v4

This commit is contained in:
Salvatore Giordano
2022-03-07 11:45:04 +01:00
35 changed files with 1059 additions and 281 deletions
+31 -5
View File
@@ -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
+101 -69
View File
@@ -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<EmptyResponse> 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<SendReactionResponse> sendReaction(
Message message,
String type, {
int score = 1,
Map<String, Object?> 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<EmptyResponse> banUser(
String userID,
Map<String, dynamic> 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<EmptyResponse> 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<Message>.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<Message> get pinnedMessages => _channelState.pinnedMessages.toList();
List<Message> get pinnedMessages => _channelState.pinnedMessages;
/// Channel pinned message list as a stream.
Stream<List<Message>> get pinnedMessagesStream =>
channelStateStream.map((cs) => cs.pinnedMessages.toList());
Stream<List<Message>> 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);
}
@@ -1073,6 +1073,28 @@ class StreamChatClient {
Future<UpdateUsersResponse> updateUsers(List<User> 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<UpdateUsersResponse> partialUpdateUser(
String id, {
Map<String, Object?>? set,
List<String>? unset,
}) {
final user = PartialUpdateUserRequest(
id: id,
set: set,
unset: unset,
);
return partialUpdateUsers([user]);
}
/// Batch partial updates the [users].
Future<UpdateUsersResponse> partialUpdateUsers(
List<PartialUpdateUserRequest> users,
) =>
_chatApi.user.partialUpdateUsers(users);
/// Bans a user from all channels
Future<EmptyResponse> banUser(
String targetUserId, [
@@ -1157,15 +1179,22 @@ class StreamChatClient {
Future<SendReactionResponse> sendReaction(
String messageId,
String reactionType, {
int score = 1,
Map<String, Object?> 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<EmptyResponse> deleteReaction(
@@ -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<void> _retryMessage(Message message) async {
@@ -84,7 +84,10 @@ class ChannelApi {
/// Mark all channels for this user as read
Future<EmptyResponse> markAllRead() async {
final response = await _client.post('/channels/read');
final response = await _client.post(
'/channels/read',
data: {},
);
return EmptyResponse.fromJson(response.data);
}
@@ -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<String, Object?>? set;
/// Fields to unset.
final List<String>? unset;
/// Serialize model to json
Map<String, dynamic> toJson() => _$PartialUpdateUserRequestToJson(this);
@override
List<Object?> get props => [id, set, unset];
}
@@ -54,3 +54,14 @@ Map<String, dynamic> _$PaginationParamsToJson(PaginationParams instance) {
writeNotNull('id_lte', instance.lessThanOrEqual);
return val;
}
Map<String, dynamic> _$PartialUpdateUserRequestToJson(
PartialUpdateUserRequest instance) =>
<String, dynamic>{
'stringify': instance.stringify,
'hash_code': instance.hashCode,
'id': instance.id,
'set': instance.set,
'unset': instance.unset,
'props': instance.props,
};
@@ -46,4 +46,17 @@ class UserApi {
);
return UpdateUsersResponse.fromJson(response.data);
}
/// Batch partial update of [users].
Future<UpdateUsersResponse> partialUpdateUsers(
List<PartialUpdateUserRequest> users,
) async {
final response = await _client.patch(
'/users',
data: {
'users': users,
},
);
return UpdateUsersResponse.fromJson(response.data);
}
}
@@ -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<String, dynamic>) {
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);
@@ -7,6 +7,8 @@ import 'package:stream_chat/src/core/models/user.dart';
part 'channel_state.g.dart';
const _emptyPinnedMessages = <Message>[];
/// 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<Message>? messages,
List<Member>? members,
List<Message>? pinnedMessages,
List<Message> pinnedMessages = _emptyPinnedMessages,
int? watcherCount,
List<User>? watchers,
List<Read>? 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,
@@ -21,7 +21,7 @@ ChannelState _$ChannelStateFromJson(Map<String, dynamic> json) => ChannelState(
pinnedMessages: (json['pinned_messages'] as List<dynamic>?)
?.map((e) => Message.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
_emptyPinnedMessages,
watcherCount: json['watcher_count'] as int?,
watchers: (json['watchers'] as List<dynamic>?)
?.map((e) => User.fromJson(e as Map<String, dynamic>))
@@ -69,7 +69,6 @@ class User extends Equatable {
'online',
'banned',
'ban_expires',
'dashboard_ban_channel_cid',
'teams',
'language',
];
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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
@@ -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<StreamChatNetworkError>());
}
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<StreamChatNetworkError>());
}
verify(() => client.deleteReaction(messageId, type)).called(1);
},
);
});
test('`.update`', () async {
const channelData = {
'name': 'Stream Team',
@@ -1772,6 +1772,46 @@ void main() {
verifyNoMoreInteractions(api.user);
});
test('`.partialUpdateUser`', () async {
const userId = 'test-user-id';
final set = {'color': 'yellow'};
final unset = <String>[];
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 {
@@ -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: <String, dynamic>{}));
final res = await channelApi.markAllRead();
expect(res, isNotNull);
verify(() => client.post(path)).called(1);
verify(() => client.post(path, data: {})).called(1);
verifyNoMoreInteractions(client);
});
@@ -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);
});
}
@@ -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;
}
}