diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 0fd26e7240..09eded4c2a 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -7,6 +7,8 @@ 🐞 Fixed +- Fixed a message stuck sending, updating or deleting forever when the app was closed before the request finished; an interrupted send, edit or delete is retried. +- Fixed deleting a message that was already deleted failing and offering a retry that could never succeed. - Fixed the connection getting stuck while connecting, and requests never completing, when the token provider throws while refreshing an expired token. - Fixed sending, updating and deleting a message not retrying when the server answers with a timeout (408), a rate limit (429) or a server error (5xx). - Fixed hard-deleting an unread message not lowering the channel's unread count. diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index cd29cb61c3..794b909957 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -787,7 +787,12 @@ class Channel { localCreatedAt: DateTime.now(), user: _client.state.currentUser, quotedMessage: quotedMessage, - state: MessageState.sending, + state: MessageState.outgoing( + state: OutgoingState.sending( + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, + ), + ), attachments: message.attachments.map( (it) { if (it.uploadState.isSuccess) return it; @@ -910,7 +915,12 @@ class Channel { // ignore: parameter_assignments message = message.copyWith( - state: MessageState.updating, + state: MessageState.outgoing( + state: OutgoingState.updating( + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, + ), + ), localUpdatedAt: DateTime.now(), attachments: message.attachments.map( (it) { @@ -1003,7 +1013,13 @@ class Channel { // ignore: parameter_assignments message = message.copyWith( - state: MessageState.updating, + state: MessageState.outgoing( + state: OutgoingState.updating( + set: set, + unset: unset, + skipEnrichUrl: skipEnrichUrl, + ), + ), localUpdatedAt: DateTime.now(), ); @@ -1120,17 +1136,18 @@ class Channel { }, ); - final deletedMessage = message.copyWith( - deletedForMe: scope is DeleteForMe, - state: MessageState.deleted(scope: scope), - ); - - state?.deleteMessage(deletedMessage, hardDelete: scope.hard); + final deletedMessage = _markMessageAsDeleted(message, scope: scope); // If hard delete, also delete the attachments from the server. if (scope.hard) _deleteMessageAttachments(deletedMessage); return response; } catch (e) { + if (e is StreamChatNetworkError && e.isMessageGoneError) { + final deletedMessage = _markMessageAsDeleted(message, scope: scope); + if (scope.hard) _deleteMessageAttachments(deletedMessage); + return EmptyResponse(); + } + final failedMessage = message.copyWith( // Update the message state to failed. state: MessageState.deletingFailed(scope: scope), @@ -1146,6 +1163,19 @@ class Channel { } } + Message _markMessageAsDeleted( + Message message, { + required MessageDeleteScope scope, + }) { + final deletedMessage = message.copyWith( + deletedForMe: scope is DeleteForMe, + state: MessageState.deleted(scope: scope), + ); + + state?.deleteMessage(deletedMessage, hardDelete: scope.hard); + return deletedMessage; + } + // Deletes a local [message] that is not yet sent to the server. // // This is typically called when a user wants to delete a message that they diff --git a/packages/stream_chat/lib/src/client/channel/channel_client_state.dart b/packages/stream_chat/lib/src/client/channel/channel_client_state.dart index 524ba55c30..7b670665b2 100644 --- a/packages/stream_chat/lib/src/client/channel/channel_client_state.dart +++ b/packages/stream_chat/lib/src/client/channel/channel_client_state.dart @@ -28,6 +28,7 @@ class ChannelClientState { ); final seededState = channelState.copyWith( + messages: _settleInterruptedRequests(channelState.messages), pinnedMessages: MessageMerging.sortPinnedMessages(channelState.pinnedMessages), ); @@ -59,11 +60,26 @@ class ChannelClientState { ?.getChannelThreads(_channel.cid!) .then((threads) { // Load all the threads for the channel from the offline storage. - if (threads.isNotEmpty) _threads = threads; + if (threads.isEmpty) return; + _threads = threads.map( + (parentId, replies) => MapEntry(parentId, _settleInterruptedRequests(replies)!), + ); }) .then((_) => retryFailedMessages()); } + // A message still being sent, updated or deleted when this state is created + // belongs to a request that never finished, as nothing in this channel can + // be in flight yet. + static List? _settleInterruptedRequests(List? messages) { + if (messages == null || !messages.any((it) => it.state.isOutgoing)) return messages; + + return [ + for (final message in messages) + if (message.state.isOutgoing) message.copyWith(state: message.state.settled) else message, + ]; + } + final Channel _channel; StreamChatClient get _client => _channel.client; final _subscriptions = CompositeSubscription(); diff --git a/packages/stream_chat/lib/src/core/error/stream_chat_error.dart b/packages/stream_chat/lib/src/core/error/stream_chat_error.dart index c2f0e9e4a3..0df59bb58b 100644 --- a/packages/stream_chat/lib/src/core/error/stream_chat_error.dart +++ b/packages/stream_chat/lib/src/core/error/stream_chat_error.dart @@ -184,6 +184,13 @@ class StreamChatNetworkError extends StreamChatError { return message.contains('already exists'); } + /// Whether the server rejected a request because the message no longer + /// exists, such as one already deleted. + @internal + bool get isMessageGoneError { + return statusCode == 404 && errorCode == ChatErrorCode.doesNotExist; + } + @override List get props => [...super.props, code, statusCode, type]; diff --git a/packages/stream_chat/lib/src/core/models/message_state.dart b/packages/stream_chat/lib/src/core/models/message_state.dart index d7433df9aa..75d62f7b34 100644 --- a/packages/stream_chat/lib/src/core/models/message_state.dart +++ b/packages/stream_chat/lib/src/core/models/message_state.dart @@ -8,6 +8,33 @@ part 'message_state.g.dart'; /// Helper extension for [MessageState]. extension MessageStateX on MessageState { + /// The state to restore this message in when its request never finished, + /// such as one cut off by the app being closed. + /// + /// A send, update or delete becomes failed with the options it was made + /// with, so it can be retried. Any other state is returned as is. + @internal + MessageState get settled { + return switch (this) { + MessageOutgoing(state: final Sending state) => MessageState.sendingFailed( + skipPush: state.skipPush, + skipEnrichUrl: state.skipEnrichUrl, + ), + MessageOutgoing(state: final Updating state) when state.set == null && state.unset == null => + MessageState.updatingFailed( + skipPush: state.skipPush, + skipEnrichUrl: state.skipEnrichUrl, + ), + MessageOutgoing(state: final Updating state) => MessageState.partialUpdatingFailed( + set: state.set, + unset: state.unset, + skipEnrichUrl: state.skipEnrichUrl, + ), + MessageOutgoing(state: final Deleting state) => MessageState.deletingFailed(scope: state.scope), + MessageInitial() || MessageCompleted() || MessageFailed() => this, + }; + } + /// Returns true if the message is in initial state. bool get isInitial => this is MessageInitial; @@ -374,10 +401,20 @@ sealed class MessageState with _$MessageState { @freezed sealed class OutgoingState with _$OutgoingState { /// Sending state when the message is being sent. - const factory OutgoingState.sending() = Sending; + const factory OutgoingState.sending({ + @Default(false) bool skipPush, + @Default(false) bool skipEnrichUrl, + }) = Sending; /// Updating state when the message is being updated. - const factory OutgoingState.updating() = Updating; + /// + /// A partial update has a [Updating.set] or [Updating.unset]. + const factory OutgoingState.updating({ + @Default(false) bool skipPush, + @Default(false) bool skipEnrichUrl, + Map? set, + List? unset, + }) = Updating; /// Deleting state when the message is being deleted. const factory OutgoingState.deleting({ diff --git a/packages/stream_chat/lib/src/core/models/message_state.freezed.dart b/packages/stream_chat/lib/src/core/models/message_state.freezed.dart index 61cc289c39..d44895312b 100644 --- a/packages/stream_chat/lib/src/core/models/message_state.freezed.dart +++ b/packages/stream_chat/lib/src/core/models/message_state.freezed.dart @@ -427,15 +427,21 @@ $OutgoingStateCopyWith(OutgoingState _, $Res Function(OutgoingState) __); @JsonSerializable() class Sending implements OutgoingState { - const Sending({final String? $type}): $type = $type ?? 'sending'; + const Sending({this.skipPush = false, this.skipEnrichUrl = false, final String? $type}): $type = $type ?? 'sending'; factory Sending.fromJson(Map json) => _$SendingFromJson(json); - +@JsonKey() final bool skipPush; +@JsonKey() final bool skipEnrichUrl; @JsonKey(name: 'runtimeType') final String $type; +/// Create a copy of OutgoingState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SendingCopyWith get copyWith => _$SendingCopyWithImpl(this, _$identity); @override Map toJson() { @@ -444,37 +450,91 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is Sending); + return identical(this, other) || (other.runtimeType == runtimeType&&other is Sending&&(identical(other.skipPush, skipPush) || other.skipPush == skipPush)&&(identical(other.skipEnrichUrl, skipEnrichUrl) || other.skipEnrichUrl == skipEnrichUrl)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => runtimeType.hashCode; +int get hashCode => Object.hash(runtimeType,skipPush,skipEnrichUrl); @override String toString() { - return 'OutgoingState.sending()'; + return 'OutgoingState.sending(skipPush: $skipPush, skipEnrichUrl: $skipEnrichUrl)'; } } +/// @nodoc +abstract mixin class $SendingCopyWith<$Res> implements $OutgoingStateCopyWith<$Res> { + factory $SendingCopyWith(Sending value, $Res Function(Sending) _then) = _$SendingCopyWithImpl; +@useResult +$Res call({ + bool skipPush, bool skipEnrichUrl +}); + + + + +} +/// @nodoc +class _$SendingCopyWithImpl<$Res> + implements $SendingCopyWith<$Res> { + _$SendingCopyWithImpl(this._self, this._then); + + final Sending _self; + final $Res Function(Sending) _then; +/// Create a copy of OutgoingState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? skipPush = null,Object? skipEnrichUrl = null,}) { + return _then(Sending( +skipPush: null == skipPush ? _self.skipPush : skipPush // ignore: cast_nullable_to_non_nullable +as bool,skipEnrichUrl: null == skipEnrichUrl ? _self.skipEnrichUrl : skipEnrichUrl // ignore: cast_nullable_to_non_nullable +as bool, + )); +} +} + /// @nodoc @JsonSerializable() class Updating implements OutgoingState { - const Updating({final String? $type}): $type = $type ?? 'updating'; + const Updating({this.skipPush = false, this.skipEnrichUrl = false, final Map? set, final List? unset, final String? $type}): _set = set,_unset = unset,$type = $type ?? 'updating'; factory Updating.fromJson(Map json) => _$UpdatingFromJson(json); +@JsonKey() final bool skipPush; +@JsonKey() final bool skipEnrichUrl; + final Map? _set; + Map? get set { + final value = _set; + if (value == null) return null; + if (_set is EqualUnmodifiableMapView) return _set; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(value); +} + + final List? _unset; + List? get unset { + final value = _unset; + if (value == null) return null; + if (_unset is EqualUnmodifiableListView) return _unset; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); +} @JsonKey(name: 'runtimeType') final String $type; +/// Create a copy of OutgoingState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$UpdatingCopyWith get copyWith => _$UpdatingCopyWithImpl(this, _$identity); @override Map toJson() { @@ -483,24 +543,56 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is Updating); + return identical(this, other) || (other.runtimeType == runtimeType&&other is Updating&&(identical(other.skipPush, skipPush) || other.skipPush == skipPush)&&(identical(other.skipEnrichUrl, skipEnrichUrl) || other.skipEnrichUrl == skipEnrichUrl)&&const DeepCollectionEquality().equals(other._set, _set)&&const DeepCollectionEquality().equals(other._unset, _unset)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => runtimeType.hashCode; +int get hashCode => Object.hash(runtimeType,skipPush,skipEnrichUrl,const DeepCollectionEquality().hash(_set),const DeepCollectionEquality().hash(_unset)); @override String toString() { - return 'OutgoingState.updating()'; + return 'OutgoingState.updating(skipPush: $skipPush, skipEnrichUrl: $skipEnrichUrl, set: $set, unset: $unset)'; } } +/// @nodoc +abstract mixin class $UpdatingCopyWith<$Res> implements $OutgoingStateCopyWith<$Res> { + factory $UpdatingCopyWith(Updating value, $Res Function(Updating) _then) = _$UpdatingCopyWithImpl; +@useResult +$Res call({ + bool skipPush, bool skipEnrichUrl, Map? set, List? unset +}); + + + + +} +/// @nodoc +class _$UpdatingCopyWithImpl<$Res> + implements $UpdatingCopyWith<$Res> { + _$UpdatingCopyWithImpl(this._self, this._then); + + final Updating _self; + final $Res Function(Updating) _then; +/// Create a copy of OutgoingState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? skipPush = null,Object? skipEnrichUrl = null,Object? set = freezed,Object? unset = freezed,}) { + return _then(Updating( +skipPush: null == skipPush ? _self.skipPush : skipPush // ignore: cast_nullable_to_non_nullable +as bool,skipEnrichUrl: null == skipEnrichUrl ? _self.skipEnrichUrl : skipEnrichUrl // ignore: cast_nullable_to_non_nullable +as bool,set: freezed == set ? _self._set : set // ignore: cast_nullable_to_non_nullable +as Map?,unset: freezed == unset ? _self._unset : unset // ignore: cast_nullable_to_non_nullable +as List?, + )); +} +} + /// @nodoc @JsonSerializable() diff --git a/packages/stream_chat/lib/src/core/models/message_state.g.dart b/packages/stream_chat/lib/src/core/models/message_state.g.dart index 7a752c170a..a1c76d855d 100644 --- a/packages/stream_chat/lib/src/core/models/message_state.g.dart +++ b/packages/stream_chat/lib/src/core/models/message_state.g.dart @@ -45,15 +45,31 @@ Map _$MessageFailedToJson(MessageFailed instance) => json) => Sending($type: json['runtimeType'] as String?); +Sending _$SendingFromJson(Map json) => Sending( + skipPush: json['skip_push'] as bool? ?? false, + skipEnrichUrl: json['skip_enrich_url'] as bool? ?? false, + $type: json['runtimeType'] as String?, +); Map _$SendingToJson(Sending instance) => { + 'skip_push': instance.skipPush, + 'skip_enrich_url': instance.skipEnrichUrl, 'runtimeType': instance.$type, }; -Updating _$UpdatingFromJson(Map json) => Updating($type: json['runtimeType'] as String?); +Updating _$UpdatingFromJson(Map json) => Updating( + skipPush: json['skip_push'] as bool? ?? false, + skipEnrichUrl: json['skip_enrich_url'] as bool? ?? false, + set: json['set'] as Map?, + unset: (json['unset'] as List?)?.map((e) => e as String).toList(), + $type: json['runtimeType'] as String?, +); Map _$UpdatingToJson(Updating instance) => { + 'skip_push': instance.skipPush, + 'skip_enrich_url': instance.skipEnrichUrl, + 'set': instance.set, + 'unset': instance.unset, 'runtimeType': instance.$type, }; diff --git a/packages/stream_chat/test/src/client/channel/channel_interrupted_requests_test.dart b/packages/stream_chat/test/src/client/channel/channel_interrupted_requests_test.dart new file mode 100644 index 0000000000..8ce32c7ffc --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_interrupted_requests_test.dart @@ -0,0 +1,274 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../../fakes.dart'; +import '../../mocks.dart'; + +void main() { + setUpAll(() => registerFallbackValue(FakeMessage())); + + test('marks a message left sending as failed to send', () { + final channel = _channel(messages: [_message(state: MessageState.sending)]); + + expect( + channel.state!.messages.single.state, + MessageState.sendingFailed(skipPush: false, skipEnrichUrl: false), + ); + }); + + test('marks a message left updating as failed to update with its options', () { + const updating = MessageState.outgoing( + state: OutgoingState.updating(skipPush: true, skipEnrichUrl: true), + ); + + final channel = _channel(messages: [_message(state: updating)]); + + expect( + channel.state!.messages.single.state, + MessageState.updatingFailed(skipPush: true, skipEnrichUrl: true), + ); + }); + + test('updates a message left updating again with its options', () async { + final client = MockStreamChatClientWithPersistence(); + when(() => client.chatPersistenceClient.getChannelThreads(any())).thenAnswer((_) async => {}); + when( + () => client.updateMessage( + any(), + skipPush: any(named: 'skipPush'), + skipEnrichUrl: any(named: 'skipEnrichUrl'), + ), + ).thenAnswer((invocation) async { + final message = invocation.positionalArguments.first as Message; + return UpdateMessageResponse()..message = message; + }); + const updating = MessageState.outgoing( + state: OutgoingState.updating(skipPush: true, skipEnrichUrl: true), + ); + + _channel( + client: client, + messages: [_message(state: updating)], + ); + await pumpEventQueue(); + + verify(() => client.updateMessage(any(), skipPush: true, skipEnrichUrl: true)).called(1); + }); + + test('updates a message left partially updating again as a partial update', () async { + final client = MockStreamChatClientWithPersistence(); + when(() => client.chatPersistenceClient.getChannelThreads(any())).thenAnswer((_) async => {}); + when( + () => client.partialUpdateMessage( + any(), + set: any(named: 'set'), + unset: any(named: 'unset'), + skipEnrichUrl: any(named: 'skipEnrichUrl'), + ), + ).thenAnswer((_) async => UpdateMessageResponse()..message = _message()); + const updating = MessageState.outgoing( + state: OutgoingState.updating(set: {'pinned': true}, unset: ['pin_expires'], skipEnrichUrl: true), + ); + + _channel( + client: client, + messages: [_message(state: updating)], + ); + await pumpEventQueue(); + + verify( + () => client.partialUpdateMessage( + 'message', + set: {'pinned': true}, + unset: ['pin_expires'], + skipEnrichUrl: true, + ), + ).called(1); + verifyNever( + () => client.updateMessage( + any(), + skipPush: any(named: 'skipPush'), + skipEnrichUrl: any(named: 'skipEnrichUrl'), + ), + ); + }); + + test('marks a message left deleting as failed to delete for the same scope', () { + final channel = _channel( + messages: [_message(state: MessageState.deleting(scope: const MessageDeleteScope.deleteForMe()))], + ); + + expect( + channel.state!.messages.single.state, + MessageState.deletingFailed(scope: const MessageDeleteScope.deleteForMe()), + ); + }); + + test('keeps the messages as they are when none was left in progress', () { + final messages = [_message(), _message(id: 'other-message')]; + + final channel = _channel(messages: messages); + + expect(channel.state!.messages, same(messages)); + }); + + test('changes only the message left in progress among the others', () { + final failed = MessageState.sendingFailed(skipPush: false, skipEnrichUrl: false); + final channel = _channel( + messages: [ + _message(id: 'sent'), + _message(id: 'failed', state: failed), + _message(id: 'sending', state: MessageState.sending), + ], + ); + + final states = channel.state!.messages.map((it) => it.state); + expect(states, [MessageState.sent, failed, failed]); + }); + + test('does not touch a message that starts sending after the channel is created', () async { + final client = MockStreamChatClientWithPersistence(); + when(() => client.chatPersistenceClient.getChannelThreads(any())).thenAnswer((_) async => {}); + final channel = _channel(client: client); + + channel.state!.updateMessage(_message(state: MessageState.sending)); + await pumpEventQueue(); + + expect(channel.state!.messages.single.state, MessageState.sending); + verifyNever(() => client.sendMessage(any(), any(), any())); + }); + + test('sends a stored thread reply left sending again', () async { + final client = MockStreamChatClientWithPersistence(); + final reply = _message(id: 'reply', parentId: 'parent', state: MessageState.sending); + when(() => client.chatPersistenceClient.getChannelThreads(any())).thenAnswer( + (_) async => { + 'parent': [reply], + }, + ); + _stubSend(client); + + final channel = _channel(client: client); + await pumpEventQueue(); + + verify( + () => client.sendMessage(any(that: isA().having((it) => it.id, 'id', 'reply')), any(), any()), + ).called(1); + expect(channel.state!.threads['parent']?.single.state.isCompleted, isTrue); + }); + + test('sends a message left sending again', () async { + final client = MockStreamChatClientWithPersistence(); + when(() => client.chatPersistenceClient.getChannelThreads(any())).thenAnswer((_) async => {}); + _stubSend(client); + + final channel = _channel( + client: client, + messages: [_message(state: MessageState.sending)], + ); + await pumpEventQueue(); + + verify(() => client.sendMessage(any(), channel.id!, channel.type)).called(1); + }); + + test('sends a message left sending again with its push and link preview options', () async { + final client = MockStreamChatClientWithPersistence(); + when(() => client.chatPersistenceClient.getChannelThreads(any())).thenAnswer((_) async => {}); + _stubSend(client); + const sending = MessageState.outgoing( + state: OutgoingState.sending(skipPush: true, skipEnrichUrl: true), + ); + + final channel = _channel( + client: client, + messages: [_message(state: sending)], + ); + await pumpEventQueue(); + + verify( + () => client.sendMessage(any(), channel.id!, channel.type, skipPush: true, skipEnrichUrl: true), + ).called(1); + }); + + test('marks a message left deleting as deleted when it is already gone', () async { + final client = MockStreamChatClientWithPersistence(); + when(() => client.chatPersistenceClient.getChannelThreads(any())).thenAnswer((_) async => {}); + const message = 'GetMessage failed with error: "message with id message has been deleted"'; + when(() => client.deleteMessage('message')).thenThrow( + StreamChatNetworkError.raw( + code: ChatErrorCode.doesNotExist.code, + statusCode: 404, + message: message, + data: ErrorResponse() + ..code = ChatErrorCode.doesNotExist.code + ..message = message + ..statusCode = 404, + ), + ); + + final channel = _channel( + client: client, + messages: [_message(state: MessageState.deleting(scope: MessageDeleteScope.softDeleteForAll))], + ); + await pumpEventQueue(); + + expect( + channel.state!.messages.single.state, + MessageState.deleted(scope: MessageDeleteScope.softDeleteForAll), + ); + }); +} + +Message _message({ + String id = 'message', + String? parentId, + MessageState state = MessageState.sent, +}) { + return Message( + id: id, + text: 'Hello', + parentId: parentId, + user: User(id: 'current-user'), + createdAt: DateTime(2026, 10, 29, 12), + state: state, + ); +} + +void _stubSend(MockStreamChatClient client) { + when( + () => client.sendMessage( + any(), + any(), + any(), + skipPush: any(named: 'skipPush'), + skipEnrichUrl: any(named: 'skipEnrichUrl'), + ), + ).thenAnswer((invocation) async { + final message = invocation.positionalArguments.first as Message; + return SendMessageResponse()..message = message.copyWith(state: MessageState.sent); + }); +} + +Channel _channel({MockStreamChatClient? client, List messages = const []}) { + final chatClient = client ?? MockStreamChatClient(); + when( + () => chatClient.detachedLogger(any()), + ).thenAnswer((invocation) => Logger.detached(invocation.positionalArguments.first as String)); + when(() => chatClient.logger).thenReturn(Logger.detached('client')); + when(() => chatClient.state).thenReturn(FakeClientState()); + when(() => chatClient.channelDeliveryReporter.submitForDelivery(any())).thenAnswer((_) async {}); + when(() => chatClient.retryPolicy).thenReturn( + RetryPolicy(shouldRetry: (_, __, ___) => false, delayFactor: Duration.zero), + ); + + final channel = Channel.fromState( + chatClient, + ChannelState( + channel: ChannelModel(type: 'messaging', id: 'interrupted'), + messages: messages, + ), + ); + addTearDown(channel.dispose); + return channel; +} diff --git a/packages/stream_chat/test/src/client/channel/channel_test.dart b/packages/stream_chat/test/src/client/channel/channel_test.dart index a26806f892..7c143a22a1 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -345,7 +345,11 @@ void main() { emitsInOrder([ [ isSameMessageAs( - message.copyWith(state: MessageState.sending), + message.copyWith( + state: const MessageState.outgoing( + state: OutgoingState.sending(skipPush: true, skipEnrichUrl: false), + ), + ), matchMessageState: true, ), ], @@ -400,7 +404,11 @@ void main() { emitsInOrder([ [ isSameMessageAs( - message.copyWith(state: MessageState.sending), + message.copyWith( + state: const MessageState.outgoing( + state: OutgoingState.sending(skipPush: true, skipEnrichUrl: true), + ), + ), matchMessageState: true, ), ], @@ -455,7 +463,11 @@ void main() { emitsInOrder([ [ isSameMessageAs( - message.copyWith(state: MessageState.sending), + message.copyWith( + state: const MessageState.outgoing( + state: OutgoingState.sending(skipPush: false, skipEnrichUrl: true), + ), + ), matchMessageState: true, ), ], @@ -1716,7 +1728,11 @@ void main() { emitsInOrder([ [ isSameMessageAs( - message.copyWith(state: MessageState.updating), + message.copyWith( + state: const MessageState.outgoing( + state: OutgoingState.updating(skipPush: false, skipEnrichUrl: true), + ), + ), matchMessageState: true, ), ], @@ -1767,7 +1783,11 @@ void main() { emitsInOrder([ [ isSameMessageAs( - message.copyWith(state: MessageState.updating), + message.copyWith( + state: const MessageState.outgoing( + state: OutgoingState.updating(skipPush: false, skipEnrichUrl: true), + ), + ), matchMessageState: true, ), ], @@ -1823,7 +1843,11 @@ void main() { emitsInOrder([ [ isSameMessageAs( - message.copyWith(state: MessageState.updating), + message.copyWith( + state: const MessageState.outgoing( + state: OutgoingState.updating(skipPush: true, skipEnrichUrl: false), + ), + ), matchMessageState: true, ), ], @@ -1872,7 +1896,11 @@ void main() { emitsInOrder([ [ isSameMessageAs( - message.copyWith(state: MessageState.updating), + message.copyWith( + state: const MessageState.outgoing( + state: OutgoingState.updating(skipPush: true, skipEnrichUrl: true), + ), + ), matchMessageState: true, ), ], @@ -2057,7 +2085,9 @@ void main() { [ isSameMessageAs( message.copyWith( - state: MessageState.updating, + state: const MessageState.outgoing( + state: OutgoingState.updating(set: set, unset: unset), + ), ), matchText: true, matchMessageState: true, @@ -2120,7 +2150,9 @@ void main() { [ isSameMessageAs( message.copyWith( - state: MessageState.updating, + state: const MessageState.outgoing( + state: OutgoingState.updating(set: set, unset: unset, skipEnrichUrl: false), + ), ), matchText: true, matchMessageState: true, @@ -2188,7 +2220,9 @@ void main() { [ isSameMessageAs( message.copyWith( - state: MessageState.updating, + state: const MessageState.outgoing( + state: OutgoingState.updating(set: set, unset: unset, skipEnrichUrl: true), + ), ), matchText: true, matchMessageState: true, @@ -2261,7 +2295,9 @@ void main() { [ isSameMessageAs( message.copyWith( - state: MessageState.updating, + state: const MessageState.outgoing( + state: OutgoingState.updating(set: set, unset: unset, skipEnrichUrl: false), + ), ), matchText: true, matchMessageState: true, @@ -2326,7 +2362,9 @@ void main() { [ isSameMessageAs( message.copyWith( - state: MessageState.updating, + state: const MessageState.outgoing( + state: OutgoingState.updating(set: set, unset: unset, skipEnrichUrl: true), + ), ), matchText: true, matchMessageState: true, @@ -2388,7 +2426,9 @@ void main() { [ isSameMessageAs( message.copyWith( - state: MessageState.updating, + state: const MessageState.outgoing( + state: OutgoingState.updating(set: set, unset: unset, skipEnrichUrl: false), + ), ), matchText: true, matchMessageState: true, @@ -2464,6 +2504,67 @@ void main() { verify(() => client.deleteMessage(messageId)).called(1); }); + test('marks a message that is already gone as deleted', () async { + const messageId = 'test-message-id'; + final message = Message( + id: messageId, + createdAt: DateTime.now(), + state: MessageState.sent, + ); + const errorMessage = 'DeleteMessage failed with error: "message with id test-message-id has been deleted"'; + when(() => client.deleteMessage(messageId)).thenThrow( + StreamChatNetworkError.raw( + code: ChatErrorCode.doesNotExist.code, + statusCode: 404, + message: errorMessage, + data: ErrorResponse() + ..code = ChatErrorCode.doesNotExist.code + ..message = errorMessage + ..statusCode = 404, + ), + ); + channel.state?.addNewMessage(message); + + final res = await channel.deleteMessage(message); + + expect(res, isNotNull); + expect(channel.state?.messages.single.state, MessageState.softDeleted); + }); + + test('removes a hard-deleted message that is already gone and its attachments', () async { + const messageId = 'test-message-id'; + final message = Message( + id: messageId, + attachments: [ + Attachment(type: 'image', imageUrl: 'test-image-url'), + Attachment(type: 'file', assetUrl: 'test-asset-url'), + ], + createdAt: DateTime.now(), + state: MessageState.sent, + ); + const errorMessage = 'DeleteMessage failed with error: "message with id test-message-id doesn\'t exist"'; + when(() => client.deleteMessage(messageId, hard: true)).thenThrow( + StreamChatNetworkError.raw( + code: ChatErrorCode.doesNotExist.code, + statusCode: 404, + message: errorMessage, + data: ErrorResponse() + ..code = ChatErrorCode.doesNotExist.code + ..message = errorMessage + ..statusCode = 404, + ), + ); + when(() => client.deleteImage(any(), channelId, channelType)).thenAnswer((_) async => EmptyResponse()); + when(() => client.deleteFile(any(), channelId, channelType)).thenAnswer((_) async => EmptyResponse()); + channel.state?.addNewMessage(message); + + await channel.deleteMessage(message, hard: true); + + expect(channel.state?.messages, isEmpty); + verify(() => client.deleteImage('test-image-url', channelId, channelType)).called(1); + verify(() => client.deleteFile('test-asset-url', channelId, channelType)).called(1); + }); + test('should delete attachments for hard delete', () async { final attachments = List.generate( 3, @@ -2641,10 +2742,7 @@ void main() { channel.state?.messagesStream.skip(1), emitsInOrder([ [ - isSameMessageAs( - message.copyWith(state: MessageState.updating), - matchMessageState: true, - ), + isA().having((it) => it.state.isUpdating, 'isUpdating', isTrue), ], [ isSameMessageAs( @@ -2697,10 +2795,7 @@ void main() { channel.state?.messagesStream.skip(1), emitsInOrder([ [ - isSameMessageAs( - message.copyWith(state: MessageState.updating), - matchMessageState: true, - ), + isA().having((it) => it.state.isUpdating, 'isUpdating', isTrue), ], [ isSameMessageAs( @@ -2755,10 +2850,7 @@ void main() { channel.state?.messagesStream.skip(1), emitsInOrder([ [ - isSameMessageAs( - message.copyWith(state: MessageState.updating), - matchMessageState: true, - ), + isA().having((it) => it.state.isUpdating, 'isUpdating', isTrue), ], [ isSameMessageAs( @@ -2823,10 +2915,7 @@ void main() { channel.state?.messagesStream.skip(1), emitsInOrder([ [ - isSameMessageAs( - message.copyWith(state: MessageState.updating), - matchMessageState: true, - ), + isA().having((it) => it.state.isUpdating, 'isUpdating', isTrue), ], [ isSameMessageAs( diff --git a/packages/stream_chat/test/src/core/error/stream_chat_error_test.dart b/packages/stream_chat/test/src/core/error/stream_chat_error_test.dart index 12275beab9..927cb882b7 100644 --- a/packages/stream_chat/test/src/core/error/stream_chat_error_test.dart +++ b/packages/stream_chat/test/src/core/error/stream_chat_error_test.dart @@ -283,6 +283,36 @@ void main() { expect(error.isMessageAlreadyExistsError, isFalse); }); + test('`.isMessageGoneError` should return true for a message that no longer exists', () { + const message = 'DeleteMessage failed with error: "message with id abc has been deleted"'; + final error = StreamChatNetworkError.raw( + code: ChatErrorCode.doesNotExist.code, + statusCode: 404, + message: message, + data: ErrorResponse() + ..code = ChatErrorCode.doesNotExist.code + ..message = message + ..statusCode = 404, + ); + + expect(error.isMessageGoneError, isTrue); + }); + + test('`.isMessageGoneError` should return false for another rejection', () { + const message = 'DeleteMessage failed with error: "not allowed"'; + final error = StreamChatNetworkError.raw( + code: ChatErrorCode.notAllowed.code, + statusCode: 403, + message: message, + data: ErrorResponse() + ..code = ChatErrorCode.notAllowed.code + ..message = message + ..statusCode = 403, + ); + + expect(error.isMessageGoneError, isFalse); + }); + test('`.toString`', () { const errorCode = ChatErrorCode.tokenExpired; final error = StreamChatNetworkError(errorCode);