Skip to content
Merged
3 changes: 3 additions & 0 deletions packages/stream_chat_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initialOption is now ignored when it isn't in options. Could you update its dartdoc ("The initial option to be selected.") to say so, and add a direct picker test: pump with an initialOption that isn't in options, and expect the first enabled option's view.

Copy link
Copy Markdown
Member

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 new stream_tabbed_attachment_picker_test.dart.


final currentValue = widget.controller.value;
final enabledTypes = currentValue.filterEnabledTypes(options: options);

Expand All @@ -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;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this file get direct unit tests in test/src/message_input/attachment_picker/stream_attachment_picker_test.dart? Right now these paths are only covered through the composer, or not at all:

  • empty options (this used to throw a StateError from options.first). A custom attachmentPickerOptionsBuilder returning [] can still hit it;
  • options going from non-empty to empty without throwing;
  • the selected option disappearing, with the selection moving to a remaining one.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 2367132 as test/src/message_input/attachment_picker/stream_tabbed_attachment_picker_test.dart (kept separate from the already long picker test file): builds without options, keeps building when its options become empty, moves to a remaining option when the selected one disappears, plus the initialOption cases. Each fails with its fix removed.

_currentOption = _calculateInitialOption();
}

@override
Widget build(BuildContext context) {
if (widget.options.isEmpty) return const Empty();

return ValueListenableBuilder<AttachmentPickerValue>(
valueListenable: widget.controller,
builder: (context, value, _) {
Expand Down
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
Expand All @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These public dartdocs no longer match the behaviour:

  • enableVoiceRecording: "If true the voice recording button will be displayed." Now it also needs upload-file.
  • disableAttachments: "If true the attachments button will not be displayed." Now it also rejects dropped files and keyboard images, and hides polls and commands.
  • allowedAttachmentPickerTypes: "which can be picked using the attachment button." Now files also controls drag-and-drop and images controls keyboard insertion. Upload types are further limited by upload-file, and commands by the channel's commands.

Could you update those three docs?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in 2367132. disableAttachments: the attachment button is hidden, and dropped files and keyboard images are refused. enableVoiceRecording: the button is only shown when the channel allows uploads. allowedAttachmentPickerTypes: applies to the picker, dropped files and keyboard images, and a type is only offered when the channel allows it.

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) {
Expand All @@ -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: () {
Expand All @@ -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),
),
),
),
);
Expand Down Expand Up @@ -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),
),
],
),
Expand All @@ -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(
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment that used to sit above this explained why the bytes == null || bytes.isEmpty check exists: the platform reads the content before handing it over, and a failed read can arrive as no data or as empty data. Could you restore it? This PR only moved the command check, and without that comment the guard looks arbitrary.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restored in 8f436d7, in a shorter form: // A failed read arrives as no data or empty data. above the check.

if (bytes == null || bytes.isEmpty) return;

Expand Down
Loading
Loading