diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 1e60d36dba..f9dc4c2fa4 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -15,12 +15,15 @@ - Allowed `equatable` 3.x alongside 2.x. - `StreamMessageComposer`'s send button now follows the message validator, so a custom validator can enable or disable it, including when editing or with a command. - `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 `StreamMessageComposer`'s send button looking enabled on an empty message when voice recording is off, and showing the microphone instead of send for a staged poll. +- Fixed the attachment picker showing an empty commands tab in channels without commands. ## 10.5.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 b32c2db1b6..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. @@ -110,9 +113,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 +126,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 46409f652a..0bc75cfb5d 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 @@ -9,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'; @@ -237,7 +239,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; @@ -247,7 +252,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; @@ -317,10 +324,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. + /// + /// Applies to the attachment picker, dropped files and images inserted from + /// the keyboard. A type is only accepted when the channel allows it. /// - /// By default, all the attachment types are allowed. + /// Defaults to [AttachmentPickerType.values]. final List allowedAttachmentPickerTypes; /// List of triggers for showing autocomplete. @@ -999,10 +1008,34 @@ class DefaultStreamMessageComposerState extends State>( + initialData: channel.ownCapabilities, + stream: channel.ownCapabilitiesStream, + comparator: const ListEquality().equals, + builder: (context, capabilities) { + return _buildMessageInputContent(context, controller, focusNode, capabilities); + }, + ); + } + + Widget _buildMessageInputContent( + BuildContext context, + StreamMessageComposerController controller, + FocusNode focusNode, + List capabilities, ) { final currentUserId = StreamChat.of(context).currentUser?.id; final isFloating = _resolveSurfaceStyle(context).isFloating; @@ -1010,9 +1043,16 @@ class DefaultStreamMessageComposerState extends State[]; for (final file in details.files) { @@ -1028,13 +1068,13 @@ class DefaultStreamMessageComposerState extends State _onContentInserted(content, allowedTypes), + ), ), ), ); @@ -1075,7 +1117,7 @@ class DefaultStreamMessageComposerState extends State allowedTypes, + ) { + 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 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( @@ -1186,25 +1238,43 @@ 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 channel = StreamChannel.of(context).channel; + final channelConfig = channel.state != null ? channel.config : null; + final canUploadFile = capabilities.contains(ChannelCapability.uploadFile); + + 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(); @@ -1472,22 +1542,17 @@ class DefaultStreamMessageComposerState extends State allowedTypes, + ) { + // Commands carry no attachments. + if (_commandEnabled) return; + if (!allowedTypes.contains(AttachmentPickerType.images)) return; - 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. + // A failed read arrives as no data or empty data. final bytes = content.data; if (bytes == null || bytes.isEmpty) return; 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_content_insertion_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_composer_content_insertion_test.dart index e2535a6e7b..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,39 +171,59 @@ 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('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 { + 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 new file mode 100644 index 0000000000..42f889641f --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_input/message_composer_upload_capability_test.dart @@ -0,0 +1,443 @@ +import 'dart:async'; + +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:record/record.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../fakes.dart'; +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); + + _insertImage(tester); + await tester.pumpAndSettle(); + + expect(controller.attachments, hasLength(1)); + }); + }); + + 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('does not accept dropped files', (tester) async { + await _pumpComposer(tester, controller: controller, ownCapabilities: _cannotUpload); + + expect(_acceptsDroppedFiles(tester), isFalse); + }); + + testWidgets('ignores keyboard images', (tester) async { + await _pumpComposer(tester, controller: controller, ownCapabilities: _cannotUpload); + + _insertImage(tester); + await tester.pumpAndSettle(); + + expect(controller.attachments, isEmpty); + }); + }); + + 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); + + _insertImage(tester); + await tester.pumpAndSettle(); + + expect(controller.attachments, hasLength(1)); + }); + + 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', () { + 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(); + + expect(_attachmentButton, findsNothing); + }); + + testWidgets('ignores 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(); + + _insertImage(tester); + await tester.pumpAndSettle(); + + expect(controller.attachments, isEmpty); + }); + + 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(); + expect(find.text('Upload tab'), findsOneWidget); + + 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(); + capabilities.add(_canUpload); + await tester.pumpAndSettle(); + + expect(find.byType(StreamSystemAttachmentPicker), 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('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); + }); + + testWidgets('does not accept dropped files when attachments are disabled', (tester) async { + await _pumpComposer(tester, controller: controller, disableAttachments: true); + + expect(_acceptsDroppedFiles(tester), isFalse); + }); +} + +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, + AttachmentPickerOptionsBuilder? attachmentPickerOptionsBuilder, + bool initialized = true, +}) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = switch (ownCapabilitiesStream) { + final stream? => _ChannelWithCapabilitiesStream(ownCapabilities: ownCapabilities, capabilitiesStream: stream), + _ => 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); + 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'}); + 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, + allowedAttachmentPickerTypes: allowedAttachmentPickerTypes, + disableAttachments: disableAttachments, + useSystemAttachmentPicker: useSystemAttachmentPicker, + attachmentPickerOptionsBuilder: attachmentPickerOptionsBuilder, + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); +} + +final _attachmentButton = find.descendant( + of: find.byType(DefaultStreamMessageComposerLeading), + matching: find.byType(StreamButton), +); + +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( + files: [DropItemFile.fromData(Uint8List(100), path: '/tmp/doc.pdf', name: 'doc.pdf')], + localPosition: Offset.zero, + globalPosition: Offset.zero, + ), + ); + await tester.pumpAndSettle(); +} + +Future> _openSystemPicker(WidgetTester tester) async { + await tester.tap(_attachmentButton); + await tester.pumpAndSettle(); + + final picker = tester.widget(find.byType(StreamSystemAttachmentPicker)); + return {...picker.options.map((it) => it.key)}; +} + +Future> _openTabbedPicker(WidgetTester tester) async { + await tester.tap(_attachmentButton); + await tester.pumpAndSettle(); + + final picker = tester.widget(find.byType(StreamTabbedAttachmentPicker)); + return {...picker.options.map((it) => it.key)}; +} + +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 { + _ChannelWithCapabilitiesStream({ + super.ownCapabilities, + required this.capabilitiesStream, + }); + + final Stream> capabilitiesStream; + + @override + Stream> get ownCapabilitiesStream => capabilitiesStream; +}