From 43d81563cec2ed9053a22886e054db79e6de8e52 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 22 Sep 2025 15:09:19 +0200 Subject: [PATCH 1/5] fix(llc): prevent sending empty messages This commit prevents the sending of messages that are considered empty by the backend. A new private method `_isMessageValidForUpload` is added to the `Channel` class. This method checks if a message has: - Non-empty text - Attachments - A quoted message ID - A poll ID If none of these conditions are met, the message is considered invalid. The `sendMessage` method now calls `_isMessageValidForUpload` before attempting to send the message to the server. If the message is invalid, it is removed from the local state, a warning is logged, and a `StreamChatError` is thrown, preventing the message from being sent. --- .../stream_chat/lib/src/client/channel.dart | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index 184923355..8c1420a6f 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -658,6 +658,15 @@ class Channel { }); } + bool _isMessageValidForUpload(Message message) { + final hasText = message.text?.trim().isNotEmpty == true; + final hasAttachments = message.attachments.isNotEmpty; + final hasQuotedMessage = message.quotedMessageId != null; + final hasPoll = message.pollId != null; + + return hasText || hasAttachments || hasQuotedMessage || hasPoll; + } + final _sendMessageLock = Lock(); /// Send a [message] to this channel. @@ -716,6 +725,15 @@ class Channel { message = await attachmentsUploadCompleter.future; } + // Validate the final message before sending it to the server. + if (_isMessageValidForUpload(message) == false) { + client.logger.warning('Message is not valid for sending, removing it'); + + // Remove the message from state as it is invalid. + state!.deleteMessage(message, hardDelete: true); + throw const StreamChatError('Message is not valid for sending'); + } + // Wait for the previous sendMessage call to finish. Otherwise, the order // of messages will not be maintained. final response = await _sendMessageLock.synchronized( From 56ccec67f59103a3de136f40f3cd35b75336a949 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 22 Sep 2025 15:27:30 +0200 Subject: [PATCH 2/5] test: add tests for sending messages with cancelled attachments This commit adds tests to ensure correct behavior when sending messages with attachments that are subsequently cancelled. The new tests cover the following scenarios: - **All attachments cancelled, no other content:** The message should not be sent. - **Attachment cancelled, text exists:** The message should be sent without the attachment. - **Attachment cancelled, quoted message exists:** The message should be sent without the attachment. - **Attachment cancelled, poll exists:** The message should be sent without the attachment. --- .../test/src/client/channel_test.dart | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index 9aed5cea1..f12080ac9 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -245,6 +245,7 @@ void main() { test('should work fine', () async { final message = Message( id: 'test-message-id', + text: 'Hello world!', user: client.state.currentUser, ); @@ -459,6 +460,236 @@ void main() { channelType, )).called(1); }); + + test( + 'should not send empty message when all attachments are cancelled', + () async { + final attachment = Attachment( + id: 'test-attachment-id', + type: 'image', + file: AttachmentFile(size: 100, path: 'test-file-path'), + ); + + final message = Message( + id: 'test-message-id', + attachments: [attachment], + ); + + when( + () => client.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + ).thenAnswer( + (_) async => throw StreamChatNetworkError.raw( + code: 0, + message: 'Request cancelled', + isRequestCancelledError: true, + ), + ); + + expect( + () => channel.sendMessage(message), + throwsA(isA()), + ); + + verifyNever( + () => client.sendMessage(any(), channelId, channelType), + ); + }, + ); + + test( + 'should send message when attachment is cancelled but text exists', + () async { + final attachment = Attachment( + id: 'test-attachment-id', + type: 'image', + file: AttachmentFile(size: 100, path: 'test-file-path'), + ); + + final message = Message( + id: 'test-message-id', + text: 'Hello world!', + attachments: [attachment], + ); + + when( + () => client.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + ).thenAnswer( + (_) async => throw StreamChatNetworkError.raw( + code: 0, + message: 'Request cancelled', + isRequestCancelledError: true, + ), + ); + + when( + () => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + ), + ).thenAnswer( + (_) async => SendMessageResponse() + ..message = message.copyWith( + attachments: [], + state: MessageState.sent, + ), + ); + + final res = await channel.sendMessage(message); + + expect(res, isNotNull); + expect(res.message.text, 'Hello world!'); + + verify( + () => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + ), + ); + }, + ); + + test( + 'should send message when attachment is cancelled but quoted message exists', + () async { + final attachment = Attachment( + id: 'test-attachment-id', + type: 'image', + file: AttachmentFile(size: 100, path: 'test-file-path'), + ); + + final quotedMessage = Message( + id: 'quoted-123', + text: 'Original message', + ); + + final message = Message( + id: 'test-message-id', + attachments: [attachment], + quotedMessageId: quotedMessage.id, + ); + + when( + () => client.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + ).thenAnswer( + (_) async => throw StreamChatNetworkError.raw( + code: 0, + message: 'Request cancelled', + isRequestCancelledError: true, + ), + ); + + when( + () => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + ), + ).thenAnswer( + (_) async => SendMessageResponse() + ..message = message.copyWith( + attachments: [], + state: MessageState.sent, + ), + ); + + final res = await channel.sendMessage(message); + + expect(res, isNotNull); + expect(res.message.quotedMessageId, quotedMessage.id); + + verify( + () => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + ), + ); + }, + ); + + test( + 'should send message when attachment is cancelled but poll exists', + () async { + final attachment = Attachment( + id: 'test-attachment-id', + type: 'image', + file: AttachmentFile(size: 100, path: 'test-file-path'), + ); + + final message = Message( + id: 'test-message-id', + attachments: [attachment], + pollId: 'poll-123', + ); + + when( + () => client.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + ).thenAnswer( + (_) async => throw StreamChatNetworkError.raw( + code: 0, + message: 'Request cancelled', + isRequestCancelledError: true, + ), + ); + + when( + () => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + ), + ).thenAnswer( + (_) async => SendMessageResponse() + ..message = message.copyWith( + attachments: [], + state: MessageState.sent, + ), + ); + + final res = await channel.sendMessage(message); + + expect(res, isNotNull); + expect(res.message.pollId, 'poll-123'); + + verify( + () => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + ), + ); + }, + ); }); group('`.createDraft`', () { From 5abf7deba27a30018702a4312f11912c142e9887 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 22 Sep 2025 15:31:50 +0200 Subject: [PATCH 3/5] test: add additional test --- .../stream_chat/test/src/client/channel_test.dart | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index f12080ac9..1b50a0c45 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -461,6 +461,19 @@ void main() { )).called(1); }); + test('should not send if the message is invalid', () async { + final message = Message(id: 'test-message-id'); + + expect( + () => channel.sendMessage(message), + throwsA(isA()), + ); + + verifyNever( + () => client.sendMessage(any(), channelId, channelType), + ); + }); + test( 'should not send empty message when all attachments are cancelled', () async { From 8940e53a211222f38d7ee3bfbc437e6ac1e0cdbe Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 22 Sep 2025 15:32:06 +0200 Subject: [PATCH 4/5] chore: update CHANGELOG.md --- packages/stream_chat/CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index fb5e53a74..13c998473 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -1,3 +1,10 @@ +## Upcoming + +🐞 Fixed + +- Fixed `Channel.sendMessage` to prevent sending empty messages when all attachments are cancelled + during upload. + ## 9.16.0 🐞 Fixed From 10a48503e015a1c96f892356fbe7723a2a5d3da0 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 22 Sep 2025 15:51:41 +0200 Subject: [PATCH 5/5] chore: improve tests --- .../test/src/client/channel_test.dart | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index 1b50a0c45..46ec0930d 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -510,6 +510,17 @@ void main() { throwsA(isA()), ); + verify( + () => client.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + ); + verifyNever( () => client.sendMessage(any(), channelId, channelType), ); @@ -567,6 +578,17 @@ void main() { expect(res, isNotNull); expect(res.message.text, 'Hello world!'); + verify( + () => client.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + ); + verify( () => client.sendMessage( any(that: isSameMessageAs(message)), @@ -633,6 +655,17 @@ void main() { expect(res, isNotNull); expect(res.message.quotedMessageId, quotedMessage.id); + verify( + () => client.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + ); + verify( () => client.sendMessage( any(that: isSameMessageAs(message)), @@ -694,6 +727,17 @@ void main() { expect(res, isNotNull); expect(res.message.pollId, 'poll-123'); + verify( + () => client.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + ); + verify( () => client.sendMessage( any(that: isSameMessageAs(message)),