From fa626d219cb466f79defaa8fc296ba0c9ee892b6 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 29 Oct 2026 15:23:49 +0100 Subject: [PATCH 1/8] fix(llc): retry a message request the app was closed during (FLU-1136) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012vTgFwqhUc2wKCWrw635yC --- packages/stream_chat/CHANGELOG.md | 1 + .../client/channel/channel_client_state.dart | 30 +++- .../channel_interrupted_requests_test.dart | 133 ++++++++++++++++++ 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 packages/stream_chat/test/src/client/channel/channel_interrupted_requests_test.dart diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 0fd26e7240..d91e6ec37d 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -7,6 +7,7 @@ 🐞 Fixed +- Fixed a message stuck sending forever, or an edit or delete lost, when the app was closed before the request finished. - 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_client_state.dart b/packages/stream_chat/lib/src/client/channel/channel_client_state.dart index 524ba55c30..95c9d638d0 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: _failInterruptedRequests(channelState.messages), pinnedMessages: MessageMerging.sortPinnedMessages(channelState.pinnedMessages), ); @@ -59,11 +60,38 @@ 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.isNotEmpty) { + _threads = { + for (final MapEntry(:key, :value) in threads.entries) key: _failInterruptedRequests(value)!, + }; + } }) .then((_) => retryFailedMessages()); } + // A message that was still being sent, updated or deleted when this state + // was created belongs to a request that never finished, such as one cut off + // when the app was closed. It is marked failed so it gets retried. + static List? _failInterruptedRequests(List? messages) { + if (messages == null || !messages.any((it) => it.state.isOutgoing)) return messages; + + return [ + for (final message in messages) + switch (message.state) { + MessageOutgoing(state: Sending()) => message.copyWith( + state: MessageState.sendingFailed(skipPush: false, skipEnrichUrl: false), + ), + MessageOutgoing(state: Updating()) => message.copyWith( + state: MessageState.updatingFailed(skipPush: false, skipEnrichUrl: false), + ), + MessageOutgoing(state: Deleting(:final scope)) => message.copyWith( + state: MessageState.deletingFailed(scope: scope), + ), + _ => message, + }, + ]; + } + final Channel _channel; StreamChatClient get _client => _channel.client; final _subscriptions = CompositeSubscription(); 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..b6b04667a9 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_interrupted_requests_test.dart @@ -0,0 +1,133 @@ +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', () { + final channel = _channel(messages: [_message(state: MessageState.updating)]); + + expect( + channel.state!.messages.single.state, + MessageState.updatingFailed(skipPush: false, skipEnrichUrl: false), + ); + }); + + 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('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); + }); +} + +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; +} From 9f56042298d9adf67306d340b2b5d5955e9712ac Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 29 Oct 2026 16:05:05 +0100 Subject: [PATCH 2/8] fix(llc): settle an interrupted update instead of re-sending it in full (FLU-1136) A pin or unpin is an update too, and re-sending it as a full update could undo it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012vTgFwqhUc2wKCWrw635yC --- packages/stream_chat/CHANGELOG.md | 2 +- .../client/channel/channel_client_state.dart | 17 +- .../channel_interrupted_requests_test.dart | 162 ++++++++++-------- 3 files changed, 103 insertions(+), 78 deletions(-) diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index d91e6ec37d..cbfeab1824 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -7,7 +7,7 @@ 🐞 Fixed -- Fixed a message stuck sending forever, or an edit or delete lost, when the app was closed before the request finished. +- Fixed a message stuck sending, updating or deleting forever when the app was closed before the request finished. - 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_client_state.dart b/packages/stream_chat/lib/src/client/channel/channel_client_state.dart index 95c9d638d0..e308de094c 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 @@ -60,18 +60,19 @@ class ChannelClientState { ?.getChannelThreads(_channel.cid!) .then((threads) { // Load all the threads for the channel from the offline storage. - if (threads.isNotEmpty) { - _threads = { - for (final MapEntry(:key, :value) in threads.entries) key: _failInterruptedRequests(value)!, - }; - } + if (threads.isEmpty) return; + _threads = threads.map( + (parentId, replies) => MapEntry(parentId, _failInterruptedRequests(replies)!), + ); }) .then((_) => retryFailedMessages()); } // A message that was still being sent, updated or deleted when this state // was created belongs to a request that never finished, such as one cut off - // when the app was closed. It is marked failed so it gets retried. + // when the app was closed. A send or delete is marked failed so it gets + // retried. An update can't be retried as it was made, so it is settled and + // the server's copy replaces it. static List? _failInterruptedRequests(List? messages) { if (messages == null || !messages.any((it) => it.state.isOutgoing)) return messages; @@ -81,9 +82,7 @@ class ChannelClientState { MessageOutgoing(state: Sending()) => message.copyWith( state: MessageState.sendingFailed(skipPush: false, skipEnrichUrl: false), ), - MessageOutgoing(state: Updating()) => message.copyWith( - state: MessageState.updatingFailed(skipPush: false, skipEnrichUrl: false), - ), + MessageOutgoing(state: Updating()) => message.copyWith(state: MessageState.updated), MessageOutgoing(state: Deleting(:final scope)) => message.copyWith( state: MessageState.deletingFailed(scope: scope), ), 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 index b6b04667a9..8c0bf86c9a 100644 --- 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 @@ -8,74 +8,100 @@ 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', () { - final channel = _channel(messages: [_message(state: MessageState.updating)]); - - expect( - channel.state!.messages.single.state, - MessageState.updatingFailed(skipPush: false, skipEnrichUrl: false), - ); - }); - - 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('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); + group('Channel.fromState', () { + 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('settles a message left updating, so it no longer shows as in progress', () { + final channel = _channel(messages: [_message(state: MessageState.updating)]); + + expect(channel.state!.messages.single.state, MessageState.updated); + }); + + test('does not send a message left updating again', () async { + final client = MockStreamChatClientWithPersistence(); + when(() => client.chatPersistenceClient.getChannelThreads(any())).thenAnswer((_) async => {}); + + _channel( + client: client, + messages: [_message(state: MessageState.updating)], + ); + await pumpEventQueue(); + + verifyNever( + () => client.updateMessage( + any(), + skipPush: any(named: 'skipPush'), + skipEnrichUrl: any(named: 'skipEnrichUrl'), + ), + ); + verifyNever( + () => client.partialUpdateMessage( + any(), + set: any(named: 'set'), + unset: any(named: 'unset'), + 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('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); + }); }); } From 0c6648e503325be4e25ea66431a724e12f45dde9 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 3 Nov 2026 12:52:12 +0100 Subject: [PATCH 3/8] fix(llc): treat a delete of a message already gone as done (FLU-1136) A delete retried after the app was closed can find the message already deleted; it now ends deleted instead of failing again on every launch. Also renames the startup step to `_settleInterruptedRequests`, lists the other message states explicitly, and covers a send started after the channel is created and a mixed list in tests. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012vTgFwqhUc2wKCWrw635yC --- packages/stream_chat/CHANGELOG.md | 2 +- .../lib/src/client/channel/channel.dart | 27 +- .../client/channel/channel_client_state.dart | 8 +- .../lib/src/core/error/stream_chat_error.dart | 7 + .../channel_interrupted_requests_test.dart | 240 +++++++++++------- .../core/error/stream_chat_error_test.dart | 30 +++ 6 files changed, 209 insertions(+), 105 deletions(-) diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index cbfeab1824..15feaed63c 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -7,7 +7,7 @@ 🐞 Fixed -- Fixed a message stuck sending, updating or deleting forever when the app was closed before the request finished. +- Fixed a message stuck sending, updating or deleting forever when the app was closed before the request finished; an interrupted send or delete is retried, and an interrupted edit is discarded. - 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..bd997176a8 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -1120,17 +1120,19 @@ 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) { + // The message is already gone, such as after a delete that went through + // before the app was closed. + if (e is StreamChatNetworkError && e.isMessageGoneError) { + _markMessageAsDeleted(message, scope: scope); + return EmptyResponse(); + } + final failedMessage = message.copyWith( // Update the message state to failed. state: MessageState.deletingFailed(scope: scope), @@ -1146,6 +1148,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 e308de094c..d2894ca8a7 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,7 +28,7 @@ class ChannelClientState { ); final seededState = channelState.copyWith( - messages: _failInterruptedRequests(channelState.messages), + messages: _settleInterruptedRequests(channelState.messages), pinnedMessages: MessageMerging.sortPinnedMessages(channelState.pinnedMessages), ); @@ -62,7 +62,7 @@ class ChannelClientState { // Load all the threads for the channel from the offline storage. if (threads.isEmpty) return; _threads = threads.map( - (parentId, replies) => MapEntry(parentId, _failInterruptedRequests(replies)!), + (parentId, replies) => MapEntry(parentId, _settleInterruptedRequests(replies)!), ); }) .then((_) => retryFailedMessages()); @@ -73,7 +73,7 @@ class ChannelClientState { // when the app was closed. A send or delete is marked failed so it gets // retried. An update can't be retried as it was made, so it is settled and // the server's copy replaces it. - static List? _failInterruptedRequests(List? messages) { + static List? _settleInterruptedRequests(List? messages) { if (messages == null || !messages.any((it) => it.state.isOutgoing)) return messages; return [ @@ -86,7 +86,7 @@ class ChannelClientState { MessageOutgoing(state: Deleting(:final scope)) => message.copyWith( state: MessageState.deletingFailed(scope: scope), ), - _ => message, + MessageInitial() || MessageCompleted() || MessageFailed() => message, }, ]; } 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/test/src/client/channel/channel_interrupted_requests_test.dart b/packages/stream_chat/test/src/client/channel/channel_interrupted_requests_test.dart index 8c0bf86c9a..9ad64ddeb8 100644 --- 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 @@ -8,100 +8,152 @@ import '../../mocks.dart'; void main() { setUpAll(() => registerFallbackValue(FakeMessage())); - group('Channel.fromState', () { - 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('settles a message left updating, so it no longer shows as in progress', () { - final channel = _channel(messages: [_message(state: MessageState.updating)]); - - expect(channel.state!.messages.single.state, MessageState.updated); - }); - - test('does not send a message left updating again', () async { - final client = MockStreamChatClientWithPersistence(); - when(() => client.chatPersistenceClient.getChannelThreads(any())).thenAnswer((_) async => {}); - - _channel( - client: client, - messages: [_message(state: MessageState.updating)], - ); - await pumpEventQueue(); - - verifyNever( - () => client.updateMessage( - any(), - skipPush: any(named: 'skipPush'), - skipEnrichUrl: any(named: 'skipEnrichUrl'), - ), - ); - verifyNever( - () => client.partialUpdateMessage( - any(), - set: any(named: 'set'), - unset: any(named: 'unset'), - 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('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('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('settles a message left updating, so it no longer shows as in progress', () { + final channel = _channel(messages: [_message(state: MessageState.updating)]); + + expect(channel.state!.messages.single.state, MessageState.updated); + }); + + test('does not send a message left updating again', () async { + final client = MockStreamChatClientWithPersistence(); + when(() => client.chatPersistenceClient.getChannelThreads(any())).thenAnswer((_) async => {}); + + _channel( + client: client, + messages: [_message(state: MessageState.updating)], + ); + await pumpEventQueue(); + + verifyNever( + () => client.updateMessage( + any(), + skipPush: any(named: 'skipPush'), + skipEnrichUrl: any(named: 'skipEnrichUrl'), + ), + ); + verifyNever( + () => client.partialUpdateMessage( + any(), + set: any(named: 'set'), + unset: any(named: 'unset'), + 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('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), + ); }); } 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); From 44983e5a30427ecffd4e2e688e287567cd7bb33e Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 3 Nov 2026 12:58:43 +0100 Subject: [PATCH 4/8] fix(llc): align the gone-message delete with the duplicate-send handling (FLU-1136) Drops the comment the check's name already covers, gives the fix its own changelog entry, and tests `deleteMessage` directly. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012vTgFwqhUc2wKCWrw635yC --- packages/stream_chat/CHANGELOG.md | 1 + .../lib/src/client/channel/channel.dart | 2 -- .../test/src/client/channel/channel_test.dart | 27 +++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 15feaed63c..aafc31e8a2 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -8,6 +8,7 @@ 🐞 Fixed - Fixed a message stuck sending, updating or deleting forever when the app was closed before the request finished; an interrupted send or delete is retried, and an interrupted edit is discarded. +- 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 bd997176a8..a3842aecd3 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -1126,8 +1126,6 @@ class Channel { return response; } catch (e) { - // The message is already gone, such as after a delete that went through - // before the app was closed. if (e is StreamChatNetworkError && e.isMessageGoneError) { _markMessageAsDeleted(message, scope: scope); return EmptyResponse(); 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..0b0abdbd23 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -2464,6 +2464,33 @@ 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('should delete attachments for hard delete', () async { final attachments = List.generate( 3, From 137cb9d14d95752ff46a2dc0863477e830c46e00 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 3 Nov 2026 13:12:39 +0100 Subject: [PATCH 5/8] fix(llc): keep the push and link preview options of a send interrupted by a restart (FLU-1136) The sending state now carries `skipPush` and `skipEnrichUrl`, so a send restored from the cache is retried with the options it was made with. The mapping from an interrupted state to its settled one moves next to the message states. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012vTgFwqhUc2wKCWrw635yC --- .../lib/src/client/channel/channel.dart | 7 ++- .../client/channel/channel_client_state.dart | 19 ++------ .../lib/src/core/models/message_state.dart | 24 +++++++++- .../core/models/message_state.freezed.dart | 46 +++++++++++++++++-- .../lib/src/core/models/message_state.g.dart | 8 +++- .../channel_interrupted_requests_test.dart | 19 ++++++++ .../test/src/client/channel/channel_test.dart | 18 ++++++-- 7 files changed, 115 insertions(+), 26 deletions(-) diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index a3842aecd3..961d57357e 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; 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 d2894ca8a7..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 @@ -68,26 +68,15 @@ class ChannelClientState { .then((_) => retryFailedMessages()); } - // A message that was still being sent, updated or deleted when this state - // was created belongs to a request that never finished, such as one cut off - // when the app was closed. A send or delete is marked failed so it gets - // retried. An update can't be retried as it was made, so it is settled and - // the server's copy replaces it. + // 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) - switch (message.state) { - MessageOutgoing(state: Sending()) => message.copyWith( - state: MessageState.sendingFailed(skipPush: false, skipEnrichUrl: false), - ), - MessageOutgoing(state: Updating()) => message.copyWith(state: MessageState.updated), - MessageOutgoing(state: Deleting(:final scope)) => message.copyWith( - state: MessageState.deletingFailed(scope: scope), - ), - MessageInitial() || MessageCompleted() || MessageFailed() => message, - }, + if (message.state.isOutgoing) message.copyWith(state: message.state.settled) else message, ]; } 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..980ff093b9 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,25 @@ 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 or delete becomes failed, so it can be retried. An update can't + /// be retried as it was made, so it becomes updated. 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: Updating()) => MessageState.updated, + MessageOutgoing(state: Deleting(:final scope)) => MessageState.deletingFailed(scope: scope), + MessageInitial() || MessageCompleted() || MessageFailed() => this, + }; + } + /// Returns true if the message is in initial state. bool get isInitial => this is MessageInitial; @@ -374,7 +393,10 @@ 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; 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..417c20a809 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,23 +450,53 @@ 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() 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..deaa9ab2b3 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,9 +45,15 @@ 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, }; 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 index 9ad64ddeb8..4c90961eef 100644 --- 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 @@ -128,6 +128,25 @@ void main() { 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 => {}); 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 0b0abdbd23..c4e54d65c8 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, ), ], From 8e13cd003d021a0cfaef514e2d6ff28c0f376a0f Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 3 Nov 2026 13:12:55 +0100 Subject: [PATCH 6/8] refactor(llc): read the deleting scope from the matched state (FLU-1136) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012vTgFwqhUc2wKCWrw635yC --- packages/stream_chat/lib/src/core/models/message_state.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 980ff093b9..60856e04bc 100644 --- a/packages/stream_chat/lib/src/core/models/message_state.dart +++ b/packages/stream_chat/lib/src/core/models/message_state.dart @@ -22,7 +22,7 @@ extension MessageStateX on MessageState { skipEnrichUrl: state.skipEnrichUrl, ), MessageOutgoing(state: Updating()) => MessageState.updated, - MessageOutgoing(state: Deleting(:final scope)) => MessageState.deletingFailed(scope: scope), + MessageOutgoing(state: final Deleting state) => MessageState.deletingFailed(scope: state.scope), MessageInitial() || MessageCompleted() || MessageFailed() => this, }; } From 110bd29a90be7bc9ccca6521d260d0c6d32ed017 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 3 Nov 2026 13:15:15 +0100 Subject: [PATCH 7/8] fix(llc): delete the attachments of a hard delete that finds the message gone (FLU-1136) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012vTgFwqhUc2wKCWrw635yC --- .../lib/src/client/channel/channel.dart | 3 +- .../test/src/client/channel/channel_test.dart | 34 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index 961d57357e..15c6d01fc4 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -1132,7 +1132,8 @@ class Channel { return response; } catch (e) { if (e is StreamChatNetworkError && e.isMessageGoneError) { - _markMessageAsDeleted(message, scope: scope); + final deletedMessage = _markMessageAsDeleted(message, scope: scope); + if (scope.hard) _deleteMessageAttachments(deletedMessage); return EmptyResponse(); } 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 c4e54d65c8..e8451066b8 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -2503,6 +2503,40 @@ void main() { 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, From 7334756262d98aed3f24ebbde986f61f2135b93c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 3 Nov 2026 13:34:43 +0100 Subject: [PATCH 8/8] fix(llc): retry an update interrupted by a restart with its options (FLU-1136) The updating state now records the options of a full update or the changes of a partial one, so an update restored from the cache is retried as it was made instead of being dropped. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012vTgFwqhUc2wKCWrw635yC --- packages/stream_chat/CHANGELOG.md | 2 +- .../lib/src/client/channel/channel.dart | 15 +++- .../lib/src/core/models/message_state.dart | 25 +++++-- .../core/models/message_state.freezed.dart | 64 +++++++++++++++-- .../lib/src/core/models/message_state.g.dart | 12 +++- .../channel_interrupted_requests_test.dart | 66 +++++++++++++++--- .../test/src/client/channel/channel_test.dart | 68 ++++++++++++------- 7 files changed, 202 insertions(+), 50 deletions(-) diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index aafc31e8a2..09eded4c2a 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -7,7 +7,7 @@ 🐞 Fixed -- Fixed a message stuck sending, updating or deleting forever when the app was closed before the request finished; an interrupted send or delete is retried, and an interrupted edit is discarded. +- 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). diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index 15c6d01fc4..794b909957 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -915,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) { @@ -1008,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(), ); 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 60856e04bc..75d62f7b34 100644 --- a/packages/stream_chat/lib/src/core/models/message_state.dart +++ b/packages/stream_chat/lib/src/core/models/message_state.dart @@ -11,9 +11,8 @@ 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 or delete becomes failed, so it can be retried. An update can't - /// be retried as it was made, so it becomes updated. Any other state is - /// returned as is. + /// 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) { @@ -21,7 +20,16 @@ extension MessageStateX on MessageState { skipPush: state.skipPush, skipEnrichUrl: state.skipEnrichUrl, ), - MessageOutgoing(state: Updating()) => MessageState.updated, + 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, }; @@ -399,7 +407,14 @@ sealed class OutgoingState with _$OutgoingState { }) = 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 417c20a809..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 @@ -502,15 +502,39 @@ as bool, @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() { @@ -519,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 deaa9ab2b3..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 @@ -57,9 +57,19 @@ Map _$SendingToJson(Sending instance) => { '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 index 4c90961eef..8ce32c7ffc 100644 --- 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 @@ -17,34 +17,78 @@ void main() { ); }); - test('settles a message left updating, so it no longer shows as in progress', () { - final channel = _channel(messages: [_message(state: MessageState.updating)]); + 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.updated); + expect( + channel.state!.messages.single.state, + MessageState.updatingFailed(skipPush: true, skipEnrichUrl: true), + ); }); - test('does not send a message left updating again', () async { + 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: MessageState.updating)], + messages: [_message(state: updating)], ); await pumpEventQueue(); - verifyNever( - () => client.updateMessage( + 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(), - skipPush: any(named: 'skipPush'), + 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), ); - verifyNever( + + _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(), - set: any(named: 'set'), - unset: any(named: 'unset'), + skipPush: any(named: 'skipPush'), skipEnrichUrl: any(named: 'skipEnrichUrl'), ), ); 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 e8451066b8..7c143a22a1 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -1728,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, ), ], @@ -1779,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, ), ], @@ -1835,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, ), ], @@ -1884,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, ), ], @@ -2069,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, @@ -2132,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, @@ -2200,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, @@ -2273,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, @@ -2338,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, @@ -2400,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, @@ -2714,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( @@ -2770,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( @@ -2828,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( @@ -2896,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(