Repository navigation
fix(ui): respect the upload-file capability in the composer #2997
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5684b53
e7af920
8d583dd
6ff81f5
2367132
8f436d7
07a71a8
2a633e8
700169b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -97,7 +97,10 @@ class StreamTabbedAttachmentPicker extends StatefulWidget { | |
| /// The list of options. | ||
| final Set<TabbedAttachmentPickerOption> 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<StreamTabbedAttachmentPicker> { | ||
| 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<StreamTabbedAttachmentPic | |
| ); | ||
| } | ||
|
|
||
| @override | ||
| void didUpdateWidget(StreamTabbedAttachmentPicker oldWidget) { | ||
| super.didUpdateWidget(oldWidget); | ||
|
|
||
| // The selected option can disappear from the options, for example when | ||
| // the user loses a permission while the picker is open. | ||
| if (widget.options.isEmpty || widget.options.contains(_currentOption)) return; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could this file get direct unit tests in
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added in 2367132 as |
||
| _currentOption = _calculateInitialOption(); | ||
| } | ||
|
|
||
| @override | ||
| Widget build(BuildContext context) { | ||
| if (widget.options.isEmpty) return const Empty(); | ||
|
|
||
| return ValueListenableBuilder<AttachmentPickerValue>( | ||
| valueListenable: widget.controller, | ||
| builder: (context, value, _) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<AttachmentPickerType> allowedAttachmentPickerTypes; | ||
|
|
||
| /// List of triggers for showing autocomplete. | ||
|
|
@@ -999,20 +1008,51 @@ class DefaultStreamMessageComposerState extends State<DefaultStreamMessageCompos | |
| ); | ||
| } | ||
|
|
||
| // Without the upload-file capability the composer offers no way to add an | ||
| // upload. A channel that isn't created yet allows uploads. | ||
| Widget _buildMessageInput( | ||
| BuildContext context, | ||
| StreamMessageComposerController controller, | ||
| FocusNode focusNode, | ||
| ) { | ||
| final channel = StreamChannel.of(context).channel; | ||
| if (channel.state == null) { | ||
| const capabilities = [ChannelCapability.uploadFile]; | ||
| return _buildMessageInputContent(context, controller, focusNode, capabilities); | ||
| } | ||
|
|
||
| return BetterStreamBuilder<List<ChannelCapability>>( | ||
| initialData: channel.ownCapabilities, | ||
| stream: channel.ownCapabilitiesStream, | ||
| comparator: const ListEquality<ChannelCapability>().equals, | ||
| builder: (context, capabilities) { | ||
| return _buildMessageInputContent(context, controller, focusNode, capabilities); | ||
| }, | ||
| ); | ||
| } | ||
|
|
||
| Widget _buildMessageInputContent( | ||
| BuildContext context, | ||
| StreamMessageComposerController controller, | ||
| FocusNode focusNode, | ||
| List<ChannelCapability> 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; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These public dartdocs no longer match the behaviour:
Could you update those three docs?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated in 2367132. |
||
| final allowedTypes = _getAllowedAttachmentPickerTypes(capabilities); | ||
| final canOpenPicker = allowedTypes.isNotEmpty; | ||
| final canDropFiles = allowedTypes.contains(AttachmentPickerType.files); | ||
|
|
||
| // Extracted so the floating gradient can wrap just the pill, keeping | ||
| // gradient height stable when the picker (a sibling) opens. | ||
| final pill = DropTarget( | ||
| enable: canDropFiles, | ||
| onDragDone: (details) async { | ||
| final attachments = <Attachment>[]; | ||
| for (final file in details.files) { | ||
|
|
@@ -1028,13 +1068,13 @@ class DefaultStreamMessageComposerState extends State<DefaultStreamMessageCompos | |
| child: StreamChatMessageInput( | ||
| controller: controller, | ||
| currentUserId: currentUserId, | ||
| onAttachmentButtonPressed: widget.props.disableAttachments ? null : _onAttachmentButtonPressed, | ||
| onAttachmentButtonPressed: canOpenPicker ? _onAttachmentButtonPressed : null, | ||
| isPickerOpen: _isPickerVisible, | ||
| placeholder: _buildPlaceholder(context), | ||
| focusNode: focusNode, | ||
| onSendPressed: sendMessage, | ||
| canAlsoSendToChannel: _shouldShowSendToChannelCheckbox(), | ||
| audioRecorderController: widget.props.enableVoiceRecording ? _audioRecorderController : null, | ||
| audioRecorderController: canRecordVoice ? _audioRecorderController : null, | ||
| sendVoiceRecordingAutomatically: widget.props.sendVoiceRecordingAutomatically, | ||
| feedback: widget.props.voiceRecordingFeedback, | ||
| onQuotedMessageCleared: () { | ||
|
|
@@ -1047,7 +1087,9 @@ class DefaultStreamMessageComposerState extends State<DefaultStreamMessageCompos | |
| autofocus: widget.props.autofocus, | ||
| autocorrect: widget.props.autoCorrect, | ||
| isFloating: isFloating, | ||
| contentInsertionConfiguration: _buildContentInsertionConfiguration(), | ||
| contentInsertionConfiguration: ContentInsertionConfiguration( | ||
| onContentInserted: (content) => _onContentInserted(content, allowedTypes), | ||
| ), | ||
| ), | ||
| ), | ||
| ); | ||
|
|
@@ -1075,7 +1117,7 @@ class DefaultStreamMessageComposerState extends State<DefaultStreamMessageCompos | |
| sizeFactor: _pickerAnimation, | ||
| // ignore: deprecated_member_use, alternative is only available since Flutter 3.44 | ||
| axisAlignment: -1, | ||
| child: _buildInlineAttachmentPicker(context), | ||
| child: _buildInlineAttachmentPicker(context, allowedTypes), | ||
| ), | ||
| ], | ||
| ), | ||
|
|
@@ -1084,16 +1126,26 @@ class DefaultStreamMessageComposerState extends State<DefaultStreamMessageCompos | |
| ); | ||
| } | ||
|
|
||
| Widget _buildInlineAttachmentPicker(BuildContext context) { | ||
| if (!_isPickerVisible) return const SizedBox.shrink(); | ||
|
|
||
| final allowedTypes = _getAllowedAttachmentPickerTypes(); | ||
| Widget _buildInlineAttachmentPicker( | ||
| BuildContext context, | ||
| List<AttachmentPickerType> 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<DefaultStreamMessageCompos | |
| return channel.sendPoll(poll).ignore(); | ||
| } | ||
|
|
||
| // Returns the list of allowed attachment picker types based on the | ||
| // current channel configuration and context. | ||
| List<AttachmentPickerType> _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<AttachmentPickerType> _getAllowedAttachmentPickerTypes( | ||
| List<ChannelCapability> 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<ChannelCapability> 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<DefaultStreamMessageCompos | |
| if (validationErrors.firstOrNull case final error?) _handleAttachmentError(error); | ||
| } | ||
|
|
||
| // Lets keyboards such as Gboard insert images and GIFs. Accepted only where | ||
| // the picker would accept an image, and validated the same way. Off while a | ||
| // command is active, as commands carry no attachments. | ||
| ContentInsertionConfiguration? _buildContentInsertionConfiguration() { | ||
| if (widget.props.disableAttachments) return null; | ||
| if (_commandEnabled) return null; | ||
| if (!StreamChannel.of(context).channel.canUploadFile) return null; | ||
| if (!_getAllowedAttachmentPickerTypes().contains(AttachmentPickerType.images)) return null; | ||
|
|
||
| return ContentInsertionConfiguration(onContentInserted: _onContentInserted); | ||
| } | ||
| // Adds an image or GIF inserted from a keyboard such as Gboard. Accepted only | ||
| // where the picker would accept an image, and validated the same way. | ||
| void _onContentInserted( | ||
| KeyboardInsertedContent content, | ||
| List<AttachmentPickerType> 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; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The comment that used to sit above this explained why the
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Restored in 8f436d7, in a shorter form: |
||
| if (bytes == null || bytes.isEmpty) return; | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
initialOptionis now ignored when it isn't inoptions. Could you update its dartdoc ("The initial option to be selected.") to say so, and add a direct picker test: pump with aninitialOptionthat isn't inoptions, and expect the first enabled option's view.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in 2367132. The doc now says it's ignored when it isn't one of the
options, and the first enabled option is selected instead. Covered by "ignores an initial option that is not one of the options" in the newstream_tabbed_attachment_picker_test.dart.