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 🐞 Fixed
- [[#857]](https://github.com/GetStream/stream-chat-flutter/issues/857) Channel now listens for member ban/unban and - [[#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. 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. - [[#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 - 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 🔄 Changed
- `client.location` is now deprecated in favor of the - `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. 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` - `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
- Added `client.enrichUrl` endpoint for enriching URLs with metadata. - 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. - Added `client.queryBannedUsers`, `channel.queryBannedUsers` endpoint for querying banned users.
## 3.3.1 ## 3.3.1
@@ -44,6 +64,12 @@
- Fixed user presence indicator not updating correctly. - Fixed user presence indicator not updating correctly.
- `ChannelEvent.membersCount` defaults to 0 avoiding parsing errors due to missing `members_count` field. - `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 ## 3.2.0
🐞 Fixed 🐞 Fixed
+101 -69
View File
@@ -419,7 +419,7 @@ class Channel {
if (index != -1) { if (index != -1) {
final newAttachments = [...message!.attachments]..[index] = attachment; final newAttachments = [...message!.attachments]..[index] = attachment;
final updatedMessage = message!.copyWith(attachments: newAttachments); final updatedMessage = message!.copyWith(attachments: newAttachments);
state?.addMessage(updatedMessage); state?.updateMessage(updatedMessage);
// updating original message for next iteration // updating original message for next iteration
message = message!.merge(updatedMessage); message = message!.merge(updatedMessage);
} }
@@ -525,7 +525,7 @@ class Channel {
).toList(), ).toList(),
); );
state!.addMessage(message); state!.updateMessage(message);
try { try {
if (message.attachments.any((it) => !it.uploadState.isSuccess)) { if (message.attachments.any((it) => !it.uploadState.isSuccess)) {
@@ -549,7 +549,7 @@ class Channel {
skipPush: skipPush, skipPush: skipPush,
skipEnrichUrl: skipEnrichUrl, skipEnrichUrl: skipEnrichUrl,
); );
state!.addMessage(response.message); state!.updateMessage(response.message);
if (cooldown > 0) cooldownStartedAt = DateTime.now(); if (cooldown > 0) cooldownStartedAt = DateTime.now();
return response; return response;
} catch (e) { } catch (e) {
@@ -588,7 +588,7 @@ class Channel {
).toList(), ).toList(),
); );
state?.addMessage(message); state?.updateMessage(message);
try { try {
if (message.attachments.any((it) => !it.uploadState.isSuccess)) { if (message.attachments.any((it) => !it.uploadState.isSuccess)) {
@@ -614,7 +614,7 @@ class Channel {
ownReactions: message.ownReactions, ownReactions: message.ownReactions,
); );
state?.addMessage(m); state?.updateMessage(m);
return response; return response;
} catch (e) { } catch (e) {
@@ -622,7 +622,7 @@ class Channel {
if (e.isRetriable) { if (e.isRetriable) {
state!._retryQueue.add([message]); state!._retryQueue.add([message]);
} else { } else {
state?.addMessage(originalMessage); state?.updateMessage(originalMessage);
} }
} }
rethrow; rethrow;
@@ -652,7 +652,7 @@ class Channel {
ownReactions: message.ownReactions, ownReactions: message.ownReactions,
); );
state?.addMessage(updatedMessage); state?.updateMessage(updatedMessage);
return response; return response;
} catch (e) { } catch (e) {
@@ -665,13 +665,18 @@ class Channel {
/// Deletes the [message] from the channel. /// Deletes the [message] from the channel.
Future<EmptyResponse> deleteMessage(Message message, {bool? hard}) async { Future<EmptyResponse> deleteMessage(Message message, {bool? hard}) async {
final hardDelete = hard ?? false;
// Directly deleting the local messages which are not yet sent to server // Directly deleting the local messages which are not yet sent to server
if (message.status == MessageSendingStatus.sending || if (message.status == MessageSendingStatus.sending ||
message.status == MessageSendingStatus.failed) { message.status == MessageSendingStatus.failed) {
state!.addMessage(message.copyWith( state!.deleteMessage(
type: 'deleted', message.copyWith(
status: MessageSendingStatus.sent, type: 'deleted',
)); status: MessageSendingStatus.sent,
),
hardDelete: hardDelete,
);
// Removing the attachments upload completer to stop the `sendMessage` // Removing the attachments upload completer to stop the `sendMessage`
// waiting for attachments to complete. // waiting for attachments to complete.
@@ -689,11 +694,14 @@ class Channel {
deletedAt: message.deletedAt ?? DateTime.now(), deletedAt: message.deletedAt ?? DateTime.now(),
); );
state?.addMessage(message); state?.deleteMessage(message, hardDelete: hardDelete);
final response = await _client.deleteMessage(message.id, hard: hard); 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; return response;
} catch (e) { } catch (e) {
@@ -833,6 +841,7 @@ class Channel {
Future<SendReactionResponse> sendReaction( Future<SendReactionResponse> sendReaction(
Message message, Message message,
String type, { String type, {
int score = 1,
Map<String, Object?> extraData = const {}, Map<String, Object?> extraData = const {},
bool enforceUnique = false, bool enforceUnique = false,
}) async { }) async {
@@ -851,7 +860,7 @@ class Channel {
createdAt: now, createdAt: now,
type: type, type: type,
user: user, user: user,
score: 1, score: score,
extraData: extraData, extraData: extraData,
); );
@@ -882,19 +891,20 @@ class Channel {
ownReactions: ownReactions, ownReactions: ownReactions,
); );
state?.addMessage(newMessage); state?.updateMessage(newMessage);
try { try {
final reactionResp = await _client.sendReaction( final reactionResp = await _client.sendReaction(
messageId, messageId,
type, type,
score: score,
extraData: extraData, extraData: extraData,
enforceUnique: enforceUnique, enforceUnique: enforceUnique,
); );
return reactionResp; return reactionResp;
} catch (_) { } catch (_) {
// Reset the message if the update fails // Reset the message if the update fails
state?.addMessage(message); state?.updateMessage(message);
rethrow; rethrow;
} }
} }
@@ -934,7 +944,7 @@ class Channel {
ownReactions: ownReactions, ownReactions: ownReactions,
); );
state?.addMessage(newMessage); state?.updateMessage(newMessage);
try { try {
final deleteResponse = await _client.deleteReaction( final deleteResponse = await _client.deleteReaction(
@@ -944,7 +954,7 @@ class Channel {
return deleteResponse; return deleteResponse;
} catch (_) { } catch (_) {
// Reset the message if the update fails // Reset the message if the update fails
state?.addMessage(message); state?.updateMessage(message);
rethrow; rethrow;
} }
} }
@@ -1101,7 +1111,7 @@ class Channel {
// update the passed message with response message // update the passed message with response message
if (res.message != null) { if (res.message != null) {
state!.addMessage(res.message!); state!.updateMessage(res.message!);
} else { } else {
// remove the passed message if response does // remove the passed message if response does
// not contain message // not contain message
@@ -1321,7 +1331,7 @@ class Channel {
} }
/// Bans the user with given [userID] from the 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( Future<EmptyResponse> banUser(
String userID, String userID,
Map<String, dynamic> options, Map<String, dynamic> options,
@@ -1343,7 +1353,9 @@ class Channel {
} }
/// Remove the ban for the user with given [userID] in the 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); Future<EmptyResponse> unbanUser(String userID) => unbanMember(userID);
/// Remove the ban for the member with given [userID] in the channel. /// Remove the ban for the member with given [userID] in the channel.
@@ -1717,7 +1729,9 @@ class ChannelClientState {
void _listenReactionDeleted() { void _listenReactionDeleted() {
_subscriptions.add(_channel.on(EventType.reactionDeleted).listen((event) { _subscriptions.add(_channel.on(EventType.reactionDeleted).listen((event) {
final oldMessage = 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 reaction = event.reaction;
final ownReactions = oldMessage?.ownReactions final ownReactions = oldMessage?.ownReactions
?.whereNot((it) => ?.whereNot((it) =>
@@ -1730,18 +1744,20 @@ class ChannelClientState {
final message = event.message!.copyWith( final message = event.message!.copyWith(
ownReactions: ownReactions, ownReactions: ownReactions,
); );
addMessage(message); updateMessage(message);
})); }));
} }
void _listenReactions() { void _listenReactions() {
_subscriptions.add(_channel.on(EventType.reactionNew).listen((event) { _subscriptions.add(_channel.on(EventType.reactionNew).listen((event) {
final oldMessage = 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( final message = event.message!.copyWith(
ownReactions: oldMessage?.ownReactions, ownReactions: oldMessage?.ownReactions,
); );
addMessage(message); updateMessage(message);
})); }));
} }
@@ -1753,12 +1769,13 @@ class ChannelClientState {
) )
.listen((event) { .listen((event) {
final oldMessage = 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( final message = event.message!.copyWith(
ownReactions: oldMessage?.ownReactions, ownReactions: oldMessage?.ownReactions,
); );
addMessage(message); updateMessage(message);
if (message.pinned) { if (message.pinned) {
_channelState = _channelState.copyWith( _channelState = _channelState.copyWith(
@@ -1775,9 +1792,9 @@ class ChannelClientState {
_subscriptions.add(_channel.on(EventType.messageDeleted).listen((event) { _subscriptions.add(_channel.on(EventType.messageDeleted).listen((event) {
final message = event.message!; final message = event.message!;
if (event.hardDelete == true) { if (event.hardDelete == true) {
removeMessage(message, hardDelete: true); removeMessage(message);
} else { } else {
addMessage(message); updateMessage(message);
} }
})); }));
} }
@@ -1792,7 +1809,7 @@ class ChannelClientState {
final message = event.message!; final message = event.message!;
if (isUpToDate || if (isUpToDate ||
(message.parentId != null && message.showInChannel != true)) { (message.parentId != null && message.showInChannel != true)) {
addMessage(message); updateMessage(message);
} }
if (_countMessageAsUnread(message)) { if (_countMessageAsUnread(message)) {
@@ -1802,9 +1819,13 @@ class ChannelClientState {
} }
/// Add a [message] to this [channelState]. /// 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) { 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); final oldIndex = newMessages.indexWhere((m) => m.id == message.id);
if (oldIndex != -1) { if (oldIndex != -1) {
Message? m; Message? m;
@@ -1819,8 +1840,24 @@ class ChannelClientState {
newMessages.add(message); 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( _channelState = _channelState.copyWith(
messages: newMessages..sort(_sortByCreatedAt), messages: newMessages..sort(_sortByCreatedAt),
pinnedMessages: newPinnedMessages,
channel: _channelState.channel?.copyWith( channel: _channelState.channel?.copyWith(
lastMessageAt: message.createdAt, lastMessageAt: message.createdAt,
), ),
@@ -1833,41 +1870,35 @@ class ChannelClientState {
} }
/// Remove a [message] from this [channelState]. /// Remove a [message] from this [channelState].
void removeMessage(Message message, {bool hardDelete = false}) { void removeMessage(Message message) {
final parentId = message.parentId; final parentId = message.parentId;
// i.e. it's a thread message // i.e. it's a thread message, Remove it
// 1. Remove the thread message
// 2. Reduce total reply count of parent message
if (parentId != null) { if (parentId != null) {
final allMessages = [...messages]; final newThreads = {...threads};
final parentMessage = allMessages.firstWhereOrNull( // Early return in case the thread is not available
(it) => it.id == parentId, if (!newThreads.containsKey(parentId)) return;
);
// return if message not available in the memory _threads = newThreads
if (parentMessage == null) return; ..update(
final replyCount = parentMessage.replyCount; parentId,
// return if reply count is null or zero (messages) => messages..removeWhere((e) => e.id == message.id),
if (replyCount == null || replyCount == 0) return; );
addMessage(parentMessage.copyWith(replyCount: replyCount - 1)); // Early return if the thread message is not shown in channel.
updateThreadInfo( if (message.showInChannel == false) return;
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);
}
} }
// 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() { void _listenReadEvents() {
@@ -1913,11 +1944,12 @@ class ChannelClientState {
.distinct(const ListEquality().equals); .distinct(const ListEquality().equals);
/// Channel pinned message list. /// Channel pinned message list.
List<Message> get pinnedMessages => _channelState.pinnedMessages.toList(); List<Message> get pinnedMessages => _channelState.pinnedMessages;
/// Channel pinned message list as a stream. /// Channel pinned message list as a stream.
Stream<List<Message>> get pinnedMessagesStream => Stream<List<Message>> get pinnedMessagesStream => channelStateStream
channelStateStream.map((cs) => cs.pinnedMessages.toList()); .map((cs) => cs.pinnedMessages)
.distinct(const ListEquality().equals);
/// Get channel last message. /// Get channel last message.
Message? get lastMessage => Message? get lastMessage =>
@@ -2228,7 +2260,7 @@ class ChannelClientState {
.toList(); .toList();
updateChannelState(_channelState.copyWith( updateChannelState(_channelState.copyWith(
pinnedMessages: pinnedMessages.where(_pinIsValid()).toList(), pinnedMessages: pinnedMessages.where(_pinIsValid).toList(),
messages: expiredMessages, messages: expiredMessages,
)); ));
} }
@@ -2265,7 +2297,7 @@ class ChannelClientState {
} }
} }
bool Function(Message) _pinIsValid() { bool _pinIsValid(Message message) {
final now = DateTime.now(); 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) => Future<UpdateUsersResponse> updateUsers(List<User> users) =>
_chatApi.user.updateUsers(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 /// Bans a user from all channels
Future<EmptyResponse> banUser( Future<EmptyResponse> banUser(
String targetUserId, [ String targetUserId, [
@@ -1157,15 +1179,22 @@ class StreamChatClient {
Future<SendReactionResponse> sendReaction( Future<SendReactionResponse> sendReaction(
String messageId, String messageId,
String reactionType, { String reactionType, {
int score = 1,
Map<String, Object?> extraData = const {}, Map<String, Object?> extraData = const {},
bool enforceUnique = false, bool enforceUnique = false,
}) => }) {
_chatApi.message.sendReaction( final _extraData = {
messageId, 'score': score,
reactionType, ...extraData,
extraData: extraData, };
enforceUnique: enforceUnique,
); return _chatApi.message.sendReaction(
messageId,
reactionType,
extraData: _extraData,
enforceUnique: enforceUnique,
);
}
/// Delete a [reactionType] from this [messageId] /// Delete a [reactionType] from this [messageId]
Future<EmptyResponse> deleteReaction( Future<EmptyResponse> deleteReaction(
@@ -159,7 +159,7 @@ class RetryQueue {
: message.status == MessageSendingStatus.updating : message.status == MessageSendingStatus.updating
? MessageSendingStatus.failed_update ? MessageSendingStatus.failed_update
: MessageSendingStatus.failed_delete; : MessageSendingStatus.failed_delete;
channel.state?.addMessage(message.copyWith(status: newStatus)); channel.state?.updateMessage(message.copyWith(status: newStatus));
} }
Future<void> _retryMessage(Message message) async { Future<void> _retryMessage(Message message) async {
@@ -84,7 +84,10 @@ class ChannelApi {
/// Mark all channels for this user as read /// Mark all channels for this user as read
Future<EmptyResponse> markAllRead() async { Future<EmptyResponse> markAllRead() async {
final response = await _client.post('/channels/read'); final response = await _client.post(
'/channels/read',
data: {},
);
return EmptyResponse.fromJson(response.data); return EmptyResponse.fromJson(response.data);
} }
@@ -156,3 +156,29 @@ class PaginationParams extends Equatable {
lessThanOrEqual, 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); writeNotNull('id_lte', instance.lessThanOrEqual);
return val; 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); 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, DioError err,
ErrorInterceptorHandler handler, ErrorInterceptorHandler handler,
) async { ) async {
ErrorResponse? error;
final data = err.response?.data; final data = err.response?.data;
if (data != null) error = ErrorResponse.fromJson(data); if (data == null || data is! Map<String, dynamic>) {
if (error?.code == ChatErrorCode.tokenExpired.code) { return handler.next(err);
}
final error = ErrorResponse.fromJson(data);
if (error.code == ChatErrorCode.tokenExpired.code) {
if (_tokenManager.isStatic) return handler.next(err); if (_tokenManager.isStatic) return handler.next(err);
_client.lock(); _client.lock();
await _tokenManager.loadToken(refresh: true); await _tokenManager.loadToken(refresh: true);
@@ -7,6 +7,8 @@ import 'package:stream_chat/src/core/models/user.dart';
part 'channel_state.g.dart'; part 'channel_state.g.dart';
const _emptyPinnedMessages = <Message>[];
/// The class that contains the information about a channel /// The class that contains the information about a channel
@JsonSerializable() @JsonSerializable()
class ChannelState { class ChannelState {
@@ -15,7 +17,7 @@ class ChannelState {
this.channel, this.channel,
this.messages = const [], this.messages = const [],
this.members = const [], this.members = const [],
this.pinnedMessages = const [], this.pinnedMessages = _emptyPinnedMessages,
this.watcherCount, this.watcherCount,
this.watchers = const [], this.watchers = const [],
this.read = const [], this.read = const [],
@@ -54,7 +56,7 @@ class ChannelState {
ChannelModel? channel, ChannelModel? channel,
List<Message>? messages, List<Message>? messages,
List<Member>? members, List<Member>? members,
List<Message>? pinnedMessages, List<Message> pinnedMessages = _emptyPinnedMessages,
int? watcherCount, int? watcherCount,
List<User>? watchers, List<User>? watchers,
List<Read>? read, List<Read>? read,
@@ -63,7 +65,11 @@ class ChannelState {
channel: channel ?? this.channel, channel: channel ?? this.channel,
messages: messages ?? this.messages, messages: messages ?? this.messages,
members: members ?? this.members, 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, watcherCount: watcherCount ?? this.watcherCount,
watchers: watchers ?? this.watchers, watchers: watchers ?? this.watchers,
read: read ?? this.read, read: read ?? this.read,
@@ -21,7 +21,7 @@ ChannelState _$ChannelStateFromJson(Map<String, dynamic> json) => ChannelState(
pinnedMessages: (json['pinned_messages'] as List<dynamic>?) pinnedMessages: (json['pinned_messages'] as List<dynamic>?)
?.map((e) => Message.fromJson(e as Map<String, dynamic>)) ?.map((e) => Message.fromJson(e as Map<String, dynamic>))
.toList() ?? .toList() ??
const [], _emptyPinnedMessages,
watcherCount: json['watcher_count'] as int?, watcherCount: json['watcher_count'] as int?,
watchers: (json['watchers'] as List<dynamic>?) watchers: (json['watchers'] as List<dynamic>?)
?.map((e) => User.fromJson(e as Map<String, dynamic>)) ?.map((e) => User.fromJson(e as Map<String, dynamic>))
@@ -69,7 +69,6 @@ class User extends Equatable {
'online', 'online',
'banned', 'banned',
'ban_expires', 'ban_expires',
'dashboard_ban_channel_cid',
'teams', 'teams',
'language', 'language',
]; ];
+1 -1
View File
@@ -3,4 +3,4 @@ import 'package:stream_chat/src/client/client.dart';
/// Current package version /// Current package version
/// Used in [StreamChatClient] to build the `x-stream-client` header /// Used in [StreamChatClient] to build the `x-stream-client` header
// ignore: constant_identifier_names // 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 name: stream_chat
homepage: https://getstream.io/ homepage: https://getstream.io/
description: The official Dart client for Stream Chat, a service for building chat applications. 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 repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
@@ -1130,6 +1130,135 @@ void main() {
verify(() => client.sendReaction(message.id, type)).called(1); 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( test(
'should restore previous message if `client.sendReaction` throws', 'should restore previous message if `client.sendReaction` throws',
() async { () 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`', () { group('`.deleteReaction`', () {
test('should work fine', () async { test('should work fine', () async {
const userId = 'test-user-id'; 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 { test('`.update`', () async {
const channelData = { const channelData = {
'name': 'Stream Team', 'name': 'Stream Team',
@@ -1772,6 +1772,46 @@ void main() {
verifyNoMoreInteractions(api.user); 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 { test('`.banUser`', () async {
const userId = 'test-user-id'; const userId = 'test-user-id';
@@ -1956,23 +1996,109 @@ void main() {
verifyNoMoreInteractions(api.channel); verifyNoMoreInteractions(api.channel);
}); });
test('`.sendReaction`', () async { group('`.sendReaction`', () {
const messageId = 'test-message-id'; test('`.sendReaction with default params`', () async {
const reactionType = 'like'; const messageId = 'test-message-id';
const reactionType = 'like';
const extraData = {'score': 1};
when(() => api.message.sendReaction(messageId, reactionType)) when(() => api.message.sendReaction(
.thenAnswer((_) async => SendReactionResponse() messageId,
..message = Message(id: messageId) reactionType,
..reaction = Reaction(type: reactionType, messageId: messageId)); extraData: extraData,
)).thenAnswer((_) async => SendReactionResponse()
..message = Message(id: messageId)
..reaction = Reaction(type: reactionType, messageId: messageId));
final res = await client.sendReaction(messageId, reactionType); final res = await client.sendReaction(messageId, reactionType);
expect(res, isNotNull); expect(res, isNotNull);
expect(res.message.id, messageId); expect(res.message.id, messageId);
expect(res.reaction.type, reactionType); expect(res.reaction.type, reactionType);
expect(res.reaction.messageId, messageId); expect(res.reaction.messageId, messageId);
verify(() => api.message.sendReaction(messageId, reactionType)).called(1); verify(() => api.message.sendReaction(
verifyNoMoreInteractions(api.message); 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 { test('`.deleteReaction`', () async {
@@ -175,14 +175,14 @@ void main() {
test('markAllRead', () async { test('markAllRead', () async {
const path = '/channels/read'; const path = '/channels/read';
when(() => client.post(path)).thenAnswer( when(() => client.post(path, data: {})).thenAnswer(
(_) async => successResponse(path, data: <String, dynamic>{})); (_) async => successResponse(path, data: <String, dynamic>{}));
final res = await channelApi.markAllRead(); final res = await channelApi.markAllRead();
expect(res, isNotNull); expect(res, isNotNull);
verify(() => client.post(path)).called(1); verify(() => client.post(path, data: {})).called(1);
verifyNoMoreInteractions(client); verifyNoMoreInteractions(client);
}); });
@@ -82,4 +82,37 @@ void main() {
verify(() => client.post(path, data: any(named: 'data'))).called(1); verify(() => client.post(path, data: any(named: 'data'))).called(1);
verifyNoMoreInteractions(client); 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 matchSendingStatus = false,
bool matchAttachments = false, bool matchAttachments = false,
bool matchAttachmentsUploadState = false, bool matchAttachmentsUploadState = false,
bool matchParentId = false,
}) => }) =>
_IsSameMessageAs( _IsSameMessageAs(
targetMessage: targetMessage, targetMessage: targetMessage,
@@ -57,6 +58,7 @@ Matcher isSameMessageAs(
matchSendingStatus: matchSendingStatus, matchSendingStatus: matchSendingStatus,
matchAttachments: matchAttachments, matchAttachments: matchAttachments,
matchAttachmentsUploadState: matchAttachmentsUploadState, matchAttachmentsUploadState: matchAttachmentsUploadState,
matchParentId: matchParentId,
); );
class _IsSameMessageAs extends Matcher { class _IsSameMessageAs extends Matcher {
@@ -67,6 +69,7 @@ class _IsSameMessageAs extends Matcher {
this.matchSendingStatus = false, this.matchSendingStatus = false,
this.matchAttachments = false, this.matchAttachments = false,
this.matchAttachmentsUploadState = false, this.matchAttachmentsUploadState = false,
this.matchParentId = false,
}); });
final Message targetMessage; final Message targetMessage;
@@ -75,6 +78,7 @@ class _IsSameMessageAs extends Matcher {
final bool matchSendingStatus; final bool matchSendingStatus;
final bool matchAttachments; final bool matchAttachments;
final bool matchAttachmentsUploadState; final bool matchAttachmentsUploadState;
final bool matchParentId;
@override @override
Description describe(Description description) => Description describe(Description description) =>
@@ -123,6 +127,9 @@ class _IsSameMessageAs extends Matcher {
matches &= matchAttachments(); matches &= matchAttachments();
} }
if (matchParentId) {
matches &= message.parentId == targetMessage.parentId;
}
return matches; return matches;
} }
} }
+16
View File
@@ -7,6 +7,22 @@
🐞 Fixed 🐞 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. - SVG rendering fixes.
- Use file extension instead of mimeType for downloading files. - Use file extension instead of mimeType for downloading files.
- [[#860]](https://github.com/GetStream/stream-chat-flutter/issues/860) CastError while compressing Videos. - [[#860]](https://github.com/GetStream/stream-chat-flutter/issues/860) CastError while compressing Videos.
@@ -26,7 +26,7 @@ apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
android { android {
compileSdkVersion 30 compileSdkVersion 31
sourceSets { sourceSets {
main.java.srcDirs += 'src/main/kotlin' 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). // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "com.example.example" applicationId "com.example.example"
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 30 targetSdkVersion 31
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
} }
@@ -16,6 +16,7 @@
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:exported="true"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as <!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user the Android process has started. This theme is visible to the user
@@ -1,5 +1,5 @@
buildscript { buildscript {
ext.kotlin_version = '1.5.20' ext.kotlin_version = '1.6.0'
repositories { repositories {
google() google()
jcenter() jcenter()
@@ -592,7 +592,9 @@ class _ChannelListViewState extends State<ChannelListView> {
child: ChannelPreview( child: ChannelPreview(
onLongPress: widget.onChannelLongPress, onLongPress: widget.onChannelLongPress,
channel: channel, channel: channel,
onImageTap: () => widget.onImageTap?.call(channel), onImageTap: widget.onImageTap != null
? () => widget.onImageTap!(channel)
: null,
onTap: (channel) => onTap(channel, widget.channelWidget), onTap: (channel) => onTap(channel, widget.channelWidget),
), ),
), ),
@@ -605,7 +607,7 @@ class _ChannelListViewState extends State<ChannelListView> {
if (widget.onChannelTap != null) { if (widget.onChannelTap != null) {
onTap = widget.onChannelTap!; onTap = widget.onChannelTap!;
} else { } else {
onTap = (client, _) { onTap = (channel, _) {
if (widget.channelWidget == null) { if (widget.channelWidget == null) {
return; return;
} }
@@ -613,7 +615,7 @@ class _ChannelListViewState extends State<ChannelListView> {
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => StreamChannel( builder: (context) => StreamChannel(
channel: client, channel: channel,
child: widget.channelWidget!, child: widget.channelWidget!,
), ),
), ),
@@ -65,24 +65,35 @@ class FullScreenMedia extends StatefulWidget {
class _FullScreenMediaState extends State<FullScreenMedia> class _FullScreenMediaState extends State<FullScreenMedia>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
bool _optionsShown = true; late final AnimationController _animationController;
late final AnimationController _controller;
late final PageController _pageController; late final PageController _pageController;
late int _currentPage; late final _curvedAnimation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeOut,
reverseCurve: Curves.easeIn,
);
final _opacityTween = Tween<double>(begin: 1, end: 0);
late final _opacityAnimation = _opacityTween.animate(
CurvedAnimation(
parent: _animationController,
curve: const Interval(0, 0.6, curve: Curves.easeOut),
),
);
late final ValueNotifier<int> _currentPage = ValueNotifier(widget.startIndex);
final videoPackages = <String, VideoPackage>{}; final videoPackages = <String, VideoPackage>{};
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_controller = AnimationController( _animationController = AnimationController(
vsync: this, vsync: this,
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
); );
_pageController = PageController(initialPage: widget.startIndex); _pageController = PageController(initialPage: widget.startIndex);
_currentPage = widget.startIndex;
for (var i = 0; i < widget.mediaAttachments.length; i++) { for (var i = 0; i < widget.mediaAttachments.length; i++) {
final attachment = widget.mediaAttachments[i]; final attachment = widget.mediaAttachments[i];
if (attachment.type != 'video') continue; if (attachment.type != 'video') continue;
@@ -116,41 +127,38 @@ class _FullScreenMediaState extends State<FullScreenMedia>
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
body: Stack( body: Stack(
children: [ children: [
AnimatedBuilder( PageView.builder(
animation: _controller, controller: _pageController,
builder: (context, snapshot) => PageView.builder( onPageChanged: (val) {
controller: _pageController, _currentPage.value = val;
onPageChanged: (val) {
setState(() {
_currentPage = val;
});
if (videoPackages.isEmpty) { if (videoPackages.isEmpty) {
return; return;
}
final currentAttachment = widget.mediaAttachments[val];
for (final e in videoPackages.values) {
if (e._attachment != currentAttachment) {
e._chewieController?.pause();
} }
}
final currentAttachment = widget.mediaAttachments[val]; if (widget.autoplayVideos &&
currentAttachment.type == 'video') {
for (final e in videoPackages.values) { final controller = videoPackages[currentAttachment.id]!;
if (e._attachment != currentAttachment) { controller._chewieController?.play();
e._chewieController?.pause(); }
} },
} itemBuilder: (context, index) {
final attachment = widget.mediaAttachments[index];
if (widget.autoplayVideos && if (attachment.type == 'image' || attachment.type == 'giphy') {
currentAttachment.type == 'video') { final imageUrl = attachment.imageUrl ??
final controller = videoPackages[currentAttachment.id]!; attachment.assetUrl ??
controller._chewieController?.play(); attachment.thumbUrl;
} return AnimatedBuilder(
}, animation: _curvedAnimation,
itemBuilder: (context, index) { builder: (context, child) => PhotoView(
final attachment = widget.mediaAttachments[index];
if (attachment.type == 'image' ||
attachment.type == 'giphy') {
final imageUrl = attachment.imageUrl ??
attachment.assetUrl ??
attachment.thumbUrl;
return PhotoView(
loadingBuilder: (context, image) => const Offstage(), loadingBuilder: (context, image) => const Offstage(),
imageProvider: (imageUrl == null && imageProvider: (imageUrl == null &&
attachment.localUri != null && attachment.localUri != null &&
@@ -166,97 +174,91 @@ class _FullScreenMediaState extends State<FullScreenMedia>
color: ColorTween( color: ColorTween(
begin: ChannelHeaderTheme.of(context).color, begin: ChannelHeaderTheme.of(context).color,
end: Colors.black, end: Colors.black,
).lerp(_controller.value), ).lerp(_curvedAnimation.value),
), ),
onTapUp: (a, b, c) { onTapUp: (a, b, c) {
setState(() { if (_animationController.isCompleted) {
_optionsShown = !_optionsShown; _animationController.reverse();
});
if (_controller.isCompleted) {
_controller.reverse();
} else { } else {
_controller.forward(); _animationController.forward();
} }
}, },
); ),
} else if (attachment.type == 'video') { );
final controller = videoPackages[attachment.id]!; } else if (attachment.type == 'video') {
if (!controller.initialized) { final controller = videoPackages[attachment.id]!;
return const Center( if (!controller.initialized) {
child: CircularProgressIndicator(), return const Center(
); child: CircularProgressIndicator(),
}
return InkWell(
onTap: () {
setState(() {
_optionsShown = !_optionsShown;
});
if (_controller.isCompleted) {
_controller.reverse();
} else {
_controller.forward();
}
},
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 50,
),
child: Chewie(
controller: controller.chewieController!,
),
),
); );
} }
return Container(); return InkWell(
}, onTap: () {
itemCount: widget.mediaAttachments.length, if (_animationController.isCompleted) {
), _animationController.reverse();
), } else {
AnimatedOpacity( _animationController.forward();
opacity: _optionsShown ? 1.0 : 0.0, }
duration: const Duration(milliseconds: 300), },
child: Column( child: Padding(
mainAxisAlignment: MainAxisAlignment.spaceBetween, padding: const EdgeInsets.symmetric(
children: [ vertical: 50,
GalleryHeader( ),
userName: widget.userName, child: Chewie(
sentAt: context.translations.sentAtText( controller: controller.chewieController!,
date: widget.message.createdAt, ),
time: widget.message.createdAt,
), ),
onBackPressed: () { );
Navigator.of(context).pop(); }
}, return const SizedBox();
message: widget.message, },
currentIndex: _currentPage, itemCount: widget.mediaAttachments.length,
onShowMessage: () { ),
widget.onShowMessage?.call( FadeTransition(
widget.message, opacity: _opacityAnimation,
StreamChannel.of(context).channel, child: ValueListenableBuilder<int>(
); valueListenable: _currentPage,
}, builder: (context, value, child) => Column(
attachmentActionsModalBuilder: mainAxisAlignment: MainAxisAlignment.spaceBetween,
widget.attachmentActionsModalBuilder, children: [
), GalleryHeader(
if (!widget.message.isEphemeral) userName: widget.userName,
GalleryFooter( sentAt: context.translations.sentAtText(
currentPage: _currentPage, date: widget.message.createdAt,
totalPages: widget.mediaAttachments.length, time: widget.message.createdAt,
mediaAttachments: widget.mediaAttachments, ),
onBackPressed: () {
Navigator.of(context).pop();
},
message: widget.message, message: widget.message,
mediaSelectedCallBack: (val) { currentIndex: value,
setState(() { onShowMessage: () {
_currentPage = val; widget.onShowMessage?.call(
widget.message,
StreamChannel.of(context).channel,
);
},
attachmentActionsModalBuilder:
widget.attachmentActionsModalBuilder,
),
if (!widget.message.isEphemeral)
GalleryFooter(
currentPage: value,
totalPages: widget.mediaAttachments.length,
mediaAttachments: widget.mediaAttachments,
message: widget.message,
mediaSelectedCallBack: (val) {
_currentPage.value = val;
_pageController.animateToPage( _pageController.animateToPage(
val, val,
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut, curve: Curves.easeInOut,
); );
Navigator.pop(context); Navigator.pop(context);
}); },
}, ),
), ],
], ),
), ),
), ),
], ],
@@ -264,9 +266,11 @@ class _FullScreenMediaState extends State<FullScreenMedia>
); );
@override @override
void dispose() async { void dispose() {
_animationController.dispose();
_pageController.dispose();
for (final package in videoPackages.values) { for (final package in videoPackages.values) {
await package.dispose(); package.dispose();
} }
super.dispose(); super.dispose();
} }
@@ -356,6 +356,7 @@ class MessageInputState extends State<MessageInput>
with RestorationMixin<MessageInput> { with RestorationMixin<MessageInput> {
final _imagePicker = ImagePicker(); final _imagePicker = ImagePicker();
late FocusNode _focusNode = widget.focusNode ?? FocusNode(); late FocusNode _focusNode = widget.focusNode ?? FocusNode();
late final _isInternalFocusNode = widget.focusNode == null;
bool _inputEnabled = true; bool _inputEnabled = true;
bool get _commandEnabled => _effectiveController.value.command != null; bool get _commandEnabled => _effectiveController.value.command != null;
@@ -1168,26 +1169,31 @@ class MessageInputState extends State<MessageInput>
}; };
} }
return UserMentionsOverlay( return LayoutBuilder(
query: query, builder: (context, snapshot) => UserMentionsOverlay(
mentionAllAppUsers: widget.mentionAllAppUsers, query: query,
client: StreamChat.of(context).client, mentionAllAppUsers: widget.mentionAllAppUsers,
channel: channel, client: StreamChat.of(context).client,
size: Size(renderObject.size.width - 16, 400), channel: channel,
mentionsTileBuilder: tileBuilder, size: Size(
onMentionUserTap: (user) { renderObject.size.width - 16,
_effectiveController.addMentionedUser(user); min(400, (snapshot.maxHeight - renderObject.size.height - 16).abs()),
splits[splits.length - 1] = user.name; ),
final rejoin = splits.join('@'); mentionsTileBuilder: tileBuilder,
onMentionUserTap: (user) {
_effectiveController.addMentionedUser(user);
splits[splits.length - 1] = user.name;
final rejoin = splits.join('@');
_effectiveController.text = rejoin + _effectiveController.text = rejoin +
_effectiveController.text.substring( _effectiveController.text.substring(
_effectiveController.selectionStart, _effectiveController.selectionStart,
); );
_onChangedDebounced.cancel(); _onChangedDebounced.cancel();
setState(() => _showMentionsOverlay = false); setState(() => _showMentionsOverlay = false);
}, },
),
); );
} }
@@ -1816,6 +1822,7 @@ class MessageInputState extends State<MessageInput>
.removeListener(_onChangedDebounced); .removeListener(_onChangedDebounced);
_controller?.dispose(); _controller?.dispose();
_focusNode.removeListener(_focusNodeListener); _focusNode.removeListener(_focusNodeListener);
if (_isInternalFocusNode) _focusNode.dispose();
_stopSlowMode(); _stopSlowMode();
_onChangedDebounced.cancel(); _onChangedDebounced.cancel();
super.dispose(); super.dispose();
@@ -709,20 +709,21 @@ class _MessageListViewState extends State<MessageListView> {
); );
}, },
), ),
BetterStreamBuilder<bool>( if (widget.showScrollToBottom)
stream: streamChannel!.channel.state!.isUpToDateStream, BetterStreamBuilder<bool>(
initialData: streamChannel!.channel.state!.isUpToDate, stream: streamChannel!.channel.state!.isUpToDateStream,
builder: (context, snapshot) => ValueListenableBuilder<bool>( initialData: streamChannel!.channel.state!.isUpToDate,
valueListenable: _showScrollToBottom, builder: (context, snapshot) => ValueListenableBuilder<bool>(
child: _buildScrollToBottom(), valueListenable: _showScrollToBottom,
builder: (context, value, child) { child: _buildScrollToBottom(),
if (!snapshot || value) { builder: (context, value, child) {
return child!; if (!snapshot || value) {
} return child!;
return const Offstage(); }
}, return const Offstage();
},
),
), ),
),
if (widget.showFloatingDateDivider) if (widget.showFloatingDateDivider)
_buildFloatingDateDivider(itemCount), _buildFloatingDateDivider(itemCount),
], ],
@@ -84,9 +84,10 @@ class MessageText extends StatelessWidget {
String _replaceMentions(String text) { String _replaceMentions(String text) {
var messageTextToRender = text; var messageTextToRender = text;
for (final user in message.mentionedUsers.toSet()) { for (final user in message.mentionedUsers.toSet()) {
final userId = user.id;
final userName = user.name; final userName = user.name;
messageTextToRender = messageTextToRender.replaceAll( messageTextToRender = messageTextToRender.replaceAll(
'@$userName', '@$userId',
'[@$userName](@${userName.replaceAll(' ', '')})', '[@$userName](@${userName.replaceAll(' ', '')})',
); );
} }
+6 -6
View File
@@ -1,7 +1,7 @@
name: stream_chat_flutter name: stream_chat_flutter
homepage: https://github.com/GetStream/stream-chat-flutter homepage: https://github.com/GetStream/stream-chat-flutter
description: Stream Chat official Flutter SDK. Build your own chat experience using Dart and Flutter. description: Stream Chat official Flutter SDK. Build your own chat experience using Dart and Flutter.
version: 3.3.2 version: 3.5.0
repository: https://github.com/GetStream/stream-chat-flutter repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
@@ -12,11 +12,11 @@ environment:
dependencies: dependencies:
cached_network_image: ^3.0.0 cached_network_image: ^3.0.0
characters: ^1.1.0 characters: ^1.1.0
chewie: ^1.2.0 chewie: ^1.3.0
collection: ^1.15.0 collection: ^1.15.0
diacritic: ^0.1.3 diacritic: ^0.1.3
dio: ^4.0.0 dio: ^4.0.0
ezanimation: ^0.5.0 ezanimation: ^0.6.0
file_picker: ^4.1.3 file_picker: ^4.1.3
flutter: flutter:
sdk: flutter sdk: flutter
@@ -27,7 +27,7 @@ dependencies:
http_parser: ^4.0.0 http_parser: ^4.0.0
image_gallery_saver: ^1.7.0 image_gallery_saver: ^1.7.0
image_picker: ^0.8.2 image_picker: ^0.8.2
jiffy: ^4.1.0 jiffy: ^5.0.0
lottie: ^1.0.1 lottie: ^1.0.1
meta: ^1.3.0 meta: ^1.3.0
path_provider: ^2.0.1 path_provider: ^2.0.1
@@ -36,7 +36,7 @@ dependencies:
rxdart: ^0.27.0 rxdart: ^0.27.0
share_plus: ^3.0.4 share_plus: ^3.0.4
shimmer: ^2.0.0 shimmer: ^2.0.0
stream_chat_flutter_core: ^3.3.1 stream_chat_flutter_core: ^3.5.0
substring_highlight: ^1.0.26 substring_highlight: ^1.0.26
synchronized: ^3.0.0 synchronized: ^3.0.0
url_launcher: ^6.0.3 url_launcher: ^6.0.3
@@ -57,6 +57,6 @@ dev_dependencies:
dart_code_metrics: ^4.4.0 dart_code_metrics: ^4.4.0
flutter_test: flutter_test:
sdk: flutter sdk: flutter
golden_toolkit: ^0.11.0 golden_toolkit: ^0.13.0
mocktail: ^0.2.0 mocktail: ^0.2.0
path: ^1.8.0 path: ^1.8.0
@@ -1,4 +1,9 @@
## Upcoming ## 3.5.0
- Updated `stream_chat` dependency to [`3.5.0`](https://pub.dev/packages/stream_chat/changelog).
## 3.4.0
- Updated `stream_chat` dependency to [`3.4.0`](https://pub.dev/packages/stream_chat/changelog).
✅ Added ✅ Added
@@ -1,7 +1,7 @@
name: stream_chat_flutter_core name: stream_chat_flutter_core
homepage: https://github.com/GetStream/stream-chat-flutter homepage: https://github.com/GetStream/stream-chat-flutter
description: Stream Chat official Flutter SDK Core. Build your own chat experience using Dart and Flutter. description: Stream Chat official Flutter SDK Core. Build your own chat experience using Dart and Flutter.
version: 3.3.1 version: 3.5.0
repository: https://github.com/GetStream/stream-chat-flutter repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
@@ -16,7 +16,7 @@ dependencies:
sdk: flutter sdk: flutter
meta: ^1.3.0 meta: ^1.3.0
rxdart: ^0.27.0 rxdart: ^0.27.0
stream_chat: ^3.3.1 stream_chat: ^3.5.0
dev_dependencies: dev_dependencies:
dart_code_metrics: ^4.4.0 dart_code_metrics: ^4.4.0
@@ -1,4 +1,4 @@
## Upcoming ## 2.1.0
✅ Added ✅ Added
@@ -10,7 +10,7 @@
## 2.0.0 ## 2.0.0
- Updated `stream_chat_flutter` dependency to [`3.0.0`](https://pub.dev/packages/stream_chat_flutter/changelog). * Updated `stream_chat_flutter` dependency to [`3.0.0`](https://pub.dev/packages/stream_chat_flutter/changelog).
🐞 Fixed 🐞 Fixed
@@ -1,6 +1,6 @@
name: stream_chat_localizations name: stream_chat_localizations
description: The Official localizations for Stream Chat Flutter, a service for building chat applications description: The Official localizations for Stream Chat Flutter, a service for building chat applications
version: 2.0.0 version: 2.1.0
homepage: https://github.com/GetStream/stream-chat-flutter homepage: https://github.com/GetStream/stream-chat-flutter
repository: https://github.com/GetStream/stream-chat-flutter repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
@@ -14,7 +14,7 @@ dependencies:
sdk: flutter sdk: flutter
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
stream_chat_flutter: ^3.0.0 stream_chat_flutter: ^3.4.0
dev_dependencies: dev_dependencies:
dart_code_metrics: ^4.4.0 dart_code_metrics: ^4.4.0
@@ -1,4 +1,4 @@
## Upcoming ## 3.1.0
- Bump `drift` to `1.3.0`. - Bump `drift` to `1.3.0`.
@@ -1,7 +1,7 @@
name: stream_chat_persistence name: stream_chat_persistence
homepage: https://github.com/GetStream/stream-chat-flutter homepage: https://github.com/GetStream/stream-chat-flutter
description: Official Stream Chat Persistence library. Build your own chat experience using Dart and Flutter. description: Official Stream Chat Persistence library. Build your own chat experience using Dart and Flutter.
version: 3.0.0 version: 3.1.0
repository: https://github.com/GetStream/stream-chat-flutter repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
@@ -10,7 +10,7 @@ environment:
flutter: ">=1.17.0" flutter: ">=1.17.0"
dependencies: dependencies:
drift: 1.3.0 drift: ">=1.3.0 < 1.4.0"
flutter: flutter:
sdk: flutter sdk: flutter
logging: ^1.0.1 logging: ^1.0.1
@@ -19,12 +19,12 @@ dependencies:
path: ^1.8.0 path: ^1.8.0
path_provider: ^2.0.1 path_provider: ^2.0.1
sqlite3_flutter_libs: ^0.5.0 sqlite3_flutter_libs: ^0.5.0
stream_chat: ^3.0.0 stream_chat: ^3.4.0
dev_dependencies: dev_dependencies:
build_runner: ^2.0.1 build_runner: ^2.0.1
dart_code_metrics: ^4.4.0 dart_code_metrics: ^4.4.0
drift_dev: 1.3.0 drift_dev: ">=1.3.0 < 1.4.0"
flutter_test: flutter_test:
sdk: flutter sdk: flutter
mocktail: ^0.2.0 mocktail: ^0.2.0