diff --git a/.github/workflows/dart_code_metrics.yaml b/.github/workflows/dart_code_metrics.yaml index 1272babc..667ac2dd 100644 --- a/.github/workflows/dart_code_metrics.yaml +++ b/.github/workflows/dart_code_metrics.yaml @@ -33,7 +33,7 @@ jobs: flutter-version: ${{ env.flutter_version }} - name: "Install Tools" - run: flutter pub global activate melos 1.0.0-dev.3 + run: flutter pub global activate melos 1.0.0-dev.10 - name: "Bootstrap Workspace" run: melos bootstrap diff --git a/.github/workflows/stream_flutter_workflow.yml b/.github/workflows/stream_flutter_workflow.yml index 1eb5082a..b91dcb85 100644 --- a/.github/workflows/stream_flutter_workflow.yml +++ b/.github/workflows/stream_flutter_workflow.yml @@ -31,7 +31,7 @@ jobs: flutter-version: ${{ env.flutter_version }} - name: "Install Tools" run: | - flutter pub global activate melos 1.0.0-dev.3 + flutter pub global activate melos 1.0.0-dev.10 - name: "Bootstrap Workspace" run: melos bootstrap - name: "Dart Analyze" diff --git a/docusaurus/docs/Flutter/assets/message_actions.jpg b/docusaurus/docs/Flutter/assets/message_actions.jpg new file mode 100644 index 00000000..dcfba64e Binary files /dev/null and b/docusaurus/docs/Flutter/assets/message_actions.jpg differ diff --git a/docusaurus/docs/Flutter/assets/message_actions.png b/docusaurus/docs/Flutter/assets/message_actions.png deleted file mode 100644 index 157f3979..00000000 Binary files a/docusaurus/docs/Flutter/assets/message_actions.png and /dev/null differ diff --git a/docusaurus/docs/Flutter/assets/message_list_view.png b/docusaurus/docs/Flutter/assets/message_list_view.png index 4f9be344..cc27be3c 100644 Binary files a/docusaurus/docs/Flutter/assets/message_list_view.png and b/docusaurus/docs/Flutter/assets/message_list_view.png differ diff --git a/docusaurus/docs/Flutter/assets/user_list_view.png b/docusaurus/docs/Flutter/assets/user_list_view.png index 4beffacc..9aede824 100644 Binary files a/docusaurus/docs/Flutter/assets/user_list_view.png and b/docusaurus/docs/Flutter/assets/user_list_view.png differ diff --git a/docusaurus/docs/Flutter/guides/customize_message_actions.mdx b/docusaurus/docs/Flutter/guides/customize_message_actions.mdx index b1cc07a0..c3dc2aba 100644 --- a/docusaurus/docs/Flutter/guides/customize_message_actions.mdx +++ b/docusaurus/docs/Flutter/guides/customize_message_actions.mdx @@ -10,7 +10,7 @@ Customizing Message Actions Message actions pop up in message overlay, when you long-press a message. -![](../assets/message_actions.png) +![](../assets/message_actions.jpg) We have provided granular control over these actions. diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 4b409536..f5b78e2e 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -1,3 +1,17 @@ +## 3.3.0 + +✅ Added + +- Extra properties added to `PaginationParams` to aid in fetching messages. +- Added hard delete functionality. + +🐞 Fixed + +- `closeConnection()` now uses `normalClosure` status when closing websocket. +- Fixed local unread count indicator increasing for thread replies. +- Fixed user presence indicator not updating correctly. +- `ChannelEvent.membersCount` defaults to 0 avoiding parsing errors due to missing `members_count` field. + ## 3.2.0 🐞 Fixed @@ -720,4 +734,4 @@ ## 0.0.2 -- first beta version +- first beta version \ No newline at end of file diff --git a/packages/stream_chat/example/lib/main.dart b/packages/stream_chat/example/lib/main.dart index d2c5dcea..629f4a32 100644 --- a/packages/stream_chat/example/lib/main.dart +++ b/packages/stream_chat/example/lib/main.dart @@ -233,10 +233,10 @@ class _MessageViewState extends State { ), ), ), - ) + ), ], ), - ) + ), ], ); } diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index e7c2d459..1bba9c84 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -648,7 +648,7 @@ class Channel { } /// Deletes the [message] from the channel. - Future deleteMessage(Message message) async { + Future deleteMessage(Message message, {bool? hard}) async { // Directly deleting the local messages which are not yet sent to server if (message.status == MessageSendingStatus.sending || message.status == MessageSendingStatus.failed) { @@ -675,7 +675,7 @@ class Channel { state?.addMessage(message); - final response = await _client.deleteMessage(message.id); + final response = await _client.deleteMessage(message.id, hard: hard); state?.addMessage(message.copyWith(status: MessageSendingStatus.sent)); @@ -1603,7 +1603,7 @@ class ChannelClientState { message.createdAt.isBefore( DateTime.now().subtract( const Duration( - seconds: 1, + seconds: 5, ), ), ), @@ -1663,7 +1663,11 @@ class ChannelClientState { void _listenMessageDeleted() { _subscriptions.add(_channel.on(EventType.messageDeleted).listen((event) { final message = event.message!; - addMessage(message); + if (event.hardDelete == true) { + removeMessage(message, hardDelete: true); + } else { + addMessage(message); + } })); } @@ -1718,7 +1722,7 @@ class ChannelClientState { } /// Remove a [message] from this [channelState]. - void removeMessage(Message message) { + void removeMessage(Message message, {bool hardDelete = false}) { final parentId = message.parentId; // i.e. it's a thread message // 1. Remove the thread message @@ -1740,7 +1744,10 @@ class ChannelClientState { } else { // Remove regular message final allMessages = [...messages]; - if (allMessages.remove(message)) { + if (hardDelete) { + allMessages.removeWhere((e) => e.id == message.id); + _channelState = _channelState.copyWith(messages: allMessages); + } else if (allMessages.remove(message)) { _channelState = _channelState.copyWith(messages: allMessages); } } @@ -1860,10 +1867,13 @@ class ChannelClientState { (m) => m.user.id == message.user?.id, ) != null; + final isThreadMessage = message.parentId != null; + return !message.silent && !message.shadowed && message.user?.id != userId && - !userIsMuted; + !userIsMuted && + !isThreadMessage; } /// Update threads with updated information about messages. diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 6b6ae933..623fa41c 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -44,10 +44,6 @@ final _levelEmojiMapper = { Level.SEVERE: '🚨', }; -final _userAgent = 'stream-chat-dart-client-' - '${CurrentPlatform.name}-' - '${PACKAGE_VERSION.split('+')[0]}'; - /// The official Dart client for Stream Chat, /// a service for building chat applications. /// This library can be used on any Dart project and on both mobile and web apps @@ -86,7 +82,7 @@ class StreamChatClient { location: location, connectTimeout: connectTimeout, receiveTimeout: receiveTimeout, - headers: {'X-Stream-Client': _userAgent}, + headers: {'X-Stream-Client': defaultUserAgent}, ); _chatApi = chatApi ?? @@ -106,7 +102,7 @@ class StreamChatClient { tokenManager: _tokenManager, handler: handleEvent, logger: detachedLogger('🔌'), - queryParameters: {'X-Stream-Client': _userAgent}, + queryParameters: {'X-Stream-Client': defaultUserAgent}, ); _retryPolicy = retryPolicy ?? @@ -131,6 +127,14 @@ class StreamChatClient { _originalChatPersistenceClient = value; } + /// Default user agent for all requests + static String defaultUserAgent = 'stream-chat-dart-client-' + '${CurrentPlatform.name}-' + '${PACKAGE_VERSION.split('+')[0]}'; + + /// Additionals headers for all requests + static Map additionalHeaders = {}; + ChatPersistenceClient? _originalChatPersistenceClient; /// Chat persistence client @@ -1209,8 +1213,14 @@ class StreamChatClient { ); /// Deletes the given message - Future deleteMessage(String messageId) => - _chatApi.message.deleteMessage(messageId); + Future deleteMessage(String messageId, {bool? hard}) async { + final response = + await _chatApi.message.deleteMessage(messageId, hard: hard); + if (hard == true) { + await _chatPersistenceClient?.deleteMessageById(messageId); + } + return response; + } /// Get a message by [messageId] Future getMessage(String messageId) => @@ -1428,6 +1438,7 @@ class ClientState { .listen((Event event) async { final eventChannel = event.channel!; await _client.chatPersistenceClient?.deleteChannels([eventChannel.cid]); + channels[eventChannel.cid]?.dispose(); channels = channels..remove(eventChannel.cid); })); } diff --git a/packages/stream_chat/lib/src/core/api/message_api.dart b/packages/stream_chat/lib/src/core/api/message_api.dart index 17269820..8894e14c 100644 --- a/packages/stream_chat/lib/src/core/api/message_api.dart +++ b/packages/stream_chat/lib/src/core/api/message_api.dart @@ -80,10 +80,16 @@ class MessageApi { /// Deletes the given [messageId] Future deleteMessage( - String messageId, - ) async { + String messageId, { + bool? hard, + }) async { final response = await _client.delete( '/messages/$messageId', + queryParameters: hard != null + ? { + 'hard': hard, + } + : null, ); return EmptyResponse.fromJson(response.data); } diff --git a/packages/stream_chat/lib/src/core/api/requests.dart b/packages/stream_chat/lib/src/core/api/requests.dart index 14995b10..6f00b374 100644 --- a/packages/stream_chat/lib/src/core/api/requests.dart +++ b/packages/stream_chat/lib/src/core/api/requests.dart @@ -60,8 +60,11 @@ class PaginationParams extends Equatable { /// ``` const PaginationParams({ this.limit = 10, + this.before = 10, + this.after = 10, this.offset, this.next, + this.idAround, this.greaterThan, this.greaterThanOrEqual, this.lessThan, @@ -78,12 +81,22 @@ class PaginationParams extends Equatable { /// The amount of items requested from the APIs. final int limit; + /// The amount of items requested before message ID from the APIs. + final int before; + + /// The amount of items requested after message ID from the APIs. + final int after; + /// The offset of requesting items. final int? offset; /// A key used to paginate. final String? next; + /// Message ID to fetch messages around + @JsonKey(name: 'id_around') + final String? idAround; + /// Filter on ids greater than the given value. @JsonKey(name: 'id_gt') final String? greaterThan; @@ -106,7 +119,10 @@ class PaginationParams extends Equatable { /// Creates a copy of [PaginationParams] with specified attributes overridden. PaginationParams copyWith({ int? limit, + int? before, + int? after, int? offset, + String? idAround, String? next, String? greaterThan, String? greaterThanOrEqual, @@ -115,7 +131,10 @@ class PaginationParams extends Equatable { }) => PaginationParams( limit: limit ?? this.limit, + before: before ?? this.before, + after: limit ?? this.after, offset: offset ?? this.offset, + idAround: idAround ?? this.idAround, next: next ?? this.next, greaterThan: greaterThan ?? this.greaterThan, greaterThanOrEqual: greaterThanOrEqual ?? this.greaterThanOrEqual, @@ -126,8 +145,11 @@ class PaginationParams extends Equatable { @override List get props => [ limit, + before, + after, offset, next, + idAround, greaterThan, greaterThanOrEqual, lessThan, diff --git a/packages/stream_chat/lib/src/core/api/requests.g.dart b/packages/stream_chat/lib/src/core/api/requests.g.dart index ef995997..7d45ee86 100644 --- a/packages/stream_chat/lib/src/core/api/requests.g.dart +++ b/packages/stream_chat/lib/src/core/api/requests.g.dart @@ -21,8 +21,11 @@ Map _$SortOptionToJson(SortOption instance) => PaginationParams _$PaginationParamsFromJson(Map json) => PaginationParams( limit: json['limit'] as int? ?? 10, + before: json['before'] as int? ?? 10, + after: json['after'] as int? ?? 10, offset: json['offset'] as int?, next: json['next'] as String?, + idAround: json['id_around'] as String?, greaterThan: json['id_gt'] as String?, greaterThanOrEqual: json['id_gte'] as String?, lessThan: json['id_lt'] as String?, @@ -32,6 +35,8 @@ PaginationParams _$PaginationParamsFromJson(Map json) => Map _$PaginationParamsToJson(PaginationParams instance) { final val = { 'limit': instance.limit, + 'before': instance.before, + 'after': instance.after, }; void writeNotNull(String key, dynamic value) { @@ -42,6 +47,7 @@ Map _$PaginationParamsToJson(PaginationParams instance) { writeNotNull('offset', instance.offset); writeNotNull('next', instance.next); + writeNotNull('id_around', instance.idAround); writeNotNull('id_gt', instance.greaterThan); writeNotNull('id_gte', instance.greaterThanOrEqual); writeNotNull('id_lt', instance.lessThan); diff --git a/packages/stream_chat/lib/src/core/http/interceptor/additional_headers_interceptor.dart b/packages/stream_chat/lib/src/core/http/interceptor/additional_headers_interceptor.dart new file mode 100644 index 00000000..466db58f --- /dev/null +++ b/packages/stream_chat/lib/src/core/http/interceptor/additional_headers_interceptor.dart @@ -0,0 +1,17 @@ +import 'package:dio/dio.dart'; +import 'package:stream_chat/stream_chat.dart'; + +/// Interceptor that sets additional headers for all requests. +class AdditionalHeadersInterceptor extends Interceptor { + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + options.headers = { + ...options.headers, + ...StreamChatClient.additionalHeaders, + }; + return handler.next(options); + } +} diff --git a/packages/stream_chat/lib/src/core/http/stream_http_client.dart b/packages/stream_chat/lib/src/core/http/stream_http_client.dart index c19cdb8a..429eb52f 100644 --- a/packages/stream_chat/lib/src/core/http/stream_http_client.dart +++ b/packages/stream_chat/lib/src/core/http/stream_http_client.dart @@ -5,6 +5,7 @@ import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; import 'package:stream_chat/src/core/error/error.dart'; import 'package:stream_chat/src/core/http/connection_id_manager.dart'; +import 'package:stream_chat/src/core/http/interceptor/additional_headers_interceptor.dart'; import 'package:stream_chat/src/core/http/interceptor/auth_interceptor.dart'; import 'package:stream_chat/src/core/http/interceptor/connection_id_interceptor.dart'; import 'package:stream_chat/src/core/http/interceptor/logging_interceptor.dart'; @@ -41,6 +42,7 @@ class StreamHttpClient { ..._options.headers, } ..interceptors.addAll([ + AdditionalHeadersInterceptor(), if (tokenManager != null) AuthInterceptor(this, tokenManager), if (connectionIdManager != null) ConnectionIdInterceptor(connectionIdManager), diff --git a/packages/stream_chat/lib/src/core/models/attachment_file.freezed.dart b/packages/stream_chat/lib/src/core/models/attachment_file.freezed.dart index 4956274a..c524a742 100644 --- a/packages/stream_chat/lib/src/core/models/attachment_file.freezed.dart +++ b/packages/stream_chat/lib/src/core/models/attachment_file.freezed.dart @@ -14,7 +14,7 @@ final _privateConstructorUsedError = UnsupportedError( 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more informations: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); UploadState _$UploadStateFromJson(Map json) { - switch (json['runtimeType'] as String?) { + switch (json['runtimeType']) { case 'preparing': return Preparing.fromJson(json); case 'inProgress': @@ -55,7 +55,7 @@ class _$UploadStateTearOff { ); } - UploadState fromJson(Map json) { + UploadState fromJson(Map json) { return UploadState.fromJson(json); } } @@ -153,11 +153,14 @@ class _$PreparingCopyWithImpl<$Res> extends _$UploadStateCopyWithImpl<$Res> /// @nodoc @JsonSerializable() class _$Preparing implements Preparing { - const _$Preparing(); + const _$Preparing({String? $type}) : $type = $type ?? 'preparing'; factory _$Preparing.fromJson(Map json) => _$$PreparingFromJson(json); + @JsonKey(name: 'runtimeType') + final String $type; + @override String toString() { return 'UploadState.preparing()'; @@ -165,7 +168,8 @@ class _$Preparing implements Preparing { @override bool operator ==(dynamic other) { - return identical(this, other) || (other is Preparing); + return identical(this, other) || + (other.runtimeType == runtimeType && other is Preparing); } @override @@ -247,7 +251,7 @@ class _$Preparing implements Preparing { @override Map toJson() { - return _$$PreparingToJson(this)..['runtimeType'] = 'preparing'; + return _$$PreparingToJson(this); } } @@ -295,7 +299,9 @@ class _$InProgressCopyWithImpl<$Res> extends _$UploadStateCopyWithImpl<$Res> /// @nodoc @JsonSerializable() class _$InProgress implements InProgress { - const _$InProgress({required this.uploaded, required this.total}); + const _$InProgress( + {required this.uploaded, required this.total, String? $type}) + : $type = $type ?? 'inProgress'; factory _$InProgress.fromJson(Map json) => _$$InProgressFromJson(json); @@ -305,6 +311,9 @@ class _$InProgress implements InProgress { @override final int total; + @JsonKey(name: 'runtimeType') + final String $type; + @override String toString() { return 'UploadState.inProgress(uploaded: $uploaded, total: $total)'; @@ -313,19 +322,15 @@ class _$InProgress implements InProgress { @override bool operator ==(dynamic other) { return identical(this, other) || - (other is InProgress && + (other.runtimeType == runtimeType && + other is InProgress && (identical(other.uploaded, uploaded) || - const DeepCollectionEquality() - .equals(other.uploaded, uploaded)) && - (identical(other.total, total) || - const DeepCollectionEquality().equals(other.total, total))); + other.uploaded == uploaded) && + (identical(other.total, total) || other.total == total)); } @override - int get hashCode => - runtimeType.hashCode ^ - const DeepCollectionEquality().hash(uploaded) ^ - const DeepCollectionEquality().hash(total); + int get hashCode => Object.hash(runtimeType, uploaded, total); @JsonKey(ignore: true) @override @@ -408,7 +413,7 @@ class _$InProgress implements InProgress { @override Map toJson() { - return _$$InProgressToJson(this)..['runtimeType'] = 'inProgress'; + return _$$InProgressToJson(this); } } @@ -419,8 +424,8 @@ abstract class InProgress implements UploadState { factory InProgress.fromJson(Map json) = _$InProgress.fromJson; - int get uploaded => throw _privateConstructorUsedError; - int get total => throw _privateConstructorUsedError; + int get uploaded; + int get total; @JsonKey(ignore: true) $InProgressCopyWith get copyWith => throw _privateConstructorUsedError; @@ -445,11 +450,14 @@ class _$SuccessCopyWithImpl<$Res> extends _$UploadStateCopyWithImpl<$Res> /// @nodoc @JsonSerializable() class _$Success implements Success { - const _$Success(); + const _$Success({String? $type}) : $type = $type ?? 'success'; factory _$Success.fromJson(Map json) => _$$SuccessFromJson(json); + @JsonKey(name: 'runtimeType') + final String $type; + @override String toString() { return 'UploadState.success()'; @@ -457,7 +465,8 @@ class _$Success implements Success { @override bool operator ==(dynamic other) { - return identical(this, other) || (other is Success); + return identical(this, other) || + (other.runtimeType == runtimeType && other is Success); } @override @@ -539,7 +548,7 @@ class _$Success implements Success { @override Map toJson() { - return _$$SuccessToJson(this)..['runtimeType'] = 'success'; + return _$$SuccessToJson(this); } } @@ -581,7 +590,8 @@ class _$FailedCopyWithImpl<$Res> extends _$UploadStateCopyWithImpl<$Res> /// @nodoc @JsonSerializable() class _$Failed implements Failed { - const _$Failed({required this.error}); + const _$Failed({required this.error, String? $type}) + : $type = $type ?? 'failed'; factory _$Failed.fromJson(Map json) => _$$FailedFromJson(json); @@ -589,6 +599,9 @@ class _$Failed implements Failed { @override final String error; + @JsonKey(name: 'runtimeType') + final String $type; + @override String toString() { return 'UploadState.failed(error: $error)'; @@ -597,14 +610,13 @@ class _$Failed implements Failed { @override bool operator ==(dynamic other) { return identical(this, other) || - (other is Failed && - (identical(other.error, error) || - const DeepCollectionEquality().equals(other.error, error))); + (other.runtimeType == runtimeType && + other is Failed && + (identical(other.error, error) || other.error == error)); } @override - int get hashCode => - runtimeType.hashCode ^ const DeepCollectionEquality().hash(error); + int get hashCode => Object.hash(runtimeType, error); @JsonKey(ignore: true) @override @@ -687,7 +699,7 @@ class _$Failed implements Failed { @override Map toJson() { - return _$$FailedToJson(this)..['runtimeType'] = 'failed'; + return _$$FailedToJson(this); } } @@ -696,7 +708,7 @@ abstract class Failed implements UploadState { factory Failed.fromJson(Map json) = _$Failed.fromJson; - String get error => throw _privateConstructorUsedError; + String get error; @JsonKey(ignore: true) $FailedCopyWith get copyWith => throw _privateConstructorUsedError; } diff --git a/packages/stream_chat/lib/src/core/models/attachment_file.g.dart b/packages/stream_chat/lib/src/core/models/attachment_file.g.dart index f36c269d..6b657c2a 100644 --- a/packages/stream_chat/lib/src/core/models/attachment_file.g.dart +++ b/packages/stream_chat/lib/src/core/models/attachment_file.g.dart @@ -22,31 +22,42 @@ Map _$AttachmentFileToJson(AttachmentFile instance) => 'size': instance.size, }; -_$Preparing _$$PreparingFromJson(Map json) => _$Preparing(); +_$Preparing _$$PreparingFromJson(Map json) => _$Preparing( + $type: json['runtimeType'] as String?, + ); Map _$$PreparingToJson(_$Preparing instance) => - {}; + { + 'runtimeType': instance.$type, + }; _$InProgress _$$InProgressFromJson(Map json) => _$InProgress( uploaded: json['uploaded'] as int, total: json['total'] as int, + $type: json['runtimeType'] as String?, ); Map _$$InProgressToJson(_$InProgress instance) => { 'uploaded': instance.uploaded, 'total': instance.total, + 'runtimeType': instance.$type, }; -_$Success _$$SuccessFromJson(Map json) => _$Success(); +_$Success _$$SuccessFromJson(Map json) => _$Success( + $type: json['runtimeType'] as String?, + ); -Map _$$SuccessToJson(_$Success instance) => - {}; +Map _$$SuccessToJson(_$Success instance) => { + 'runtimeType': instance.$type, + }; _$Failed _$$FailedFromJson(Map json) => _$Failed( error: json['error'] as String, + $type: json['runtimeType'] as String?, ); Map _$$FailedToJson(_$Failed instance) => { 'error': instance.error, + 'runtimeType': instance.$type, }; diff --git a/packages/stream_chat/lib/src/core/models/event.dart b/packages/stream_chat/lib/src/core/models/event.dart index aed35696..8dc08a8d 100644 --- a/packages/stream_chat/lib/src/core/models/event.dart +++ b/packages/stream_chat/lib/src/core/models/event.dart @@ -27,6 +27,7 @@ class Event { this.channelId, this.channelType, this.parentId, + this.hardDelete, this.extraData = const {}, this.isLocal = true, }) : createdAt = createdAt?.toUtc() ?? DateTime.now().toUtc(); @@ -91,6 +92,10 @@ class Event { @JsonKey(defaultValue: false) final bool isLocal; + /// This is true if the message has been hard deleted + @JsonKey(includeIfNull: false) + final bool? hardDelete; + /// Map of custom channel extraData final Map extraData; @@ -113,6 +118,7 @@ class Event { 'channel_id', 'channel_type', 'parent_id', + 'hard_delete', 'is_local', ]; @@ -139,6 +145,7 @@ class Event { int? unreadChannels, bool? online, String? parentId, + bool? hardDelete, Map? extraData, }) => Event( @@ -158,6 +165,7 @@ class Event { channelId: channelId ?? this.channelId, channelType: channelType ?? this.channelType, parentId: parentId ?? this.parentId, + hardDelete: hardDelete ?? this.hardDelete, extraData: extraData ?? this.extraData, isLocal: isLocal, ); @@ -181,7 +189,7 @@ class EventChannel extends ChannelModel { required DateTime createdAt, required DateTime updatedAt, DateTime? deletedAt, - required int memberCount, + int memberCount = 0, Map? extraData, int cooldown = 0, String? team, diff --git a/packages/stream_chat/lib/src/core/models/event.g.dart b/packages/stream_chat/lib/src/core/models/event.g.dart index 29a4482b..d3a531fe 100644 --- a/packages/stream_chat/lib/src/core/models/event.g.dart +++ b/packages/stream_chat/lib/src/core/models/event.g.dart @@ -37,30 +37,42 @@ Event _$EventFromJson(Map json) => Event( channelId: json['channel_id'] as String?, channelType: json['channel_type'] as String?, parentId: json['parent_id'] as String?, + hardDelete: json['hard_delete'] as bool?, extraData: json['extra_data'] as Map? ?? const {}, isLocal: json['is_local'] as bool? ?? false, ); -Map _$EventToJson(Event instance) => { - 'type': instance.type, - 'cid': instance.cid, - 'channel_id': instance.channelId, - 'channel_type': instance.channelType, - 'connection_id': instance.connectionId, - 'created_at': instance.createdAt.toIso8601String(), - 'me': instance.me?.toJson(), - 'user': instance.user?.toJson(), - 'message': instance.message?.toJson(), - 'channel': instance.channel?.toJson(), - 'member': instance.member?.toJson(), - 'reaction': instance.reaction?.toJson(), - 'total_unread_count': instance.totalUnreadCount, - 'unread_channels': instance.unreadChannels, - 'online': instance.online, - 'parent_id': instance.parentId, - 'is_local': instance.isLocal, - 'extra_data': instance.extraData, - }; +Map _$EventToJson(Event instance) { + final val = { + 'type': instance.type, + 'cid': instance.cid, + 'channel_id': instance.channelId, + 'channel_type': instance.channelType, + 'connection_id': instance.connectionId, + 'created_at': instance.createdAt.toIso8601String(), + 'me': instance.me?.toJson(), + 'user': instance.user?.toJson(), + 'message': instance.message?.toJson(), + 'channel': instance.channel?.toJson(), + 'member': instance.member?.toJson(), + 'reaction': instance.reaction?.toJson(), + 'total_unread_count': instance.totalUnreadCount, + 'unread_channels': instance.unreadChannels, + 'online': instance.online, + 'parent_id': instance.parentId, + 'is_local': instance.isLocal, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('hard_delete', instance.hardDelete); + val['extra_data'] = instance.extraData; + return val; +} EventChannel _$EventChannelFromJson(Map json) => EventChannel( members: (json['members'] as List?) @@ -82,7 +94,7 @@ EventChannel _$EventChannelFromJson(Map json) => EventChannel( deletedAt: json['deleted_at'] == null ? null : DateTime.parse(json['deleted_at'] as String), - memberCount: json['member_count'] as int, + memberCount: json['member_count'] as int? ?? 0, extraData: json['extra_data'] as Map?, cooldown: json['cooldown'] as int? ?? 0, team: json['team'] as String?, diff --git a/packages/stream_chat/lib/src/core/models/read.dart b/packages/stream_chat/lib/src/core/models/read.dart index 812fc6f7..40a72c92 100644 --- a/packages/stream_chat/lib/src/core/models/read.dart +++ b/packages/stream_chat/lib/src/core/models/read.dart @@ -1,3 +1,4 @@ +import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:stream_chat/src/core/models/user.dart'; @@ -5,9 +6,9 @@ part 'read.g.dart'; /// The class that defines a read event @JsonSerializable() -class Read { +class Read extends Equatable { /// Constructor used for json serialization - Read({ + const Read({ required this.lastRead, required this.user, this.unreadMessages = 0, @@ -39,4 +40,11 @@ class Read { user: user ?? this.user, unreadMessages: unreadMessages ?? this.unreadMessages, ); + + @override + List get props => [ + lastRead, + user, + unreadMessages, + ]; } diff --git a/packages/stream_chat/lib/src/core/models/user.dart b/packages/stream_chat/lib/src/core/models/user.dart index 44df4399..7aa7c51c 100644 --- a/packages/stream_chat/lib/src/core/models/user.dart +++ b/packages/stream_chat/lib/src/core/models/user.dart @@ -179,5 +179,14 @@ class User extends Equatable { ); @override - List get props => [id, role]; + List get props => [ + id, + role, + lastActive, + online, + extraData, + banned, + teams, + language, + ]; } diff --git a/packages/stream_chat/lib/src/db/chat_persistence_client.dart b/packages/stream_chat/lib/src/db/chat_persistence_client.dart index aa0ac245..98f6a364 100644 --- a/packages/stream_chat/lib/src/db/chat_persistence_client.dart +++ b/packages/stream_chat/lib/src/db/chat_persistence_client.dart @@ -82,7 +82,6 @@ abstract class ChatPersistenceClient { members: data[0] as List, // ignore: cast_nullable_to_non_nullable read: data[1] as List, - // ignore: cast_nullable_to_non_nullable channel: data[2] as ChannelModel?, // ignore: cast_nullable_to_non_nullable messages: data[3] as List, diff --git a/packages/stream_chat/lib/src/ws/websocket.dart b/packages/stream_chat/lib/src/ws/websocket.dart index 05382fb7..82c7d7c1 100644 --- a/packages/stream_chat/lib/src/ws/websocket.dart +++ b/packages/stream_chat/lib/src/ws/websocket.dart @@ -121,7 +121,8 @@ class WebSocket with TimerHelper { _logger?.info('Closing connection with $baseUrl'); if (_webSocketChannel != null) { _unsubscribeFromWebSocketChannel(); - _webSocketChannel?.sink.close(status.goingAway); + _webSocketChannel?.sink + .close(_manuallyClosed ? status.normalClosure : status.goingAway); _webSocketChannel = null; } } @@ -309,7 +310,10 @@ class WebSocket with TimerHelper { Event? event; try { event = Event.fromJson(jsonData); - } catch (_) {} + } catch (e, stk) { + _logger?.warning('Error parsing an event: $e'); + _logger?.warning('Stack trace: $stk'); + } if (event == null) return; diff --git a/packages/stream_chat/lib/version.dart b/packages/stream_chat/lib/version.dart index c839a929..cc19a375 100644 --- a/packages/stream_chat/lib/version.dart +++ b/packages/stream_chat/lib/version.dart @@ -3,4 +3,4 @@ import 'package:stream_chat/src/client/client.dart'; /// Current package version /// Used in [StreamChatClient] to build the `x-stream-client` header // ignore: constant_identifier_names -const PACKAGE_VERSION = '3.2.0'; +const PACKAGE_VERSION = '3.3.0'; diff --git a/packages/stream_chat/pubspec.yaml b/packages/stream_chat/pubspec.yaml index 96096fd3..38ee620c 100644 --- a/packages/stream_chat/pubspec.yaml +++ b/packages/stream_chat/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat homepage: https://getstream.io/ description: The official Dart client for Stream Chat, a service for building chat applications. -version: 3.2.0 +version: 3.3.0 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -13,10 +13,10 @@ dependencies: collection: ^1.15.0 dio: ^4.0.0 equatable: ^2.0.0 - freezed_annotation: ^0.15.0 + freezed_annotation: ^1.0.0 http_parser: ^4.0.0 jose: ^0.3.2 - json_annotation: ^4.0.1 + json_annotation: ^4.3.0 logging: ^1.0.1 meta: ^1.3.0 mime: ^1.0.0 @@ -28,7 +28,7 @@ dependencies: dev_dependencies: build_runner: ^2.0.1 dart_code_metrics: ^4.4.0 - freezed: ^0.15.0+1 + freezed: ^1.0.0 json_serializable: ^6.0.1 mocktail: ^0.2.0 test: ^1.17.12 \ No newline at end of file diff --git a/packages/stream_chat/test/src/core/http/interceptor/additional_headers_interceptor_test.dart b/packages/stream_chat/test/src/core/http/interceptor/additional_headers_interceptor_test.dart new file mode 100644 index 00000000..88cf42ae --- /dev/null +++ b/packages/stream_chat/test/src/core/http/interceptor/additional_headers_interceptor_test.dart @@ -0,0 +1,29 @@ +import 'package:dio/dio.dart'; +import 'package:stream_chat/src/core/http/interceptor/additional_headers_interceptor.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +void main() { + late AdditionalHeadersInterceptor additionalHeadersInterceptor; + + setUp(() { + additionalHeadersInterceptor = AdditionalHeadersInterceptor(); + }); + + test( + '`onRequest` should add additional headers in the request', + () async { + final options = RequestOptions(path: 'test-path'); + final handler = RequestInterceptorHandler(); + + StreamChatClient.additionalHeaders = {'test-header': 'test-value'}; + additionalHeadersInterceptor.onRequest(options, handler); + + final updatedOptions = (await handler.future).data as RequestOptions; + final updateHeaders = updatedOptions.headers; + + expect(updateHeaders.containsKey('test-header'), isTrue); + expect(updateHeaders['test-header'], 'test-value'); + }, + ); +} diff --git a/packages/stream_chat/test/src/core/http/stream_http_client_test.dart b/packages/stream_chat/test/src/core/http/stream_http_client_test.dart index 7860a0d6..6abed8ae 100644 --- a/packages/stream_chat/test/src/core/http/stream_http_client_test.dart +++ b/packages/stream_chat/test/src/core/http/stream_http_client_test.dart @@ -4,6 +4,7 @@ import 'package:mocktail/mocktail.dart'; import 'package:stream_chat/src/core/api/responses.dart'; import 'package:stream_chat/src/core/error/error.dart'; import 'package:stream_chat/src/core/http/connection_id_manager.dart'; +import 'package:stream_chat/src/core/http/interceptor/additional_headers_interceptor.dart'; import 'package:stream_chat/src/core/http/interceptor/auth_interceptor.dart'; import 'package:stream_chat/src/core/http/interceptor/connection_id_interceptor.dart'; import 'package:stream_chat/src/core/http/interceptor/logging_interceptor.dart'; @@ -48,12 +49,23 @@ void main() { return dioError; } + test('UserAgentInterceptor should be added', () { + const apiKey = 'api-key'; + final client = StreamHttpClient(apiKey); + + expect( + client.httpClient.interceptors + .whereType() + .length, + 1); + }); + test('AuthInterceptor should be added if tokenManager is provided', () { const apiKey = 'api-key'; final client = StreamHttpClient(apiKey, tokenManager: TokenManager()); - expect(client.httpClient.interceptors.length, 1); - expect(client.httpClient.interceptors.first, isA()); + expect( + client.httpClient.interceptors.whereType().length, 1); }); test( @@ -65,10 +77,11 @@ void main() { connectionIdManager: ConnectionIdManager(), ); - expect(client.httpClient.interceptors.length, 1); expect( - client.httpClient.interceptors.first, - isA(), + client.httpClient.interceptors + .whereType() + .length, + 1, ); }, ); @@ -80,10 +93,9 @@ void main() { logger: Logger('test-logger'), ); - expect(client.httpClient.interceptors.length, 1); expect( - client.httpClient.interceptors.first, - isA(), + client.httpClient.interceptors.whereType().length, + 1, ); }); @@ -111,27 +123,6 @@ void main() { verify(() => logger.severe(any())).called(greaterThan(0)); }); - test('`.lock` should lock the dio client', () async { - final client = StreamHttpClient('api-key'); - expect(client.httpClient.interceptors.requestLock.locked, isFalse); - client.lock(); - expect(client.httpClient.interceptors.requestLock.locked, isTrue); - }); - - test('`.unlock` should unlock the dio client', () async { - final client = StreamHttpClient('api-key'); - expect(client.httpClient.interceptors.requestLock.locked, isFalse); - client.lock(); - expect(client.httpClient.interceptors.requestLock.locked, isTrue); - client.unlock(); - expect(client.httpClient.interceptors.requestLock.locked, isFalse); - }); - - test('`.clear` should clear and unlock the dio client', () async { - final client = StreamHttpClient('api-key')..clear(); - expect(client.httpClient.interceptors.requestLock.locked, isFalse); - }); - test('`.close` should close the dio client', () async { final client = StreamHttpClient('api-key')..close(force: true); try { diff --git a/packages/stream_chat/test/src/core/models/reaction_test.dart b/packages/stream_chat/test/src/core/models/reaction_test.dart index fbe493ef..285d8a46 100644 --- a/packages/stream_chat/test/src/core/models/reaction_test.dart +++ b/packages/stream_chat/test/src/core/models/reaction_test.dart @@ -70,13 +70,19 @@ void main() { expect( newReaction.extraData, {'updated_at': '2020-01-28T22:17:31.108742Z'}); + final newUserCreateTime = DateTime.now(); + newReaction = reaction.copyWith( type: 'lol', createdAt: DateTime.parse('2021-01-28T22:17:31.108742Z'), extraData: {}, messageId: 'test', score: 2, - user: User(id: 'test'), + user: User( + id: 'test', + createdAt: newUserCreateTime, + updatedAt: newUserCreateTime, + ), userId: 'test', ); @@ -88,12 +94,21 @@ void main() { expect(newReaction.extraData, {}); expect(newReaction.messageId, 'test'); expect(newReaction.score, 2); - expect(newReaction.user, User(id: 'test')); + expect( + newReaction.user, + User( + id: 'test', + createdAt: newUserCreateTime, + updatedAt: newUserCreateTime, + ), + ); expect(newReaction.userId, 'test'); }); test('merge', () { final reaction = Reaction.fromJson(jsonFixture('reaction.json')); + final newUserCreateTime = DateTime.now(); + final newReaction = reaction.merge( Reaction( type: 'lol', @@ -101,7 +116,11 @@ void main() { extraData: {}, messageId: 'test', score: 2, - user: User(id: 'test'), + user: User( + id: 'test', + createdAt: newUserCreateTime, + updatedAt: newUserCreateTime, + ), userId: 'test', ), ); @@ -114,7 +133,14 @@ void main() { expect(newReaction.extraData, {}); expect(newReaction.messageId, 'test'); expect(newReaction.score, 2); - expect(newReaction.user, User(id: 'test')); + expect( + newReaction.user, + User( + id: 'test', + createdAt: newUserCreateTime, + updatedAt: newUserCreateTime, + ), + ); expect(newReaction.userId, 'test'); }); }); diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index cc6cd374..46af3352 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -6,18 +6,36 @@ - `MessageInput` now works with a `MessageInputController` instead of a `TextEditingController` +## 3.3.0 + +✅ Added + +- `MessageListView` now allows more better control over spacing after messages using `spacingWidgetBuilder`. +- `StreamChannel` can now fetch messages around a message ID with the `queryAroundMessage` call. +- Added `MessageListView.keyboardDismissBehavior` property. + +🐞 Fixed + +- [[#766]]`AttachmentActionsModal` now has customisation options for actions. +- Fixed `MessageWidget` null errors associated with `channel.memberCount`. +- Fixed adding attachments on web. +- [[#767]](https://github.com/GetStream/stream-chat-flutter/issues/767): Fix `MessageInput` focus behaviour when sending messages. +- Fixed user presence indicator not updating correctly. +- Do not use `withData: true` in `FilePicker` calls. +- Fixed read indicator not updating correctly in specific situations. + ## 3.2.0 -- Updated Dart SDK constraints to `>=2.14.0 <3.0.0` +- Updated Dart SDK constraints to `>=2.14.0 <3.0.0`. - Updated `stream_chat_flutter_core` dependency to [`3.2.0`](https://pub.dev/packages/stream_chat_flutter_core/changelog). 🐞 Fixed -- Fixed message highlight animation alignment in `MessageListView` +- Fixed message highlight animation alignment in `MessageListView`. - [[#491]](https://github.com/GetStream/stream-chat-flutter/issues/491): Fix `MediaListView` showing media in wrong order. - Fixed `MessageListView` initialIndex not working in some cases. - Improved `MessageListView` rendering in case of reordering. -- Fix image thumbnail generation when using Stream CDN +- Fix image thumbnail generation when using Stream CDN. ✅ Added @@ -868,4 +886,4 @@ The property showVideoFullScreen was added mainly because of this issue brianega ## 0.0.1 -- First release +- First release \ No newline at end of file diff --git a/packages/stream_chat_flutter/example/lib/tutorial_part_6.dart b/packages/stream_chat_flutter/example/lib/tutorial_part_6.dart index 423bd7db..19d5d1ae 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial_part_6.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial_part_6.dart @@ -64,11 +64,12 @@ class MyApp extends StatelessWidget { ), ), messageListViewTheme: const MessageListViewThemeData( - backgroundColor: Colors.grey, - backgroundImage: DecorationImage( - image: AssetImage('assets/background_doodle.png'), - fit: BoxFit.cover, - )), + backgroundColor: Colors.grey, + backgroundImage: DecorationImage( + image: AssetImage('assets/background_doodle.png'), + fit: BoxFit.cover, + ), + ), otherMessageTheme: MessageThemeData( messageBackgroundColor: colorTheme.textHighEmphasis, messageTextStyle: TextStyle( diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart index 2ce9adb5..c77b668c 100644 --- a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart @@ -45,6 +45,7 @@ class PositionedList extends StatefulWidget { this.addSemanticIndexes = true, this.addRepaintBoundaries = true, this.addAutomaticKeepAlives = true, + this.keyboardDismissBehavior, }) : assert((positionedIndex == 0) || (positionedIndex < itemCount), 'positionedIndex cannot be 0 and must be smaller than itemCount'), super(key: key); @@ -134,6 +135,10 @@ class PositionedList extends StatefulWidget { /// See [SliverChildBuilderDelegate.addAutomaticKeepAlives]. final bool addAutomaticKeepAlives; + /// [ScrollViewKeyboardDismissBehavior] the defines how this [PositionedList] will + /// dismiss the keyboard automatically. + final ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior; + @override State createState() => _PositionedListState(); } @@ -173,6 +178,7 @@ class _PositionedListState extends State { anchor: widget.alignment, center: _centerKey, controller: scrollController, + keyboardDismissBehavior: widget.keyboardDismissBehavior, scrollDirection: widget.scrollDirection, reverse: widget.reverse, cacheExtent: widget.cacheExtent, diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scroll_view.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scroll_view.dart index a0b499c0..1aff0df3 100644 --- a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scroll_view.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scroll_view.dart @@ -28,9 +28,12 @@ class UnboundedCustomScrollView extends CustomScrollView { List slivers = const [], int? semanticChildCount, DragStartBehavior dragStartBehavior = DragStartBehavior.start, + ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior, }) : _anchor = anchor, super( key: key, + keyboardDismissBehavior: keyboardDismissBehavior ?? + ScrollViewKeyboardDismissBehavior.manual, scrollDirection: scrollDirection, reverse: reverse, controller: controller, diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart index 765a90a0..f3203ff6 100644 --- a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart @@ -52,6 +52,7 @@ class ScrollablePositionedList extends StatefulWidget { this.addRepaintBoundaries = true, this.minCacheExtent, this.findChildIndexCallback, + this.keyboardDismissBehavior, }) : itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?, separatorBuilder = null, super(key: key); @@ -77,6 +78,7 @@ class ScrollablePositionedList extends StatefulWidget { this.addRepaintBoundaries = true, this.minCacheExtent, this.findChildIndexCallback, + this.keyboardDismissBehavior, }) : assert(separatorBuilder != null, 'seperatorBuilder cannot be null'), itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?, super(key: key); @@ -92,6 +94,10 @@ class ScrollablePositionedList extends StatefulWidget { /// index of the child element with that associated key, or null if not found. final ChildIndexGetter? findChildIndexCallback; + /// [ScrollViewKeyboardDismissBehavior] the defines how this [PositionedList] will + /// dismiss the keyboard automatically. + final ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior; + /// Number of items the [itemBuilder] can produce. final int itemCount; @@ -344,6 +350,7 @@ class _ScrollablePositionedListState extends State child: NotificationListener( onNotification: (_) => _isTransitioning, child: PositionedList( + keyboardDismissBehavior: widget.keyboardDismissBehavior, itemBuilder: widget.itemBuilder, separatorBuilder: widget.separatorBuilder, itemCount: widget.itemCount, @@ -374,6 +381,8 @@ class _ScrollablePositionedListState extends State child: NotificationListener( onNotification: (_) => false, child: PositionedList( + keyboardDismissBehavior: + widget.keyboardDismissBehavior, itemBuilder: widget.itemBuilder, separatorBuilder: widget.separatorBuilder, itemCount: widget.itemCount, diff --git a/packages/stream_chat_flutter/lib/src/attachment_actions_modal.dart b/packages/stream_chat_flutter/lib/src/attachment_actions_modal.dart index f49561aa..0c10ae46 100644 --- a/packages/stream_chat_flutter/lib/src/attachment_actions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/attachment_actions_modal.dart @@ -24,6 +24,11 @@ class AttachmentActionsModal extends StatelessWidget { this.onShowMessage, this.imageDownloader, this.fileDownloader, + this.showReply = true, + this.showShowInChat = true, + this.showSave = true, + this.showDelete = true, + this.customActions = const [], }) : super(key: key); /// The message containing the attachments @@ -41,6 +46,49 @@ class AttachmentActionsModal extends StatelessWidget { /// Callback to provide download files final AttachmentDownloader? fileDownloader; + /// Show reply option + final bool showReply; + + /// Show show in chat option + final bool showShowInChat; + + /// Show save option + final bool showSave; + + /// Show delete option + final bool showDelete; + + /// List of custom actions + final List customActions; + + /// Creates a copy of [MessageWidget] with specified attributes overridden. + AttachmentActionsModal copyWith({ + Key? key, + int? currentIndex, + Message? message, + VoidCallback? onShowMessage, + AttachmentDownloader? imageDownloader, + AttachmentDownloader? fileDownloader, + bool? showReply, + bool? showShowInChat, + bool? showSave, + bool? showDelete, + List? customActions, + }) => + AttachmentActionsModal( + key: key ?? this.key, + currentIndex: currentIndex ?? this.currentIndex, + message: message ?? this.message, + onShowMessage: onShowMessage ?? this.onShowMessage, + imageDownloader: imageDownloader ?? this.imageDownloader, + fileDownloader: fileDownloader ?? this.fileDownloader, + showReply: showReply ?? this.showReply, + showShowInChat: showShowInChat ?? this.showShowInChat, + showSave: showSave ?? this.showSave, + showDelete: showDelete ?? this.showDelete, + customActions: customActions ?? this.customActions, + ); + @override Widget build(BuildContext context) => GestureDetector( behavior: HitTestBehavior.translucent, @@ -67,82 +115,86 @@ class AttachmentActionsModal extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ - _buildButton( - context, - context.translations.replyLabel, - StreamSvgIcon.iconCurveLineLeftUp( - size: 24, - color: theme.colorTheme.textLowEmphasis, + if (showReply) + _buildButton( + context, + context.translations.replyLabel, + StreamSvgIcon.iconCurveLineLeftUp( + size: 24, + color: theme.colorTheme.textLowEmphasis, + ), + () { + Navigator.pop(context, ReturnActionType.reply); + }, ), - () { - Navigator.pop(context, ReturnActionType.reply); - }, - ), - _buildButton( - context, - context.translations.showInChatLabel, - StreamSvgIcon.eye( - size: 24, - color: theme.colorTheme.textHighEmphasis, + if (showShowInChat) + _buildButton( + context, + context.translations.showInChatLabel, + StreamSvgIcon.eye( + size: 24, + color: theme.colorTheme.textHighEmphasis, + ), + onShowMessage, ), - onShowMessage, - ), - _buildButton( - context, - message.attachments[currentIndex].type == 'video' - ? context.translations.saveVideoLabel - : context.translations.saveImageLabel, - StreamSvgIcon.iconSave( - size: 24, - color: theme.colorTheme.textLowEmphasis, + if (showSave) + _buildButton( + context, + message.attachments[currentIndex].type == 'video' + ? context.translations.saveVideoLabel + : context.translations.saveImageLabel, + StreamSvgIcon.iconSave( + size: 24, + color: theme.colorTheme.textLowEmphasis, + ), + () { + final attachment = message.attachments[currentIndex]; + final isImage = attachment.type == 'image'; + final Future Function( + Attachment, { + void Function(int, int) progressCallback, + }) saveFile = fileDownloader ?? _downloadAttachment; + final Future Function( + Attachment, { + void Function(int, int) progressCallback, + }) saveImage = imageDownloader ?? _downloadAttachment; + final downloader = isImage ? saveImage : saveFile; + + final progressNotifier = + ValueNotifier<_DownloadProgress?>( + _DownloadProgress.initial(), + ); + + downloader( + attachment, + progressCallback: (received, total) { + progressNotifier.value = _DownloadProgress( + total, + received, + ); + }, + ).catchError((e, stk) { + progressNotifier.value = null; + }); + + // Closing attachment actions modal before opening + // attachment download dialog + Navigator.pop(context); + + showDialog( + barrierDismissible: false, + context: context, + barrierColor: theme.colorTheme.overlay, + builder: (context) => _buildDownloadProgressDialog( + context, + progressNotifier, + ), + ); + }, ), - () { - final attachment = message.attachments[currentIndex]; - final isImage = attachment.type == 'image'; - final Future Function( - Attachment, { - void Function(int, int) progressCallback, - }) saveFile = fileDownloader ?? _downloadAttachment; - final Future Function( - Attachment, { - void Function(int, int) progressCallback, - }) saveImage = imageDownloader ?? _downloadAttachment; - final downloader = isImage ? saveImage : saveFile; - - final progressNotifier = - ValueNotifier<_DownloadProgress?>( - _DownloadProgress.initial(), - ); - - downloader( - attachment, - progressCallback: (received, total) { - progressNotifier.value = _DownloadProgress( - total, - received, - ); - }, - ).catchError((e, stk) { - progressNotifier.value = null; - }); - - // Closing attachment actions modal before opening - // attachment download dialog - Navigator.pop(context); - - showDialog( - barrierDismissible: false, - context: context, - barrierColor: theme.colorTheme.overlay, - builder: (context) => _buildDownloadProgressDialog( - context, - progressNotifier, - ), - ); - }, - ), if (StreamChat.of(context).currentUser?.id == - message.user?.id) + message.user?.id && + showDelete) _buildButton( context, context.translations.deleteLabel.capitalize(), @@ -171,6 +223,16 @@ class AttachmentActionsModal extends StatelessWidget { }, color: theme.colorTheme.accentError, ), + ...customActions + .map( + (e) => _buildButton( + context, + e.actionTitle, + e.icon, + e.onTap, + ), + ) + .toList(), ] .map((e) => Align( alignment: Alignment.centerRight, @@ -193,7 +255,7 @@ class AttachmentActionsModal extends StatelessWidget { Widget _buildButton( context, String title, - StreamSvgIcon icon, + Widget icon, VoidCallback? onTap, { Color? color, Key? key, @@ -331,3 +393,22 @@ class _DownloadProgress { int get toPercentage => (received * 100) ~/ total; } + +/// Class for custom attachment action +class AttachmentAction { + /// Constructor for custom attachment action + AttachmentAction({ + required this.actionTitle, + required this.icon, + required this.onTap, + }); + + /// Title for the attachment action + String actionTitle; + + /// Icon for the attachment action + Widget icon; + + /// Callback for when the action is tapped + VoidCallback onTap; +} diff --git a/packages/stream_chat_flutter/lib/src/channel_info.dart b/packages/stream_chat_flutter/lib/src/channel_info.dart index 6bd61dad..62bfd1de 100644 --- a/packages/stream_chat_flutter/lib/src/channel_info.dart +++ b/packages/stream_chat_flutter/lib/src/channel_info.dart @@ -59,9 +59,10 @@ class ChannelInfo extends StatelessWidget { final memberCount = channel.memberCount; if (memberCount != null && memberCount > 2) { var text = context.translations.membersCountText(memberCount); - final watcherCount = channel.state?.watcherCount ?? 0; - if (watcherCount > 0) { - text += ' ${context.translations.watchersCountText(watcherCount)}'; + final onlineCount = + members?.where((m) => m.user?.online == true).length ?? 0; + if (onlineCount > 0) { + text += ', ${context.translations.watchersCountText(onlineCount)}'; } alternativeWidget = Text( text, diff --git a/packages/stream_chat_flutter/lib/src/channel_preview.dart b/packages/stream_chat_flutter/lib/src/channel_preview.dart index 6e3e5363..de038c82 100644 --- a/packages/stream_chat_flutter/lib/src/channel_preview.dart +++ b/packages/stream_chat_flutter/lib/src/channel_preview.dart @@ -126,16 +126,26 @@ class ChannelPreview extends StatelessWidget { streamChatState.currentUser?.id) { return Padding( padding: const EdgeInsets.only(right: 4), - child: SendingIndicator( - message: lastMessage!, - size: channelPreviewTheme.indicatorIconSize, - isMessageRead: channel.state!.read - .where((element) => - element.user.id != - channel.client.state.currentUser!.id) - .where((element) => element.lastRead - .isAfter(lastMessage.createdAt)) - .isNotEmpty, + child: BetterStreamBuilder>( + stream: channel.state?.readStream, + initialData: channel.state?.read, + builder: (context, data) { + final readList = data.where((it) => + it.user.id != + channel.client.state.currentUser?.id && + (it.lastRead + .isAfter(lastMessage!.createdAt) || + it.lastRead.isAtSameMomentAs( + lastMessage.createdAt, + ))); + final isMessageRead = readList.length >= + (channel.memberCount ?? 0) - 1; + return SendingIndicator( + message: lastMessage!, + size: channelPreviewTheme.indicatorIconSize, + isMessageRead: isMessageRead, + ); + }, ), ); } diff --git a/packages/stream_chat_flutter/lib/src/extension.dart b/packages/stream_chat_flutter/lib/src/extension.dart index 92590de1..73956da6 100644 --- a/packages/stream_chat_flutter/lib/src/extension.dart +++ b/packages/stream_chat_flutter/lib/src/extension.dart @@ -1,6 +1,7 @@ import 'package:characters/characters.dart'; import 'package:diacritic/diacritic.dart'; import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/emoji/emoji.dart'; import 'package:stream_chat_flutter/src/localization/translations.dart'; @@ -46,7 +47,7 @@ extension IterableX on Iterable { extension PlatformFileX on PlatformFile { /// Converts the [PlatformFile] into [AttachmentFile] AttachmentFile get toAttachmentFile => AttachmentFile( - path: path, + path: kIsWeb ? null : path, name: name, bytes: bytes, size: size, diff --git a/packages/stream_chat_flutter/lib/src/full_screen_media.dart b/packages/stream_chat_flutter/lib/src/full_screen_media.dart index 0c47383c..6547a93d 100644 --- a/packages/stream_chat_flutter/lib/src/full_screen_media.dart +++ b/packages/stream_chat_flutter/lib/src/full_screen_media.dart @@ -33,6 +33,7 @@ class FullScreenMedia extends StatefulWidget { this.startIndex = 0, String? userName, this.onShowMessage, + this.attachmentActionsModalBuilder, }) : userName = userName ?? '', super(key: key); @@ -51,6 +52,11 @@ class FullScreenMedia extends StatefulWidget { /// Callback for when show message is tapped final ShowMessageCallback? onShowMessage; + /// Widget builder for attachment actions modal + /// [defaultActionsModal] is the default [AttachmentActionsModal] config + /// Use [defaultActionsModal.copyWith] to easily customize it + final AttachmentActionsBuilder? attachmentActionsModalBuilder; + @override _FullScreenMediaState createState() => _FullScreenMediaState(); } @@ -196,6 +202,8 @@ class _FullScreenMediaState extends State StreamChannel.of(context).channel, ); }, + attachmentActionsModalBuilder: + widget.attachmentActionsModalBuilder, ), if (!widget.message.isEphemeral) GalleryFooter( diff --git a/packages/stream_chat_flutter/lib/src/gallery_footer.dart b/packages/stream_chat_flutter/lib/src/gallery_footer.dart index dcfa0e29..0acd9b02 100644 --- a/packages/stream_chat_flutter/lib/src/gallery_footer.dart +++ b/packages/stream_chat_flutter/lib/src/gallery_footer.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; diff --git a/packages/stream_chat_flutter/lib/src/gallery_header.dart b/packages/stream_chat_flutter/lib/src/gallery_header.dart index fc5f67b4..2325af69 100644 --- a/packages/stream_chat_flutter/lib/src/gallery_header.dart +++ b/packages/stream_chat_flutter/lib/src/gallery_header.dart @@ -6,6 +6,15 @@ import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/theme/themes.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +/// Widget builder for attachment actions modal +/// [defaultActionsModal] is the default [AttachmentActionsModal] config +/// Use [defaultActionsModal.copyWith] to easily customize it +typedef AttachmentActionsBuilder = Widget Function( + BuildContext context, + Attachment attachment, + AttachmentActionsModal defaultActionsModal, +); + /// Header/AppBar widget for media display screen class GalleryHeader extends StatelessWidget implements PreferredSizeWidget { /// Creates a channel header @@ -21,6 +30,7 @@ class GalleryHeader extends StatelessWidget implements PreferredSizeWidget { this.userName = '', this.sentAt = '', this.backgroundColor, + this.attachmentActionsModalBuilder, }) : preferredSize = const Size.fromHeight(kToolbarHeight), super(key: key); @@ -55,6 +65,11 @@ class GalleryHeader extends StatelessWidget implements PreferredSizeWidget { /// The background color of this [GalleryHeader]. final Color? backgroundColor; + /// Widget builder for attachment actions modal + /// [defaultActionsModal] is the default [AttachmentActionsModal] config + /// Use [defaultActionsModal.copyWith] to easily customize it + final AttachmentActionsBuilder? attachmentActionsModalBuilder; + @override Widget build(BuildContext context) { final galleryHeaderThemeData = GalleryHeaderTheme.of(context); @@ -123,17 +138,26 @@ class GalleryHeader extends StatelessWidget implements PreferredSizeWidget { final galleryHeaderThemeData = StreamChatTheme.of(context).galleryHeaderTheme; + final defaultModal = AttachmentActionsModal( + message: message, + currentIndex: currentIndex, + onShowMessage: onShowMessage, + ); + + final effectiveModal = attachmentActionsModalBuilder?.call( + context, + message.attachments[currentIndex], + defaultModal, + ) ?? + defaultModal; + final result = await showDialog( useRootNavigator: false, context: context, barrierColor: galleryHeaderThemeData.bottomSheetBarrierColor, builder: (context) => StreamChannel( channel: channel, - child: AttachmentActionsModal( - message: message, - currentIndex: currentIndex, - onShowMessage: onShowMessage, - ), + child: effectiveModal, ), ); diff --git a/packages/stream_chat_flutter/lib/src/group_avatar.dart b/packages/stream_chat_flutter/lib/src/group_avatar.dart index 4a16279e..bf599c63 100644 --- a/packages/stream_chat_flutter/lib/src/group_avatar.dart +++ b/packages/stream_chat_flutter/lib/src/group_avatar.dart @@ -81,6 +81,7 @@ class GroupAvatar extends StatelessWidget { ), initialData: member, builder: (context, member) => UserAvatar( + showOnlineStatus: false, user: member.user!, borderRadius: BorderRadius.zero, ), @@ -118,6 +119,7 @@ class GroupAvatar extends StatelessWidget { ), initialData: member, builder: (context, member) => UserAvatar( + showOnlineStatus: false, user: member.user!, borderRadius: BorderRadius.zero, ), diff --git a/packages/stream_chat_flutter/lib/src/message_input.dart b/packages/stream_chat_flutter/lib/src/message_input.dart index 61ac3ac6..2e035df0 100644 --- a/packages/stream_chat_flutter/lib/src/message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input.dart @@ -218,6 +218,7 @@ class MessageInput extends StatefulWidget { this.mentionAllAppUsers = false, this.attachmentsPickerBuilder, this.sendButtonBuilder, + this.shouldKeepFocusAfterMessage, }) : assert( initialMessage == null || editMessage == null, "Can't provide both `initialMessage` and `editMessage`", @@ -342,6 +343,10 @@ class MessageInput extends StatefulWidget { /// Builder for creating send button final MessageRelatedBuilder? sendButtonBuilder; + /// Defines if the [MessageInput] loses focuses after a message is sent. + /// The default behaviour keeps focus until a command is enabled. + final bool? shouldKeepFocusAfterMessage; + @override MessageInputState createState() => MessageInputState(); @@ -988,7 +993,8 @@ class MessageInputState extends State { } Widget _buildMentionsOverlayEntry() { - if (messageInputController.selectionStart < 0) { + final channel = StreamChannel.of(context).channel; + if (messageInputController.selectionStart < 0 || channel.state == null) { return const Offstage(); } @@ -1020,7 +1026,7 @@ class MessageInputState extends State { query: query, mentionAllAppUsers: widget.mentionAllAppUsers, client: StreamChat.of(context).client, - channel: StreamChannel.of(context).channel, + channel: channel, size: Size(renderObject.size.width - 16, 400), mentionsTileBuilder: tileBuilder, onMentionUserTap: (user) { @@ -1476,7 +1482,6 @@ class MessageInputState extends State { } final res = await FilePicker.platform.pickFiles( type: type, - withData: true, ); if (res?.files.isNotEmpty == true) { file = res!.files.single.toAttachmentFile; @@ -1554,7 +1559,9 @@ class MessageInputState extends State { return; } - final shouldUnfocus = _commandEnabled; + var shouldKeepFocus = widget.shouldKeepFocusAfterMessage; + + shouldKeepFocus ??= !_commandEnabled; if (_commandEnabled) { text = '${'/${_chosenCommand!.name} '}$text'; @@ -1620,8 +1627,10 @@ class MessageInputState extends State { sendingFuture = channel.updateMessage(message); } - if (!shouldUnfocus) { + if (shouldKeepFocus) { FocusScope.of(context).requestFocus(_focusNode); + } else { + FocusScope.of(context).unfocus(); } final resp = await sendingFuture; diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index 5424a1c2..40e74f23 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -58,6 +58,46 @@ typedef OnMessageTap = void Function(Message); /// Callback on reply tapped typedef ReplyTapCallback = void Function(Message); +/// Spacing Types (These are properties of a message to help inform the decision +/// of how much space / which widget to build after it) +enum SpacingType { + /// Message is a thread + thread, + + /// There is a >1s time diff between current and last message + timeDiff, + + /// Next message is by a different user + otherUser, + + /// Message is deleted + deleted, + + /// No other conditions are valid, default spacing (This will likely be the + /// only rule in the list provided) + defaultSpacing, +} + +/// Builder for building certain spacing after widgets. +/// This spacing can be in form of any widgets you like. +/// A List of [SpacingType] is provided to help inform the decision of +/// what to build after the message. +/// +/// As an example: +/// MessageListView( +/// spacingWidgetBuilder: (context, list) { +/// if(list.contains(SpacingType.defaultSpacing)) { +/// return SizedBox(height: 2.0,); +/// } else { +/// return SizedBox(height: 8.0,); +/// } +/// }, +/// ), +typedef SpacingWidgetBuilder = Widget Function( + BuildContext context, + List spacingTypes, +); + /// Class for message details // ignore: prefer-match-file-name class MessageDetails { @@ -171,8 +211,14 @@ class MessageListView extends StatefulWidget { this.reverse = true, this.paginationLimit = 20, this.paginationLoadingIndicatorBuilder, + this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.onDrag, + this.spacingWidgetBuilder, }) : super(key: key); + /// [ScrollViewKeyboardDismissBehavior] the defines how this [PositionedList] will + /// dismiss the keyboard automatically. + final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; + /// Function used to build a custom message widget final MessageBuilder? messageBuilder; @@ -289,6 +335,12 @@ class MessageListView extends StatefulWidget { /// Builder used to build the loading indicator shown while paginating. final WidgetBuilder? paginationLoadingIndicatorBuilder; + /// This allows a user to customise the space after a message + /// A List of [SpacingType] is provided to provide more data about the + /// type of message (thread, difference in time between current and last + /// message, default spacing, etc) + final SpacingWidgetBuilder? spacingWidgetBuilder; + @override _MessageListViewState createState() => _MessageListViewState(); } @@ -443,9 +495,6 @@ class _MessageListViewState extends State { childAnchor: Alignment.topCenter, message: statusString, child: LazyLoadScrollView( - onPageScrollStart: () { - FocusScope.of(context).unfocus(); - }, onStartOfPage: () async { _inBetweenList = false; if (!_upToDate) { @@ -471,6 +520,7 @@ class _MessageListViewState extends State { key: (initialIndex != 0 && initialAlignment != 0) ? ValueKey('$initialIndex-$initialAlignment') : null, + keyboardDismissBehavior: widget.keyboardDismissBehavior, itemPositionsListener: _itemPositionListener, initialScrollIndex: initialIndex, initialAlignment: initialAlignment, @@ -564,17 +614,38 @@ class _MessageListViewState extends State { Units.MINUTE, ); + final spacingRules = []; + final isNextUserSame = message.user!.id == nextMessage.user?.id; final isThread = message.replyCount! > 0; final isDeleted = message.isDeleted; - if (timeDiff >= 1 || - !isNextUserSame || - isThread || - isDeleted) { - return const SizedBox(height: 8); + final hasTimeDiff = timeDiff >= 1; + + if (hasTimeDiff) { + spacingRules.add(SpacingType.timeDiff); } - return const SizedBox(height: 2); + + if (!isNextUserSame) { + spacingRules.add(SpacingType.otherUser); + } + + if (isThread) { + spacingRules.add(SpacingType.thread); + } + + if (isDeleted) { + spacingRules.add(SpacingType.deleted); + } + + if (spacingRules.isNotEmpty) { + return widget.spacingWidgetBuilder + ?.call(context, spacingRules) ?? + const SizedBox(height: 8); + } + return widget.spacingWidgetBuilder + ?.call(context, [SpacingType.defaultSpacing]) ?? + const SizedBox(height: 2); }, itemBuilder: (context, i) { if (i == itemCount - 1) { @@ -1003,15 +1074,6 @@ class _MessageListViewState extends State { ); } - final channel = streamChannel!.channel; - final readList = channel.state?.read.where((read) { - if (read.user.id == userId) return false; - return read.lastRead.isAfter(message.createdAt) || - read.lastRead.isAtSameMomentAs(message.createdAt); - }).toList() ?? - []; - - final allRead = readList.length >= (channel.memberCount ?? 0) - 1; final hasFileAttachment = message.attachments.any((it) => it.type == 'file'); @@ -1140,8 +1202,6 @@ class _MessageListViewState extends State { messageTheme: isMyMessage ? _streamTheme.ownMessageTheme : _streamTheme.otherMessageTheme, - readList: readList, - allRead: allRead, onReturnAction: (action) { switch (action) { case ReturnActionType.none: diff --git a/packages/stream_chat_flutter/lib/src/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget.dart index f6ae52c6..6182df54 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget.dart @@ -97,14 +97,20 @@ class MessageWidget extends StatefulWidget { this.deletedBottomRowBuilder, this.onReturnAction, this.customAttachmentBuilders, - this.readList, this.padding, this.textPadding = const EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), this.attachmentPadding = EdgeInsets.zero, - this.allRead = false, + @Deprecated(''' + allRead is now deprecated and it will be removed in future releases. + The MessageWidget now listens for read events on its own. + ''') this.allRead = false, + @Deprecated(''' + readList is now deprecated and it will be removed in future releases. + The MessageWidget now listens for read events on its own. + ''') this.readList, this.onQuotedMessageTap, this.customActions = const [], this.onAttachmentTap, @@ -508,7 +514,6 @@ class MessageWidget extends StatefulWidget { showUserAvatar: showUserAvatar ?? this.showUserAvatar, showSendingIndicator: showSendingIndicator ?? this.showSendingIndicator, showReactions: showReactions ?? this.showReactions, - allRead: allRead ?? this.allRead, showThreadReplyIndicator: showThreadReplyIndicator ?? this.showThreadReplyIndicator, showInChannelIndicator: @@ -517,7 +522,6 @@ class MessageWidget extends StatefulWidget { onLinkTap: onLinkTap ?? this.onLinkTap, showReactionPickerIndicator: showReactionPickerIndicator ?? this.showReactionPickerIndicator, - readList: readList ?? this.readList, onShowMessage: onShowMessage ?? this.onShowMessage, onReturnAction: onReturnAction ?? this.onReturnAction, showUsername: showUsername ?? this.showUsername, @@ -558,8 +562,6 @@ class _MessageWidgetState extends State bool get showTimeStamp => widget.showTimestamp; - bool get isMessageRead => widget.readList?.isNotEmpty == true; - bool get showInChannel => widget.showInChannelIndicator; bool get hasQuotedMessage => widget.message.quotedMessage != null; @@ -1230,6 +1232,7 @@ class _MessageWidgetState extends State Widget _buildSendingIndicator() { final style = widget.messageTheme.createdAtStyle; final message = widget.message; + final memberCount = StreamChannel.of(context).channel.memberCount ?? 0; if (hasNonUrlAttachments && (message.status == MessageSendingStatus.sending || @@ -1252,27 +1255,40 @@ class _MessageWidgetState extends State ); } - Widget child = SendingIndicator( - message: message, - isMessageRead: isMessageRead, - size: style!.fontSize, + final channel = StreamChannel.of(context).channel; + + return BetterStreamBuilder>( + stream: channel.state?.readStream, + initialData: channel.state?.read, + builder: (context, data) { + final readList = data.where((it) => + it.user.id != _streamChat.currentUser?.id && + (it.lastRead.isAfter(message.createdAt) || + it.lastRead.isAtSameMomentAs(message.createdAt))); + final isMessageRead = readList.length >= (channel.memberCount ?? 0) - 1; + Widget child = SendingIndicator( + message: message, + isMessageRead: isMessageRead, + size: style!.fontSize, + ); + if (isMessageRead) { + child = Row( + children: [ + if (memberCount > 2) + Text( + readList.length.toString(), + style: style.copyWith( + color: _streamChatTheme.colorTheme.accentPrimary, + ), + ), + const SizedBox(width: 2), + child, + ], + ); + } + return child; + }, ); - if (isMessageRead) { - child = Row( - children: [ - if (StreamChannel.of(context).channel.memberCount! > 2) - Text( - widget.readList!.length.toString(), - style: style.copyWith( - color: _streamChatTheme.colorTheme.accentPrimary, - ), - ), - const SizedBox(width: 2), - child, - ], - ); - } - return child; } Widget _buildUserAvatar() => Transform.translate( diff --git a/packages/stream_chat_flutter/lib/src/stream_chat.dart b/packages/stream_chat_flutter/lib/src/stream_chat.dart index ba8bcf64..327a0927 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat.dart @@ -110,7 +110,15 @@ class StreamChatState extends State { onBackgroundEventReceived: widget.onBackgroundEventReceived, backgroundKeepAlive: widget.backgroundKeepAlive, connectivityStream: widget.connectivityStream, - child: widget.child ?? const Offstage(), + child: Builder( + builder: (context) { + StreamChatClient.additionalHeaders = { + 'X-Stream-Client': + '${StreamChatClient.defaultUserAgent}-ui', + }; + return widget.child ?? const Offstage(); + }, + ), ), ); }, diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index da0cd3db..78977cf4 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -2,6 +2,7 @@ export 'package:jiffy/jiffy.dart'; export 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; export 'src/attachment/attachment.dart'; +export 'src/attachment_actions_modal.dart'; export 'src/back_button.dart'; export 'src/channel_avatar.dart'; export 'src/channel_header.dart'; diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index d89b4ccd..13300b2c 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat_flutter homepage: https://github.com/GetStream/stream-chat-flutter description: Stream Chat official Flutter SDK. Build your own chat experience using Dart and Flutter. -version: 3.2.0 +version: 3.3.0 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index 37354ebf..28ecb73f 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -1,7 +1,13 @@ +# Upcoming + ✅ Added - Added `MessageInputController` to hold `Message` related data. +## 3.3.0 + +- Updated `stream_chat` dependency to [`3.3.0`](https://pub.dev/packages/stream_chat/changelog). + ## 3.2.0 - Updated `stream_chat` dependency to [`3.2.0`](https://pub.dev/packages/stream_chat/changelog). diff --git a/packages/stream_chat_flutter_core/example/lib/main.dart b/packages/stream_chat_flutter_core/example/lib/main.dart index 31410e63..4d3b2f55 100644 --- a/packages/stream_chat_flutter_core/example/lib/main.dart +++ b/packages/stream_chat_flutter_core/example/lib/main.dart @@ -83,9 +83,12 @@ class HomeScreen extends StatelessWidget { channelListController: channelListController, filter: Filter.and([ Filter.equal('type', 'messaging'), - Filter.in_('members', [ - StreamChatCore.of(context).currentUser!.id, - ]) + Filter.in_( + 'members', + [ + StreamChatCore.of(context).currentUser!.id, + ], + ) ]), emptyBuilder: (BuildContext context) => const Center( child: Text('Looks like you are not in any channels'), @@ -120,7 +123,7 @@ class HomeScreen extends StatelessWidget { itemBuilder: (BuildContext context, int index) { final _item = channels[index]; return ListTile( - title: Text(_item.name!), + title: Text(_item.name ?? ''), subtitle: StreamBuilder( stream: _item.state!.lastMessageStream, initialData: _item.state!.lastMessage, @@ -318,10 +321,10 @@ class _MessageScreenState extends State { ), ), ), - ) + ), ], ), - ) + ), ], ), ), diff --git a/packages/stream_chat_flutter_core/lib/src/channel_list_core.dart b/packages/stream_chat_flutter_core/lib/src/channel_list_core.dart index 8ac28e76..51467ca3 100644 --- a/packages/stream_chat_flutter_core/lib/src/channel_list_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/channel_list_core.dart @@ -157,7 +157,10 @@ class ChannelListCoreState extends State { presence: widget.presence, memberLimit: widget.memberLimit, messageLimit: widget.messageLimit, - paginationParams: PaginationParams(limit: widget.limit), + paginationParams: PaginationParams( + limit: widget.limit, + offset: 0, + ), ); /// Fetches more channels with updated pagination and updates the widget diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart index 8117a26e..633c3137 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart @@ -227,13 +227,13 @@ class StreamChannelState extends State { preferOffline: preferOffline, ); - Future> _queryAtMessage({ + Future _queryAtMessage({ String? messageId, int before = 20, int after = 20, bool preferOffline = false, }) async { - if (channel.state == null) return []; + if (channel.state == null) return null; channel.state!.isUpToDate = false; channel.state!.truncate(); @@ -245,23 +245,33 @@ class StreamChannelState extends State { preferOffline: preferOffline, ); channel.state!.isUpToDate = true; - return []; + return null; } - return Future.wait([ - queryBeforeMessage( - messageId, - limit: before, - preferOffline: preferOffline, - ), - queryAfterMessage( - messageId, - limit: after, - preferOffline: preferOffline, - ), - ]); + return queryAroundMessage( + messageId, + before: before, + after: after, + preferOffline: preferOffline, + ); } + /// + Future queryAroundMessage( + String messageId, { + int before = 20, + int after = 20, + bool preferOffline = false, + }) => + channel.query( + messagesPagination: PaginationParams( + idAround: messageId, + before: before, + after: after, + ), + preferOffline: preferOffline, + ); + /// Future queryBeforeMessage( String messageId, { diff --git a/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart b/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart index 3d07b3c7..3d00b739 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart @@ -95,7 +95,12 @@ class StreamChatCoreState extends State Timer? _disconnectTimer; @override - Widget build(BuildContext context) => widget.child; + Widget build(BuildContext context) { + StreamChatClient.additionalHeaders = { + 'X-Stream-Client': '${StreamChatClient.defaultUserAgent}-core', + }; + return widget.child; + } // coverage:ignore-start diff --git a/packages/stream_chat_flutter_core/pubspec.yaml b/packages/stream_chat_flutter_core/pubspec.yaml index 85b82caa..48d104f3 100644 --- a/packages/stream_chat_flutter_core/pubspec.yaml +++ b/packages/stream_chat_flutter_core/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat_flutter_core homepage: https://github.com/GetStream/stream-chat-flutter description: Stream Chat official Flutter SDK Core. Build your own chat experience using Dart and Flutter. -version: 3.2.0 +version: 3.3.0 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -16,7 +16,7 @@ dependencies: sdk: flutter meta: ^1.3.0 rxdart: ^0.27.0 - stream_chat: ^3.2.0 + stream_chat: ^3.3.0 dev_dependencies: dart_code_metrics: ^4.4.0 diff --git a/packages/stream_chat_flutter_core/test/channel_list_core_test.dart b/packages/stream_chat_flutter_core/test/channel_list_core_test.dart index 8407be67..88711647 100644 --- a/packages/stream_chat_flutter_core/test/channel_list_core_test.dart +++ b/packages/stream_chat_flutter_core/test/channel_list_core_test.dart @@ -476,7 +476,7 @@ void main() { _stateSetter?.call(() => limit = 6); final updatedChannels = _generateChannels(mockClient, count: limit); - final updatedPagination = pagination.copyWith(limit: limit); + final updatedPagination = PaginationParams(limit: limit); when(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), diff --git a/packages/stream_chat_flutter_core/test/message_search_list_core_test.dart b/packages/stream_chat_flutter_core/test/message_search_list_core_test.dart index 475c6c23..d699faa0 100644 --- a/packages/stream_chat_flutter_core/test/message_search_list_core_test.dart +++ b/packages/stream_chat_flutter_core/test/message_search_list_core_test.dart @@ -518,7 +518,7 @@ void main() { _stateSetter?.call(() => limit = 6); final updatedMessageResponseList = _generateMessages(count: limit); - final updatedPagination = pagination.copyWith(limit: limit); + final updatedPagination = PaginationParams(limit: limit); when(() => mockClient.search( testFilter, query: any(named: 'query'), diff --git a/packages/stream_chat_flutter_core/test/stream_channel_test.dart b/packages/stream_chat_flutter_core/test/stream_channel_test.dart index 08763f9d..de536cb5 100644 --- a/packages/stream_chat_flutter_core/test/stream_channel_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_channel_test.dart @@ -189,9 +189,7 @@ void main() { membersPagination: any(named: 'membersPagination'), watchersPagination: any(named: 'watchersPagination'), preferOffline: any(named: 'preferOffline'), - )).called( - 2, // Fetching After messages + Fetching Before messages, - ); + )).called(1); }, ); @@ -214,14 +212,10 @@ void main() { child: const Offstage(key: childKey), ); - final beforePagination = PaginationParams( - lessThan: initialMessageId, - limit: 20, - ); - - final afterPagination = PaginationParams( - greaterThanOrEqual: initialMessageId, - limit: 20, + final paginationParams = PaginationParams( + idAround: initialMessageId, + after: 20, + before: 20, ); when(() => mockChannel.initialized).thenAnswer((_) async => true); @@ -232,17 +226,7 @@ void main() { state: any(named: 'state'), watch: any(named: 'watch'), presence: any(named: 'presence'), - messagesPagination: beforePagination, - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - preferOffline: any(named: 'preferOffline'), - )).thenAnswer((_) async => ChannelState(messages: messages)); - - when(() => mockChannel.query( - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - messagesPagination: afterPagination, + messagesPagination: paginationParams, membersPagination: any(named: 'membersPagination'), watchersPagination: any(named: 'watchersPagination'), preferOffline: any(named: 'preferOffline'), @@ -267,17 +251,7 @@ void main() { state: any(named: 'state'), watch: any(named: 'watch'), presence: any(named: 'presence'), - messagesPagination: beforePagination, - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - preferOffline: any(named: 'preferOffline'), - )).called(1); - - verify(() => mockChannel.query( - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - messagesPagination: afterPagination, + messagesPagination: paginationParams, membersPagination: any(named: 'membersPagination'), watchersPagination: any(named: 'watchersPagination'), preferOffline: any(named: 'preferOffline'), @@ -285,29 +259,15 @@ void main() { _stateSetter?.call(() => initialMessageId = 'testInitialMessageId2'); - final updatedBeforePagination = beforePagination.copyWith( - lessThan: initialMessageId, - ); - - final updatedAfterPagination = afterPagination.copyWith( - greaterThanOrEqual: initialMessageId, + final updatedPaginationParams = paginationParams.copyWith( + idAround: initialMessageId, ); when(() => mockChannel.query( state: any(named: 'state'), watch: any(named: 'watch'), presence: any(named: 'presence'), - messagesPagination: updatedBeforePagination, - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - preferOffline: any(named: 'preferOffline'), - )).thenAnswer((_) async => ChannelState(messages: messages)); - - when(() => mockChannel.query( - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - messagesPagination: updatedAfterPagination, + messagesPagination: updatedPaginationParams, membersPagination: any(named: 'membersPagination'), watchersPagination: any(named: 'watchersPagination'), preferOffline: any(named: 'preferOffline'), @@ -319,17 +279,7 @@ void main() { state: any(named: 'state'), watch: any(named: 'watch'), presence: any(named: 'presence'), - messagesPagination: updatedBeforePagination, - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - preferOffline: any(named: 'preferOffline'), - )).called(1); - - verify(() => mockChannel.query( - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - messagesPagination: updatedAfterPagination, + messagesPagination: updatedPaginationParams, membersPagination: any(named: 'membersPagination'), watchersPagination: any(named: 'watchersPagination'), preferOffline: any(named: 'preferOffline'), diff --git a/packages/stream_chat_flutter_core/test/user_list_core_test.dart b/packages/stream_chat_flutter_core/test/user_list_core_test.dart index c916ce17..27f4cbe8 100644 --- a/packages/stream_chat_flutter_core/test/user_list_core_test.dart +++ b/packages/stream_chat_flutter_core/test/user_list_core_test.dart @@ -496,7 +496,7 @@ void main() { _stateSetter?.call(() => limit = 6); final updatedUsers = _generateUsers(count: limit); - final updatedPagination = pagination.copyWith(limit: limit); + final updatedPagination = PaginationParams(limit: limit); when(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), diff --git a/packages/stream_chat_localizations/example/lib/add_new_lang.dart b/packages/stream_chat_localizations/example/lib/add_new_lang.dart index 28ad50fb..6fa7689b 100644 --- a/packages/stream_chat_localizations/example/lib/add_new_lang.dart +++ b/packages/stream_chat_localizations/example/lib/add_new_lang.dart @@ -374,8 +374,10 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { String get youText => 'You'; @override - String galleryPaginationText( - {required int currentPage, required int totalPages}) => + String galleryPaginationText({ + required int currentPage, + required int totalPages, + }) => '$currentPage of $totalPages'; @override diff --git a/packages/stream_chat_persistence/example/lib/main.dart b/packages/stream_chat_persistence/example/lib/main.dart index e187da56..551a3ae9 100644 --- a/packages/stream_chat_persistence/example/lib/main.dart +++ b/packages/stream_chat_persistence/example/lib/main.dart @@ -242,7 +242,7 @@ class _MessageViewState extends State { ), ), ), - ) + ), ], ), ) diff --git a/packages/stream_chat_persistence/test/src/dao/user_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/user_dao_test.dart index fb095e36..f5a40dbc 100644 --- a/packages/stream_chat_persistence/test/src/dao/user_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/user_dao_test.dart @@ -45,7 +45,6 @@ void main() { role: 'testRole', createdAt: DateTime.now(), updatedAt: DateTime.now(), - lastActive: DateTime.now(), online: math.Random().nextBool(), banned: math.Random().nextBool(), );