From 5684b535e029e255fc95fe59fee964c1bebb5229 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 23 Sep 2026 13:56:04 +0200 Subject: [PATCH 1/7] fix(ui): respect the upload-file capability in the composer Without upload-file every upload is rejected by the backend, but the composer still offered the attachment button, voice recording and drag and drop, so the message only failed on send. Hide the button and voice recording, and ignore dropped files and keyboard images, when the capability is missing. The composer reacts to capability changes. A channel that isn't created yet has no capabilities to check, so it keeps uploads available. This also fixes keyboard images being off on such channels, since they checked canUploadFile directly. Co-Authored-By: Claude Opus 5.5 --- packages/stream_chat_flutter/CHANGELOG.md | 1 + .../stream_message_composer.dart | 37 ++++- ...ssage_composer_upload_capability_test.dart | 155 ++++++++++++++++++ 3 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 packages/stream_chat_flutter/test/src/message_input/message_composer_upload_capability_test.dart diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 3bd21b1b34..40af9865a5 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -18,6 +18,7 @@ - A deleted message now renders the timestamp and delivery status below the placeholder, matching the design, and no longer shows the "Edited" marker — there is no text left to have been edited. - `AccessibleMessagePreviewFormatter.formatMessageSemanticsLabel` must now return the body without a speaker prefix when `channel` is omitted. An implementation that prefixes unconditionally makes a message row announce "You said, You: hello". - `StreamImageCDN.resolveUrl` now leaves a URL that already asks for a specific size alone, rather than replacing it with the size the layout computed. +- `StreamMessageComposer` now hides the attachment button and voice recording, and ignores dropped files and keyboard images, when the user lacks the `upload-file` capability in the channel. The backend rejected those uploads anyway, so the message used to fail only on send. A channel that isn't created yet still offers uploads. ⚠️ Deprecated diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart index 28db027231..16d5bb31a8 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart @@ -990,11 +990,35 @@ class DefaultStreamMessageComposerState extends State( + stream: channel.ownCapabilitiesStream.map((it) => it.contains(ChannelCapability.uploadFile)), + initialData: channel.canUploadFile, + builder: (context, canUploadFile) { + return _buildMessageInputContent(context, controller, focusNode, canUploadFile: canUploadFile); + }, + ); + } + + Widget _buildMessageInputContent( + BuildContext context, + StreamMessageComposerController controller, + FocusNode focusNode, { + required bool canUploadFile, + }) { final currentUserId = StreamChat.of(context).currentUser?.id; final isFloating = _resolveSurfaceStyle(context).isFloating; @@ -1005,6 +1029,8 @@ class DefaultStreamMessageComposerState extends State[]; for (final file in details.files) { attachments.add(await file.toAttachment(type: AttachmentType.file)); @@ -1019,13 +1045,17 @@ class DefaultStreamMessageComposerState extends State _pumpComposer( + WidgetTester tester, { + required StreamMessageComposerController controller, + List ownCapabilities = _canUpload, + bool initialized = true, +}) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(ownCapabilities: ownCapabilities); + final channelState = MockChannelState(); + final member = Member( + userId: 'user-id', + user: User(id: 'user-id'), + ); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(OwnUser(id: 'user-id'))); + when(() => client.appSettings).thenReturn( + const AppSettings( + name: 'test', + fileUploadConfig: UploadConfig(), + imageUploadConfig: UploadConfig(), + autoTranslationEnabled: false, + asyncUrlEnrichEnabled: false, + ), + ); + when(() => channel.state).thenReturn(initialized ? channelState : null); + when(() => channel.client).thenReturn(client); + when(channel.getRemainingCooldown).thenReturn(0); + when(() => channel.lastMessageAt).thenReturn(DateTime.parse('2020-06-22 12:00:00')); + when(() => channel.extraData).thenReturn({'name': 'test'}); + when(() => channel.extraDataStream).thenAnswer((_) => Stream.value({'name': 'test'})); + when(() => channelState.members).thenReturn([member]); + when(() => channelState.membersStream).thenAnswer((_) => Stream.value([member])); + when(() => channelState.messages).thenReturn([]); + when(() => channelState.messagesStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.draft).thenReturn(null); + when(() => channelState.draftStream).thenAnswer((_) => Stream.value(null)); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + body: StreamMessageComposer( + messageComposerController: controller, + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); +} + +final _attachmentButton = find.descendant( + of: find.byType(DefaultStreamMessageComposerLeading), + matching: find.byType(StreamButton), +); + +final _micButton = find.byKey(const ValueKey('microphone_key')); + +Future _dropFile(WidgetTester tester) async { + tester.widget(find.byType(DropTarget)).onDragDone!( + DropDoneDetails( + files: [DropItemFile.fromData(Uint8List(100), path: '/tmp/doc.pdf', name: 'doc.pdf')], + localPosition: Offset.zero, + globalPosition: Offset.zero, + ), + ); + await tester.pumpAndSettle(); +} + +ContentInsertionConfiguration? _keyboardConfig(WidgetTester tester) { + return tester.widget(find.byType(EditableText)).contentInsertionConfiguration; +} + +void main() { + final originalRecordPlatform = RecordPlatform.instance; + setUp(() => RecordPlatform.instance = FakeRecordPlatform()); + tearDown(() => RecordPlatform.instance = originalRecordPlatform); + + late StreamMessageComposerController controller; + setUp(() => controller = StreamMessageComposerController()); + tearDown(() => controller.dispose()); + + group('StreamMessageComposer with the upload-file capability', () { + testWidgets('offers every way to add an upload', (tester) async { + await _pumpComposer(tester, controller: controller); + + expect(_attachmentButton, findsOneWidget); + expect(_micButton, findsOneWidget); + expect(_keyboardConfig(tester), isNotNull); + + await _dropFile(tester); + expect(controller.attachments, hasLength(1)); + }); + }); + + group('StreamMessageComposer without the upload-file capability', () { + testWidgets('hides the attachment button', (tester) async { + await _pumpComposer(tester, controller: controller, ownCapabilities: _cannotUpload); + + expect(_attachmentButton, findsNothing); + }); + + testWidgets('hides voice recording', (tester) async { + await _pumpComposer(tester, controller: controller, ownCapabilities: _cannotUpload); + + expect(_micButton, findsNothing); + }); + + testWidgets('ignores dropped files', (tester) async { + await _pumpComposer(tester, controller: controller, ownCapabilities: _cannotUpload); + + await _dropFile(tester); + + expect(controller.attachments, isEmpty); + }); + + testWidgets('does not accept keyboard images', (tester) async { + await _pumpComposer(tester, controller: controller, ownCapabilities: _cannotUpload); + + expect(_keyboardConfig(tester), isNull); + }); + }); + + group('StreamMessageComposer on a channel that is not created yet', () { + testWidgets('keeps uploads available, as there are no capabilities to check', (tester) async { + await _pumpComposer(tester, controller: controller, ownCapabilities: const [], initialized: false); + + expect(_attachmentButton, findsOneWidget); + expect(_micButton, findsOneWidget); + expect(_keyboardConfig(tester), isNotNull); + }); + }); +} From e7af920e8c66747bd9d2924eedde017813a7403d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 12 Oct 2026 16:55:11 +0200 Subject: [PATCH 2/7] fix(ui): keep polls and commands available without upload-file Without the upload-file capability the composer now removes only the picker's upload options, so poll creation and commands stay reachable. The attachment button hides only when nothing is left to offer. - Read all capabilities from one live list; config is read safely, so a channel that isn't created yet no longer throws. - Hide the commands option in channels without commands. - Dropped files respect disableAttachments and allowedAttachmentPickerTypes. - Keyboard images stay available after a command is cleared; inserts are ignored while a command is active. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/stream_chat_flutter/CHANGELOG.md | 4 +- .../stream_message_composer.dart | 120 ++++---- ...ssage_composer_content_insertion_test.dart | 15 +- ...ssage_composer_upload_capability_test.dart | 262 ++++++++++++++---- 4 files changed, 291 insertions(+), 110 deletions(-) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 40af9865a5..102885403e 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -18,7 +18,7 @@ - A deleted message now renders the timestamp and delivery status below the placeholder, matching the design, and no longer shows the "Edited" marker — there is no text left to have been edited. - `AccessibleMessagePreviewFormatter.formatMessageSemanticsLabel` must now return the body without a speaker prefix when `channel` is omitted. An implementation that prefixes unconditionally makes a message row announce "You said, You: hello". - `StreamImageCDN.resolveUrl` now leaves a URL that already asks for a specific size alone, rather than replacing it with the size the layout computed. -- `StreamMessageComposer` now hides the attachment button and voice recording, and ignores dropped files and keyboard images, when the user lacks the `upload-file` capability in the channel. The backend rejected those uploads anyway, so the message used to fail only on send. A channel that isn't created yet still offers uploads. +- `StreamMessageComposer` now hides upload options, voice recording included, when the user lacks the `upload-file` capability in the channel. ⚠️ Deprecated @@ -41,6 +41,8 @@ - Fixed one image rendition yielding two cache entries when its resize parameters arrived in a different order, or a crop on the URL survived a resize that does not crop. - Fixed image attachments not being resized at all when the URL carried a crop or a resize mode but no dimensions. - Fixed a URL whose host merely contains `stream-io-cdn.com` being treated as Stream's CDN. +- Fixed `StreamMessageComposer` accepting dropped files when `disableAttachments` is set or files aren't in `allowedAttachmentPickerTypes`. +- Fixed the attachment picker showing an empty commands tab in channels without commands. ## 10.4.0 diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart index 16d5bb31a8..e1692d3f12 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:math' as math; +import 'package:collection/collection.dart'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:flutter/material.dart'; // Needed for RenderProxyBox; it also re-exports the semantics library that @@ -990,10 +991,8 @@ class DefaultStreamMessageComposerState extends State( - stream: channel.ownCapabilitiesStream.map((it) => it.contains(ChannelCapability.uploadFile)), - initialData: channel.canUploadFile, - builder: (context, canUploadFile) { - return _buildMessageInputContent(context, controller, focusNode, canUploadFile: canUploadFile); + return BetterStreamBuilder>( + initialData: channel.ownCapabilities, + stream: channel.ownCapabilitiesStream, + comparator: const ListEquality().equals, + builder: (context, capabilities) { + return _buildMessageInputContent(context, controller, focusNode, capabilities); }, ); } @@ -1016,20 +1017,25 @@ class DefaultStreamMessageComposerState extends State capabilities, + ) { final currentUserId = StreamChat.of(context).currentUser?.id; final isFloating = _resolveSurfaceStyle(context).isFloating; return StreamMessageValueListenableBuilder( valueListenable: controller, builder: (context, value, _) { + final canUploadFile = capabilities.contains(ChannelCapability.uploadFile); + final canRecordVoice = widget.props.enableVoiceRecording && canUploadFile; + final allowedTypes = _getAllowedAttachmentPickerTypes(capabilities); + final canOpenPicker = allowedTypes.isNotEmpty; + // Extracted so the floating gradient can wrap just the pill, keeping // gradient height stable when the picker (a sibling) opens. final pill = DropTarget( onDragDone: (details) async { - if (!canUploadFile) return; + if (!allowedTypes.contains(AttachmentPickerType.files)) return; final attachments = []; for (final file in details.files) { @@ -1045,17 +1051,13 @@ class DefaultStreamMessageComposerState extends State allowedTypes, + ) { if (!_isPickerVisible) return const SizedBox.shrink(); - final allowedTypes = _getAllowedAttachmentPickerTypes(); - - final isWebOrDesktop = switch (CurrentPlatform.type) { + final useSystemPicker = switch (CurrentPlatform.type) { + _ when widget.props.useSystemAttachmentPicker => true, PlatformType.android || PlatformType.ios => false, _ => true, }; - final useSystemPicker = widget.props.useSystemAttachmentPicker || isWebOrDesktop; if (useSystemPicker) { return systemAttachmentPickerBuilder( @@ -1207,25 +1210,42 @@ class DefaultStreamMessageComposerState extends State _getAllowedAttachmentPickerTypes() { - final allowedTypes = widget.props.allowedAttachmentPickerTypes.where((type) { - if (type != AttachmentPickerType.poll) return true; - - // We don't allow editing polls. - if (_isEditing) return false; - // We don't allow creating polls in threads. - if (_effectiveController.message.parentId != null) return false; + // Returns the list of allowed attachment picker types based on the user's + // capabilities, the current channel configuration and context. + List _getAllowedAttachmentPickerTypes( + List capabilities, + ) { + if (widget.props.disableAttachments) return const []; - // Otherwise, check if the user has the permission to send polls. - final channel = StreamChannel.of(context).channel; - return channel.config?.polls == true && channel.canSendPoll; - }); + final canUploadFile = capabilities.contains(ChannelCapability.uploadFile); + final channel = StreamChannel.of(context).channel; + final channelConfig = channel.state != null ? channel.config : null; + final pickerTypes = widget.props.allowedAttachmentPickerTypes; + final allowedTypes = pickerTypes.where( + (type) => switch (type) { + ImagesPickerType() || VideosPickerType() || AudiosPickerType() || FilesPickerType() => canUploadFile, + PollPickerType() => _canCreatePoll(capabilities, channelConfig), + CommandPickerType() => channelConfig?.commands.isNotEmpty ?? false, + CustomAttachmentPickerType() => true, + }, + ); return allowedTypes.toList(growable: false); } + bool _canCreatePoll( + List capabilities, + ChannelConfig? channelConfig, + ) { + // We don't allow editing polls. + if (_isEditing) return false; + // We don't allow creating polls in threads. + if (_effectiveController.message.parentId != null) return false; + + // Otherwise, check if the user has the permission to send polls. + return channelConfig?.polls == true && capabilities.contains(ChannelCapability.sendPoll); + } + /// Toggles the inline attachment picker visibility. void _onAttachmentButtonPressed() => _isPickerVisible ? _hidePicker() : _showPicker(); @@ -1494,20 +1514,18 @@ class DefaultStreamMessageComposerState extends State allowedTypes, + ) { + if (!allowedTypes.contains(AttachmentPickerType.images)) return null; return ContentInsertionConfiguration(onContentInserted: _onContentInserted); } void _onContentInserted(KeyboardInsertedContent content) { - // The platform reads the content before handing it over, and a failed read - // can arrive as no data or as empty data; either way there is nothing to - // attach. + // Commands carry no attachments. + if (_commandEnabled) return; + final bytes = content.data; if (bytes == null || bytes.isEmpty) return; diff --git a/packages/stream_chat_flutter/test/src/message_input/message_composer_content_insertion_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_composer_content_insertion_test.dart index e2535a6e7b..9e6a4d2438 100644 --- a/packages/stream_chat_flutter/test/src/message_input/message_composer_content_insertion_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/message_composer_content_insertion_test.dart @@ -187,13 +187,24 @@ void main() { expect(_editableText(tester).contentInsertionConfiguration, isNull); }); - testWidgets('is off while a command is active', (tester) async { + testWidgets('stays on while a command is active', (tester) async { await _pumpComposer(tester, controller: controller); controller.setCommand(Command(name: 'giphy')); await tester.pumpAndSettle(); - expect(_editableText(tester).contentInsertionConfiguration, isNull); + expect(_editableText(tester).contentInsertionConfiguration, isNotNull); + }); + + testWidgets('ignores an image inserted while a command is active', (tester) async { + await _pumpComposer(tester, controller: controller); + controller.setCommand(Command(name: 'giphy')); + await tester.pumpAndSettle(); + + _insert(tester, _content()); + await tester.pumpAndSettle(); + + expect(controller.attachments, isEmpty); }); testWidgets('is off when the user cannot upload files', (tester) async { diff --git a/packages/stream_chat_flutter/test/src/message_input/message_composer_upload_capability_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_composer_upload_capability_test.dart index 102556a7c3..b4c7ed2299 100644 --- a/packages/stream_chat_flutter/test/src/message_input/message_composer_upload_capability_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/message_composer_upload_capability_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:desktop_drop/desktop_drop.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -11,16 +13,189 @@ import '../mocks.dart'; const _canUpload = [ChannelCapability.sendMessage, ChannelCapability.uploadFile]; const _cannotUpload = [ChannelCapability.sendMessage]; +const _cannotUploadButCanSendPoll = [ChannelCapability.sendMessage, ChannelCapability.sendPoll]; +final _giphy = Command(name: 'giphy', args: '[text]'); + +void main() { + final originalRecordPlatform = RecordPlatform.instance; + setUp(() => RecordPlatform.instance = FakeRecordPlatform()); + tearDown(() => RecordPlatform.instance = originalRecordPlatform); + + late StreamMessageComposerController controller; + setUp(() => controller = StreamMessageComposerController()); + tearDown(() => controller.dispose()); + + group('with the upload-file capability', () { + testWidgets('shows the attachment button', (tester) async { + await _pumpComposer(tester, controller: controller); + + expect(_attachmentButton, findsOneWidget); + }); + + testWidgets('shows voice recording', (tester) async { + await _pumpComposer(tester, controller: controller); + + expect(_micButton, findsOneWidget); + }); + + testWidgets('accepts dropped files', (tester) async { + await _pumpComposer(tester, controller: controller); + + await _dropFile(tester); + + expect(controller.attachments, hasLength(1)); + }); + + testWidgets('accepts keyboard images', (tester) async { + await _pumpComposer(tester, controller: controller); + + expect(_keyboardConfig(tester), isNotNull); + }); + }); + + group('without the upload-file capability', () { + testWidgets('offers only the system picker options that upload nothing', (tester) async { + await _pumpComposer(tester, controller: controller, ownCapabilities: _cannotUploadButCanSendPoll); + + expect(await _openSystemPicker(tester), {'poll-creator'}); + }); + + testWidgets('offers only the tabbed picker tabs that upload nothing', (tester) async { + CurrentPlatform.debugCurrentPlatformOverride = PlatformType.android; + addTearDown(() => CurrentPlatform.debugCurrentPlatformOverride = null); + + await _pumpComposer( + tester, + controller: controller, + ownCapabilities: _cannotUploadButCanSendPoll, + commands: [_giphy], + useSystemAttachmentPicker: false, + ); + + expect(await _openTabbedPicker(tester), {'poll-creator', 'command-picker'}); + }); + + testWidgets('hides the attachment button when the channel has no polls or commands', (tester) async { + await _pumpComposer(tester, controller: controller, ownCapabilities: _cannotUpload); + + expect(_attachmentButton, findsNothing); + }); + + testWidgets('hides the attachment button when every allowed option uploads', (tester) async { + await _pumpComposer( + tester, + controller: controller, + ownCapabilities: _cannotUpload, + allowedAttachmentPickerTypes: const [AttachmentPickerType.images, AttachmentPickerType.files], + ); + + expect(_attachmentButton, findsNothing); + }); + + testWidgets('hides voice recording', (tester) async { + await _pumpComposer(tester, controller: controller, ownCapabilities: _cannotUpload); + + expect(_micButton, findsNothing); + }); + + testWidgets('ignores dropped files', (tester) async { + await _pumpComposer(tester, controller: controller, ownCapabilities: _cannotUpload); + + await _dropFile(tester); + + expect(controller.attachments, isEmpty); + }); + + testWidgets('does not accept keyboard images', (tester) async { + await _pumpComposer(tester, controller: controller, ownCapabilities: _cannotUpload); + + expect(_keyboardConfig(tester), isNull); + }); + }); + + group('on a channel that is not created yet', () { + testWidgets('shows the attachment button', (tester) async { + await _pumpComposer(tester, controller: controller, ownCapabilities: const [], initialized: false); + + expect(_attachmentButton, findsOneWidget); + }); + + testWidgets('shows voice recording', (tester) async { + await _pumpComposer(tester, controller: controller, ownCapabilities: const [], initialized: false); + + expect(_micButton, findsOneWidget); + }); + + testWidgets('accepts keyboard images', (tester) async { + await _pumpComposer(tester, controller: controller, ownCapabilities: const [], initialized: false); + + expect(_keyboardConfig(tester), isNotNull); + }); + }); + + testWidgets('hides voice recording once the upload-file capability is revoked', (tester) async { + final capabilities = StreamController>.broadcast(); + addTearDown(capabilities.close); + + await _pumpComposer(tester, controller: controller, ownCapabilitiesStream: capabilities.stream); + capabilities.add(_cannotUpload); + await tester.pumpAndSettle(); + + expect(_micButton, findsNothing); + }); + + testWidgets('leaves out the command tab when the channel has no commands', (tester) async { + CurrentPlatform.debugCurrentPlatformOverride = PlatformType.android; + addTearDown(() => CurrentPlatform.debugCurrentPlatformOverride = null); + + await _pumpComposer( + tester, + controller: controller, + ownCapabilities: _cannotUploadButCanSendPoll, + useSystemAttachmentPicker: false, + ); + + expect(await _openTabbedPicker(tester), {'poll-creator'}); + }); + + testWidgets('hides polls and commands too when attachments are disabled', (tester) async { + await _pumpComposer( + tester, + controller: controller, + ownCapabilities: const [..._canUpload, ChannelCapability.sendPoll], + commands: [_giphy], + disableAttachments: true, + ); + + expect(_attachmentButton, findsNothing); + }); + + testWidgets('ignores dropped files when attachments are disabled', (tester) async { + await _pumpComposer(tester, controller: controller, disableAttachments: true); + + await _dropFile(tester); + + expect(controller.attachments, isEmpty); + }); +} Future _pumpComposer( WidgetTester tester, { required StreamMessageComposerController controller, List ownCapabilities = _canUpload, + Stream>? ownCapabilitiesStream, + List allowedAttachmentPickerTypes = AttachmentPickerType.values, + List commands = const [], + bool disableAttachments = false, + bool useSystemAttachmentPicker = true, bool initialized = true, }) async { final client = MockClient(); final clientState = MockClientState(); - final channel = MockChannel(ownCapabilities: ownCapabilities); + final channel = switch (ownCapabilitiesStream) { + final stream? => _ChannelWithCapabilitiesStream(ownCapabilities: ownCapabilities, capabilitiesStream: stream), + _ => MockChannel(ownCapabilities: ownCapabilities), + }; final channelState = MockChannelState(); final member = Member( userId: 'user-id', @@ -41,6 +216,11 @@ Future _pumpComposer( ); when(() => channel.state).thenReturn(initialized ? channelState : null); when(() => channel.client).thenReturn(client); + if (initialized) { + when(() => channel.config).thenReturn(ChannelConfig(polls: true, commands: commands)); + } else { + when(() => channel.config).thenThrow(StateError('Channel has not been initialized')); + } when(channel.getRemainingCooldown).thenReturn(0); when(() => channel.lastMessageAt).thenReturn(DateTime.parse('2020-06-22 12:00:00')); when(() => channel.extraData).thenReturn({'name': 'test'}); @@ -61,6 +241,9 @@ Future _pumpComposer( child: Scaffold( body: StreamMessageComposer( messageComposerController: controller, + allowedAttachmentPickerTypes: allowedAttachmentPickerTypes, + disableAttachments: disableAttachments, + useSystemAttachmentPicker: useSystemAttachmentPicker, ), ), ), @@ -89,67 +272,34 @@ Future _dropFile(WidgetTester tester) async { await tester.pumpAndSettle(); } -ContentInsertionConfiguration? _keyboardConfig(WidgetTester tester) { - return tester.widget(find.byType(EditableText)).contentInsertionConfiguration; -} - -void main() { - final originalRecordPlatform = RecordPlatform.instance; - setUp(() => RecordPlatform.instance = FakeRecordPlatform()); - tearDown(() => RecordPlatform.instance = originalRecordPlatform); - - late StreamMessageComposerController controller; - setUp(() => controller = StreamMessageComposerController()); - tearDown(() => controller.dispose()); - - group('StreamMessageComposer with the upload-file capability', () { - testWidgets('offers every way to add an upload', (tester) async { - await _pumpComposer(tester, controller: controller); - - expect(_attachmentButton, findsOneWidget); - expect(_micButton, findsOneWidget); - expect(_keyboardConfig(tester), isNotNull); - - await _dropFile(tester); - expect(controller.attachments, hasLength(1)); - }); - }); - - group('StreamMessageComposer without the upload-file capability', () { - testWidgets('hides the attachment button', (tester) async { - await _pumpComposer(tester, controller: controller, ownCapabilities: _cannotUpload); - - expect(_attachmentButton, findsNothing); - }); - - testWidgets('hides voice recording', (tester) async { - await _pumpComposer(tester, controller: controller, ownCapabilities: _cannotUpload); - - expect(_micButton, findsNothing); - }); +Future> _openSystemPicker(WidgetTester tester) async { + await tester.tap(_attachmentButton); + await tester.pumpAndSettle(); - testWidgets('ignores dropped files', (tester) async { - await _pumpComposer(tester, controller: controller, ownCapabilities: _cannotUpload); + final picker = tester.widget(find.byType(StreamSystemAttachmentPicker)); + return {...picker.options.map((it) => it.key)}; +} - await _dropFile(tester); +Future> _openTabbedPicker(WidgetTester tester) async { + await tester.tap(_attachmentButton); + await tester.pumpAndSettle(); - expect(controller.attachments, isEmpty); - }); + final picker = tester.widget(find.byType(StreamTabbedAttachmentPicker)); + return {...picker.options.map((it) => it.key)}; +} - testWidgets('does not accept keyboard images', (tester) async { - await _pumpComposer(tester, controller: controller, ownCapabilities: _cannotUpload); +ContentInsertionConfiguration? _keyboardConfig(WidgetTester tester) { + return tester.widget(find.byType(EditableText)).contentInsertionConfiguration; +} - expect(_keyboardConfig(tester), isNull); - }); +class _ChannelWithCapabilitiesStream extends MockChannel { + _ChannelWithCapabilitiesStream({ + super.ownCapabilities, + required this.capabilitiesStream, }); - group('StreamMessageComposer on a channel that is not created yet', () { - testWidgets('keeps uploads available, as there are no capabilities to check', (tester) async { - await _pumpComposer(tester, controller: controller, ownCapabilities: const [], initialized: false); + final Stream> capabilitiesStream; - expect(_attachmentButton, findsOneWidget); - expect(_micButton, findsOneWidget); - expect(_keyboardConfig(tester), isNotNull); - }); - }); + @override + Stream> get ownCapabilitiesStream => capabilitiesStream; } From 8d583dd570e7d927fd047b519f7af5a9fe88749a Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 13 Oct 2026 11:35:39 +0200 Subject: [PATCH 3/7] fix(ui): keep an open picker in step with revoked capabilities When uploads are revoked while the attachment picker is open, the tabbed picker now moves off a tab that no longer exists, and the composer closes the picker when nothing is left to offer. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../stream_attachment_picker.dart | 16 +++- .../stream_message_composer.dart | 15 +++- ...ssage_composer_upload_capability_test.dart | 84 +++++++++++++++++-- 3 files changed, 104 insertions(+), 11 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart index b10dc370c1..589bbcdfd5 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart @@ -110,9 +110,9 @@ class StreamTabbedAttachmentPicker extends StatefulWidget { class _StreamTabbedAttachmentPickerState extends State { late var _currentOption = _calculateInitialOption(); TabbedAttachmentPickerOption _calculateInitialOption() { - if (widget.initialOption case final option?) return option; - final options = widget.options; + if (widget.initialOption case final option? when options.contains(option)) return option; + final currentValue = widget.controller.value; final enabledTypes = currentValue.filterEnabledTypes(options: options); @@ -123,8 +123,20 @@ class _StreamTabbedAttachmentPickerState extends State( valueListenable: widget.controller, builder: (context, value, _) { diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart index e1692d3f12..0886b148c6 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart @@ -10,6 +10,7 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import '../../stream_chat_flutter.dart'; +import '../misc/empty_widget.dart'; import 'audio_recorder/audio_recorder_announcer.dart'; import 'composer_attachment_announcer.dart'; import 'error_alert_sheet.dart'; @@ -1111,7 +1112,16 @@ class DefaultStreamMessageComposerState extends State allowedTypes, ) { - if (!_isPickerVisible) return const SizedBox.shrink(); + if (!_isPickerVisible) return const Empty(); + + // Nothing is left to offer, for example after uploads are revoked while + // the picker is open. + if (allowedTypes.isEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _hidePicker(); + }); + return const Empty(); + } final useSystemPicker = switch (CurrentPlatform.type) { _ when widget.props.useSystemAttachmentPicker => true, @@ -1217,9 +1227,10 @@ class DefaultStreamMessageComposerState extends State switch (type) { diff --git a/packages/stream_chat_flutter/test/src/message_input/message_composer_upload_capability_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_composer_upload_capability_test.dart index b4c7ed2299..0ba637ee84 100644 --- a/packages/stream_chat_flutter/test/src/message_input/message_composer_upload_capability_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/message_composer_upload_capability_test.dart @@ -133,15 +133,83 @@ void main() { }); }); - testWidgets('hides voice recording once the upload-file capability is revoked', (tester) async { - final capabilities = StreamController>.broadcast(); - addTearDown(capabilities.close); + group('once the upload-file capability is revoked', () { + testWidgets('hides voice recording', (tester) async { + final capabilities = StreamController>.broadcast(); + addTearDown(capabilities.close); + + await _pumpComposer(tester, controller: controller, ownCapabilitiesStream: capabilities.stream); + capabilities.add(_cannotUpload); + await tester.pumpAndSettle(); + + expect(_micButton, findsNothing); + }); + + testWidgets('hides the attachment button when nothing is left to offer', (tester) async { + final capabilities = StreamController>.broadcast(); + addTearDown(capabilities.close); - await _pumpComposer(tester, controller: controller, ownCapabilitiesStream: capabilities.stream); - capabilities.add(_cannotUpload); - await tester.pumpAndSettle(); + await _pumpComposer(tester, controller: controller, ownCapabilitiesStream: capabilities.stream); + capabilities.add(_cannotUpload); + await tester.pumpAndSettle(); - expect(_micButton, findsNothing); + expect(_attachmentButton, findsNothing); + }); + + testWidgets('stops accepting keyboard images', (tester) async { + final capabilities = StreamController>.broadcast(); + addTearDown(capabilities.close); + + await _pumpComposer(tester, controller: controller, ownCapabilitiesStream: capabilities.stream); + capabilities.add(_cannotUpload); + await tester.pumpAndSettle(); + + expect(_keyboardConfig(tester), isNull); + }); + + testWidgets('moves an open tabbed picker off a tab that uploads', (tester) async { + CurrentPlatform.debugCurrentPlatformOverride = PlatformType.android; + addTearDown(() => CurrentPlatform.debugCurrentPlatformOverride = null); + final capabilities = StreamController>.broadcast(); + addTearDown(capabilities.close); + + await _pumpComposer( + tester, + controller: controller, + ownCapabilities: const [..._canUpload, ChannelCapability.sendPoll], + ownCapabilitiesStream: capabilities.stream, + useSystemAttachmentPicker: false, + attachmentPickerOptionsBuilder: (context, defaultOptions) => [ + TabbedAttachmentPickerOption( + key: 'upload-tab', + icon: Icons.upload, + supportedTypes: const [AttachmentPickerType.files], + optionViewBuilder: (context, controller) => const Text('Upload tab'), + ), + ...defaultOptions.where((option) => option.key == 'poll-creator'), + ], + ); + await tester.tap(_attachmentButton); + await tester.pumpAndSettle(); + capabilities.add(_cannotUploadButCanSendPoll); + await tester.pumpAndSettle(); + + expect(find.text('Upload tab'), findsNothing); + expect(find.text('Create a poll and let everyone vote!'), findsOneWidget); + }); + + testWidgets('closes an open picker when nothing is left to offer', (tester) async { + final capabilities = StreamController>.broadcast(); + addTearDown(capabilities.close); + + await _pumpComposer(tester, controller: controller, ownCapabilitiesStream: capabilities.stream); + await tester.tap(_attachmentButton); + await tester.pumpAndSettle(); + capabilities.add(_cannotUpload); + await tester.pumpAndSettle(); + + expect(find.byType(StreamSystemAttachmentPicker), findsNothing); + }); }); testWidgets('leaves out the command tab when the channel has no commands', (tester) async { @@ -188,6 +256,7 @@ Future _pumpComposer( List commands = const [], bool disableAttachments = false, bool useSystemAttachmentPicker = true, + AttachmentPickerOptionsBuilder? attachmentPickerOptionsBuilder, bool initialized = true, }) async { final client = MockClient(); @@ -244,6 +313,7 @@ Future _pumpComposer( allowedAttachmentPickerTypes: allowedAttachmentPickerTypes, disableAttachments: disableAttachments, useSystemAttachmentPicker: useSystemAttachmentPicker, + attachmentPickerOptionsBuilder: attachmentPickerOptionsBuilder, ), ), ), From 236713248b5d0731b166b865cc2ac666061830b8 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 13 Oct 2026 14:03:41 +0200 Subject: [PATCH 4/7] fix(ui): stop accepting drops the composer can't take - Disable the drop target when files can't be added, rather than accepting the drop and discarding it. - Update the docs of `disableAttachments`, `enableVoiceRecording`, `allowedAttachmentPickerTypes` and `initialOption` to match the new behaviour. - Add direct tests for the tabbed picker, and composer tests for a capability granted after mount, drops refused by the allowed types, and polls on a channel that isn't created yet. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/stream_chat_flutter/CHANGELOG.md | 4 +- .../stream_attachment_picker.dart | 5 +- .../stream_message_composer.dart | 21 +++-- .../stream_tabbed_attachment_picker_test.dart | 85 +++++++++++++++++++ ...ssage_composer_upload_capability_test.dart | 68 +++++++++++++-- 5 files changed, 165 insertions(+), 18 deletions(-) create mode 100644 packages/stream_chat_flutter/test/src/message_input/attachment_picker/stream_tabbed_attachment_picker_test.dart diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index e4e31af08c..fd2c579ded 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -44,7 +44,8 @@ - The unread messages divider now shows a count. - `StreamMessageListView` no longer marks a channel read until the unread boundary has been seen, or while it is manually marked unread. - Screen readers now announce each message as one phrase: sender, body, time and status. -- `StreamMessageComposer` now hides upload options, voice recording included, when the user lacks the `upload-file` capability in the channel. +- `StreamMessageComposer` now hides the picker's upload options and voice recording, and refuses dropped files, when the user lacks the `upload-file` capability. The attachment button is hidden when nothing else is left to offer. +- `StreamMessageComposer` now refuses dropped files when `disableAttachments` is set or `allowedAttachmentPickerTypes` leaves out `AttachmentPickerType.files`. ⚠️ Deprecated @@ -78,7 +79,6 @@ - Fixed the jump-to-unread pill being dismissed by the slightest scroll after marking a message unread. - Fixed the jump-to-unread pill flickering back in and straight out on every new message after being dismissed. - Fixed tapping the jump-to-unread pill doing nothing on a channel the user has never opened. -- Fixed `StreamMessageComposer` accepting dropped files when `disableAttachments` is set or files aren't in `allowedAttachmentPickerTypes`. - Fixed the attachment picker showing an empty commands tab in channels without commands. ## 10.4.0 diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart index 43b1e4a17e..5119587542 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart @@ -97,7 +97,10 @@ class StreamTabbedAttachmentPicker extends StatefulWidget { /// The list of options. final Set options; - /// The initial option to be selected. + /// The option selected when the picker opens. + /// + /// Ignored when it isn't one of the [options]; the first enabled option is + /// selected instead. final TabbedAttachmentPickerOption? initialOption; /// The controller of the attachment picker. diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart index 0886b148c6..b91f0b3d05 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart @@ -234,7 +234,10 @@ class MessageComposerProps { /// The focus node associated to the TextField. final FocusNode? focusNode; - /// If true the attachments button will not be displayed. + /// Whether the user is prevented from adding attachments. + /// + /// When true, the attachment button is hidden, and dropped files and + /// keyboard images are refused. /// /// Defaults to false. final bool disableAttachments; @@ -244,7 +247,9 @@ class MessageComposerProps { /// Defaults to true. final bool canAlsoSendToChannelFromThread; - /// If true the voice recording button will be displayed. + /// Whether the voice recording button is shown. + /// + /// The button is only shown when the channel allows uploads. /// /// Defaults to false. final bool enableVoiceRecording; @@ -314,10 +319,12 @@ class MessageComposerProps { /// Defaults to [StreamAttachmentValidator.defaultMaxAttachmentCount]. final int attachmentLimit; - /// The list of allowed attachment types which can be picked using the - /// attachment button. + /// The attachment types the user can add. /// - /// By default, all the attachment types are allowed. + /// Applies to the attachment picker, dropped files and images inserted from + /// the keyboard. A type is only offered when the channel allows it. + /// + /// Defaults to [AttachmentPickerType.values]. final List allowedAttachmentPickerTypes; /// List of triggers for showing autocomplete. @@ -1031,13 +1038,13 @@ class DefaultStreamMessageComposerState extends State[]; for (final file in details.files) { attachments.add(await file.toAttachment(type: AttachmentType.file)); diff --git a/packages/stream_chat_flutter/test/src/message_input/attachment_picker/stream_tabbed_attachment_picker_test.dart b/packages/stream_chat_flutter/test/src/message_input/attachment_picker/stream_tabbed_attachment_picker_test.dart new file mode 100644 index 0000000000..ec7ebb8535 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_input/attachment_picker/stream_tabbed_attachment_picker_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + final images = _option('images', AttachmentPickerType.images); + final files = _option('files', AttachmentPickerType.files); + final poll = _option('poll', AttachmentPickerType.poll); + + testWidgets('selects the initial option', (tester) async { + await _pumpPicker(tester, options: {images, files}, initialOption: files); + + expect(find.text('files view'), findsOneWidget); + }); + + testWidgets('ignores an initial option that is not one of the options', (tester) async { + await _pumpPicker(tester, options: {images, files}, initialOption: poll); + + expect(find.text('images view'), findsOneWidget); + }); + + testWidgets('builds without options', (tester) async { + await _pumpPicker(tester, options: {}); + + expect(tester.takeException(), isNull); + }); + + testWidgets('moves to a remaining option when the selected one disappears', (tester) async { + final controller = _controller(); + await _pumpPicker(tester, controller: controller, options: {images, files}); + expect(find.text('images view'), findsOneWidget); + + await _pumpPicker(tester, controller: controller, options: {files}); + + expect(find.text('files view'), findsOneWidget); + }); + + testWidgets('keeps building when its options become empty', (tester) async { + final controller = _controller(); + await _pumpPicker(tester, controller: controller, options: {images}); + + await _pumpPicker(tester, controller: controller, options: {}); + + expect(tester.takeException(), isNull); + }); +} + +TabbedAttachmentPickerOption _option(String key, AttachmentPickerType type) { + return TabbedAttachmentPickerOption( + key: key, + icon: Icons.attach_file, + supportedTypes: [type], + optionViewBuilder: (context, controller) => Text('$key view'), + ); +} + +StreamAttachmentPickerController _controller() { + final controller = StreamAttachmentPickerController(); + addTearDown(controller.dispose); + return controller; +} + +Future _pumpPicker( + WidgetTester tester, { + required Set options, + StreamAttachmentPickerController? controller, + TabbedAttachmentPickerOption? initialOption, +}) async { + await tester.pumpWidget( + MaterialApp( + home: StreamChatTheme( + data: StreamChatThemeData(), + child: Scaffold( + body: StreamTabbedAttachmentPicker( + controller: controller ?? _controller(), + options: options, + initialOption: initialOption, + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); +} diff --git a/packages/stream_chat_flutter/test/src/message_input/message_composer_upload_capability_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_composer_upload_capability_test.dart index 0ba637ee84..813998ae67 100644 --- a/packages/stream_chat_flutter/test/src/message_input/message_composer_upload_capability_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/message_composer_upload_capability_test.dart @@ -98,12 +98,10 @@ void main() { expect(_micButton, findsNothing); }); - testWidgets('ignores dropped files', (tester) async { + testWidgets('does not accept dropped files', (tester) async { await _pumpComposer(tester, controller: controller, ownCapabilities: _cannotUpload); - await _dropFile(tester); - - expect(controller.attachments, isEmpty); + expect(_acceptsDroppedFiles(tester), isFalse); }); testWidgets('does not accept keyboard images', (tester) async { @@ -131,6 +129,46 @@ void main() { expect(_keyboardConfig(tester), isNotNull); }); + + testWidgets('does not offer polls', (tester) async { + await _pumpComposer(tester, controller: controller, ownCapabilities: const [], initialized: false); + + expect(await _openSystemPicker(tester), isNot(contains('poll-creator'))); + }); + }); + + group('once the upload-file capability is granted', () { + testWidgets('shows voice recording', (tester) async { + final capabilities = StreamController>.broadcast(); + addTearDown(capabilities.close); + + await _pumpComposer( + tester, + controller: controller, + ownCapabilities: _cannotUpload, + ownCapabilitiesStream: capabilities.stream, + ); + capabilities.add(_canUpload); + await tester.pumpAndSettle(); + + expect(_micButton, findsOneWidget); + }); + + testWidgets('shows the attachment button', (tester) async { + final capabilities = StreamController>.broadcast(); + addTearDown(capabilities.close); + + await _pumpComposer( + tester, + controller: controller, + ownCapabilities: _cannotUpload, + ownCapabilitiesStream: capabilities.stream, + ); + capabilities.add(_canUpload); + await tester.pumpAndSettle(); + + expect(_attachmentButton, findsOneWidget); + }); }); group('once the upload-file capability is revoked', () { @@ -191,6 +229,8 @@ void main() { ); await tester.tap(_attachmentButton); await tester.pumpAndSettle(); + expect(find.text('Upload tab'), findsOneWidget); + capabilities.add(_cannotUploadButCanSendPoll); await tester.pumpAndSettle(); @@ -207,6 +247,8 @@ void main() { await tester.pumpAndSettle(); capabilities.add(_cannotUpload); await tester.pumpAndSettle(); + capabilities.add(_canUpload); + await tester.pumpAndSettle(); expect(find.byType(StreamSystemAttachmentPicker), findsNothing); }); @@ -238,12 +280,20 @@ void main() { expect(_attachmentButton, findsNothing); }); - testWidgets('ignores dropped files when attachments are disabled', (tester) async { - await _pumpComposer(tester, controller: controller, disableAttachments: true); + testWidgets('does not accept dropped files when files are not an allowed type', (tester) async { + await _pumpComposer( + tester, + controller: controller, + allowedAttachmentPickerTypes: const [AttachmentPickerType.images], + ); + + expect(_acceptsDroppedFiles(tester), isFalse); + }); - await _dropFile(tester); + testWidgets('does not accept dropped files when attachments are disabled', (tester) async { + await _pumpComposer(tester, controller: controller, disableAttachments: true); - expect(controller.attachments, isEmpty); + expect(_acceptsDroppedFiles(tester), isFalse); }); } @@ -331,6 +381,8 @@ final _attachmentButton = find.descendant( final _micButton = find.byKey(const ValueKey('microphone_key')); +bool _acceptsDroppedFiles(WidgetTester tester) => tester.widget(find.byType(DropTarget)).enable; + Future _dropFile(WidgetTester tester) async { tester.widget(find.byType(DropTarget)).onDragDone!( DropDoneDetails( From 8f436d73f5df25033ae5627eca2e0e8ab09d4c73 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 13 Oct 2026 14:07:02 +0200 Subject: [PATCH 5/7] docs(ui): restore why keyboard content without data is ignored Co-Authored-By: Claude Opus 5.5 (1M context) --- .../lib/src/message_input/stream_message_composer.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart index b91f0b3d05..1ed6344633 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart @@ -1544,6 +1544,7 @@ class DefaultStreamMessageComposerState extends State Date: Tue, 13 Oct 2026 14:37:45 +0200 Subject: [PATCH 6/7] fix(ui): check keyboard images against the channel on insert The keyboard reads its insertion configuration only when it connects, so a configuration that followed the channel's capabilities went stale while the keyboard was open: a revoked capability hit an assertion on insert, and a granted one wasn't offered until the field refocused. The keyboard now always gets the configuration, and an inserted image is accepted only when the composer currently allows images. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../stream_message_composer.dart | 19 ++++++----- ...ssage_composer_content_insertion_test.dart | 21 ++++++++---- ...ssage_composer_upload_capability_test.dart | 32 ++++++++++++++----- 3 files changed, 48 insertions(+), 24 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart index 1ed6344633..e83126f28d 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart @@ -322,7 +322,7 @@ class MessageComposerProps { /// The attachment types the user can add. /// /// Applies to the attachment picker, dropped files and images inserted from - /// the keyboard. A type is only offered when the channel allows it. + /// the keyboard. A type is only accepted when the channel allows it. /// /// Defaults to [AttachmentPickerType.values]. final List allowedAttachmentPickerTypes; @@ -1078,7 +1078,9 @@ class DefaultStreamMessageComposerState extends State _onContentInserted(content, allowedTypes), + ), ), ), ); @@ -1531,18 +1533,15 @@ class DefaultStreamMessageComposerState extends State allowedTypes, ) { - if (!allowedTypes.contains(AttachmentPickerType.images)) return null; - return ContentInsertionConfiguration(onContentInserted: _onContentInserted); - } - - void _onContentInserted(KeyboardInsertedContent content) { // Commands carry no attachments. if (_commandEnabled) return; + if (!allowedTypes.contains(AttachmentPickerType.images)) return; // A failed read arrives as no data or empty data. final bytes = content.data; diff --git a/packages/stream_chat_flutter/test/src/message_input/message_composer_content_insertion_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_composer_content_insertion_test.dart index 9e6a4d2438..4e62cd5b34 100644 --- a/packages/stream_chat_flutter/test/src/message_input/message_composer_content_insertion_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/message_composer_content_insertion_test.dart @@ -171,20 +171,26 @@ void main() { expect(controller.attachments, isEmpty); }); - testWidgets('is off when attachments are disabled', (tester) async { + testWidgets('ignores an image inserted when attachments are disabled', (tester) async { await _pumpComposer(tester, controller: controller, disableAttachments: true); - expect(_editableText(tester).contentInsertionConfiguration, isNull); + _insert(tester, _content()); + await tester.pumpAndSettle(); + + expect(controller.attachments, isEmpty); }); - testWidgets('is off when images are not an allowed picker type', (tester) async { + testWidgets('ignores an image inserted when images are not an allowed picker type', (tester) async { await _pumpComposer( tester, controller: controller, allowedAttachmentPickerTypes: [AttachmentPickerType.files], ); - expect(_editableText(tester).contentInsertionConfiguration, isNull); + _insert(tester, _content()); + await tester.pumpAndSettle(); + + expect(controller.attachments, isEmpty); }); testWidgets('stays on while a command is active', (tester) async { @@ -207,14 +213,17 @@ void main() { expect(controller.attachments, isEmpty); }); - testWidgets('is off when the user cannot upload files', (tester) async { + testWidgets('ignores an image inserted when the user cannot upload files', (tester) async { await _pumpComposer( tester, controller: controller, ownCapabilities: [ChannelCapability.sendMessage], ); - expect(_editableText(tester).contentInsertionConfiguration, isNull); + _insert(tester, _content()); + await tester.pumpAndSettle(); + + expect(controller.attachments, isEmpty); }); testWidgets('names the file from the MIME type when the URI extension disagrees', (tester) async { diff --git a/packages/stream_chat_flutter/test/src/message_input/message_composer_upload_capability_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_composer_upload_capability_test.dart index 813998ae67..42f889641f 100644 --- a/packages/stream_chat_flutter/test/src/message_input/message_composer_upload_capability_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/message_composer_upload_capability_test.dart @@ -49,7 +49,10 @@ void main() { testWidgets('accepts keyboard images', (tester) async { await _pumpComposer(tester, controller: controller); - expect(_keyboardConfig(tester), isNotNull); + _insertImage(tester); + await tester.pumpAndSettle(); + + expect(controller.attachments, hasLength(1)); }); }); @@ -104,10 +107,13 @@ void main() { expect(_acceptsDroppedFiles(tester), isFalse); }); - testWidgets('does not accept keyboard images', (tester) async { + testWidgets('ignores keyboard images', (tester) async { await _pumpComposer(tester, controller: controller, ownCapabilities: _cannotUpload); - expect(_keyboardConfig(tester), isNull); + _insertImage(tester); + await tester.pumpAndSettle(); + + expect(controller.attachments, isEmpty); }); }); @@ -127,7 +133,10 @@ void main() { testWidgets('accepts keyboard images', (tester) async { await _pumpComposer(tester, controller: controller, ownCapabilities: const [], initialized: false); - expect(_keyboardConfig(tester), isNotNull); + _insertImage(tester); + await tester.pumpAndSettle(); + + expect(controller.attachments, hasLength(1)); }); testWidgets('does not offer polls', (tester) async { @@ -194,7 +203,7 @@ void main() { expect(_attachmentButton, findsNothing); }); - testWidgets('stops accepting keyboard images', (tester) async { + testWidgets('ignores keyboard images', (tester) async { final capabilities = StreamController>.broadcast(); addTearDown(capabilities.close); @@ -202,7 +211,10 @@ void main() { capabilities.add(_cannotUpload); await tester.pumpAndSettle(); - expect(_keyboardConfig(tester), isNull); + _insertImage(tester); + await tester.pumpAndSettle(); + + expect(controller.attachments, isEmpty); }); testWidgets('moves an open tabbed picker off a tab that uploads', (tester) async { @@ -410,8 +422,12 @@ Future> _openTabbedPicker(WidgetTester tester) async { return {...picker.options.map((it) => it.key)}; } -ContentInsertionConfiguration? _keyboardConfig(WidgetTester tester) { - return tester.widget(find.byType(EditableText)).contentInsertionConfiguration; +void _insertImage(WidgetTester tester) { + tester + .state(find.byType(EditableText)) + .insertContent( + KeyboardInsertedContent(mimeType: 'image/gif', uri: 'content://keyboard/cat.gif', data: Uint8List(100)), + ); } class _ChannelWithCapabilitiesStream extends MockChannel { From 2a633e85fa55921cdba55462d0efd11679e06cf5 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 13 Oct 2026 14:44:41 +0200 Subject: [PATCH 7/7] docs(ui): move this PR's changelog entries to Upcoming They stayed under the already-released 10.5.0 through the master merge. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/stream_chat_flutter/CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index fd2c579ded..166c410bb6 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -10,11 +10,14 @@ - Allowed `equatable` 3.x alongside 2.x. - `StreamMessageListView` now rebuilds only the message rows that changed; a custom row builder should read changing outside state through an inherited widget. +- `StreamMessageComposer` now hides the picker's upload options and voice recording, and refuses dropped files, when the user lacks the `upload-file` capability. The attachment button is hidden when nothing else is left to offer. +- `StreamMessageComposer` now refuses dropped files when `disableAttachments` is set or `allowedAttachmentPickerTypes` leaves out `AttachmentPickerType.files`. 🐞 Fixed - Fixed `StreamMessageListView` dropping frames on each new message in busy channels. - Fixed `StreamMessageComposer` polling the audio recorder ten times a second while no recording is in progress, including when voice recording is disabled. +- Fixed the attachment picker showing an empty commands tab in channels without commands. ## 10.5.0 @@ -44,8 +47,6 @@ - The unread messages divider now shows a count. - `StreamMessageListView` no longer marks a channel read until the unread boundary has been seen, or while it is manually marked unread. - Screen readers now announce each message as one phrase: sender, body, time and status. -- `StreamMessageComposer` now hides the picker's upload options and voice recording, and refuses dropped files, when the user lacks the `upload-file` capability. The attachment button is hidden when nothing else is left to offer. -- `StreamMessageComposer` now refuses dropped files when `disableAttachments` is set or `allowedAttachmentPickerTypes` leaves out `AttachmentPickerType.files`. ⚠️ Deprecated @@ -79,7 +80,6 @@ - Fixed the jump-to-unread pill being dismissed by the slightest scroll after marking a message unread. - Fixed the jump-to-unread pill flickering back in and straight out on every new message after being dismissed. - Fixed tapping the jump-to-unread pill doing nothing on a channel the user has never opened. -- Fixed the attachment picker showing an empty commands tab in channels without commands. ## 10.4.0