diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 10f4ae340..04e3d31f7 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -2,6 +2,8 @@ 🐞 Fixed +- Fixed `Channel.sendMessage` to prevent sending empty messages when all attachments are cancelled + during upload. - Fixed `toDraftMessage` to only include successfully uploaded attachments in draft messages. ## 9.16.0 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( diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index 9aed5cea1..46ec0930d 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,293 @@ void main() { channelType, )).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 { + 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()), + ); + + verify( + () => client.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + ); + + 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.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + ); + + 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.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + ); + + 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.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + ); + + verify( + () => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + ), + ); + }, + ); }); group('`.createDraft`', () {