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..cc093a31 100644 --- a/.github/workflows/stream_flutter_workflow.yml +++ b/.github/workflows/stream_flutter_workflow.yml @@ -3,6 +3,7 @@ name: stream_flutter_workflow env: ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true' flutter_version: "2.5.1" + melos_version: "1.0.0-dev.10" on: pull_request: @@ -31,7 +32,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 ${{ env.melos_version }} - name: "Bootstrap Workspace" run: melos bootstrap - name: "Dart Analyze" @@ -60,7 +61,8 @@ jobs: with: flutter-version: ${{ env.flutter_version }} - name: "Install Tools" - run: flutter pub global activate melos + run: | + flutter pub global activate melos ${{ env.melos_version }} - name: "Bootstrap Workspace" run: melos bootstrap - name: "Melos Format" @@ -71,7 +73,7 @@ jobs: test: runs-on: macos-latest - timeout-minutes: 15 + timeout-minutes: 20 steps: - name: "Git Checkout" uses: actions/checkout@v2 @@ -88,7 +90,7 @@ jobs: flutter-version: ${{ env.flutter_version }} - name: "Install Tools" run: | - flutter pub global activate melos + flutter pub global activate melos ${{ env.melos_version }} pub global activate remove_from_coverage - name: "Bootstrap Workspace" run: melos bootstrap @@ -120,4 +122,4 @@ jobs: uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 with: path: packages/stream_chat_flutter/coverage/lcov.info - min_coverage: 67 \ No newline at end of file + min_coverage: 67 diff --git a/analysis_options.yaml b/analysis_options.yaml index e5eaf7ca..03a4220e 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -154,18 +154,6 @@ dart_code_metrics: # Dart Specific - binary-expression-operand-order - double-literal-format - - prefer-match-file-name: - exclude: - - packages/*/test/** - - packages/*/example/** - - packages/**/util/** - - packages/**/utils.dart - - packages/stream_chat/lib/src/client/client.dart - - packages/stream_chat/lib/src/core/api/responses.dart - - packages/stream_chat/lib/src/core/api/requests.dart - - packages/stream_chat/lib/src/core/platform_detector/** - - packages/stream_chat_persistence/lib/src/db/shared/** - - packages/stream_chat_localizations/lib/src/stream_chat_localizations.dart - no-boolean-literal-compare - no-equal-then-else - no-empty-block: 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..f7e02ba4 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -1,3 +1,31 @@ +## Upcoming + +✅ Added + +- Added `client.enrichUrl` endpoint for enriching URLs with metadata. + +## 3.3.1 + +🐞 Fixed + +- [[#799]](https://github.com/GetStream/stream-chat-flutter/issues/799) Fixed `totalUnreadCount` is not updating when + app is resumed from background mode. +- Fix retry mechanism failing in some cases. + +## 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 +748,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..51034d3a 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)); @@ -1466,8 +1466,6 @@ class ChannelClientState { _listenMemberRemoved(); - _computeUnread(); - _startCleaning(); _startCleaningPinnedMessages(); @@ -1490,15 +1488,6 @@ class ChannelClientState { final _subscriptions = []; - void _computeUnread() { - final userRead = channelState.read.firstWhereOrNull( - (r) => r.user.id == _channel._client.state.currentUser?.id, - ); - if (userRead != null && userRead.unreadMessages > 0) { - unreadCount = userRead.unreadMessages; - } - } - void _checkExpiredAttachmentMessages(ChannelState channelState) async { final expiredAttachmentMessagesId = channelState.messages .where((m) => @@ -1603,7 +1592,7 @@ class ChannelClientState { message.createdAt.isBefore( DateTime.now().subtract( const Duration( - seconds: 1, + seconds: 5, ), ), ), @@ -1663,7 +1652,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); + } })); } @@ -1705,7 +1698,7 @@ class ChannelClientState { } _channelState = _channelState.copyWith( - messages: newMessages, + messages: newMessages..sort(_sortByCreatedAt), channel: _channelState.channel?.copyWith( lastMessageAt: message.createdAt, ), @@ -1718,7 +1711,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 +1733,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); } } @@ -1843,15 +1839,34 @@ class ChannelClientState { /// Channel read list as a stream. Stream> get readStream => channelStateStream.map((cs) => cs.read); - final BehaviorSubject _unreadCountController = BehaviorSubject.seeded(0); + bool _isCurrentUserRead(Read read) => + read.user.id == _channel._client.state.currentUser!.id; - set unreadCount(int value) => _unreadCountController.add(value); + /// Channel read for the logged in user. + Read? get currentUserRead => read.firstWhereOrNull(_isCurrentUserRead); + + /// Channel read for the logged in user as a stream. + Stream get currentUserReadStream => + readStream.map((read) => read.firstWhereOrNull(_isCurrentUserRead)); /// Unread count getter as a stream. - Stream get unreadCountStream => _unreadCountController.stream.distinct(); + Stream get unreadCountStream => + currentUserReadStream.map((read) => read?.unreadMessages ?? 0); /// Unread count getter. - int get unreadCount => _unreadCountController.value; + int get unreadCount => currentUserRead?.unreadMessages ?? 0; + + /// Setter for unread count. + set unreadCount(int count) { + final reads = [..._channelState.read]; + final currentUserReadIndex = reads.indexWhere(_isCurrentUserRead); + + if (currentUserReadIndex < 0) return; + + reads[currentUserReadIndex] = + reads[currentUserReadIndex].copyWith(unreadMessages: count); + _channelState = _channelState.copyWith(read: reads); + } bool _countMessageAsUnread(Message message) { final userId = _channel.client.state.currentUser?.id; @@ -1860,10 +1875,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. @@ -2108,7 +2126,6 @@ class ChannelClientState { /// Call this method to dispose this object. void dispose() { _debouncedUpdatePersistenceChannelState.cancel(); - _unreadCountController.close(); _retryQueue.dispose(); _subscriptions.forEach((s) => s.cancel()); _channelStateController.close(); diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 6b6ae933..b2ae3657 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 @@ -391,6 +395,9 @@ class StreamChatClient { } void _handleHealthCheckEvent(Event event) { + final user = event.me; + if (user != null) state.currentUser = user; + final connectionId = event.connectionId; if (connectionId != null) { _connectionIdManager.setConnectionId(connectionId); @@ -1209,8 +1216,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) => @@ -1303,6 +1316,10 @@ class StreamChatClient { }, ); + /// Get OpenGraph data of the given [url]. + Future enrichUrl(String url) => + _chatApi.general.enrichUrl(url); + /// Closes the [_ws] connection and resets the [state] /// If [flushChatPersistence] is true the client deletes all offline /// user's data. @@ -1428,6 +1445,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/client/retry_queue.dart b/packages/stream_chat/lib/src/client/retry_queue.dart index c441b5fb..ad140ad6 100644 --- a/packages/stream_chat/lib/src/client/retry_queue.dart +++ b/packages/stream_chat/lib/src/client/retry_queue.dart @@ -1,18 +1,13 @@ import 'dart:async'; import 'package:collection/collection.dart'; -import 'package:logging/logging.dart'; import 'package:rxdart/rxdart.dart'; -import 'package:stream_chat/src/client/channel.dart'; import 'package:stream_chat/src/client/retry_policy.dart'; -import 'package:stream_chat/src/core/error/error.dart'; -import 'package:stream_chat/src/core/models/message.dart'; -import 'package:stream_chat/src/event_type.dart'; import 'package:stream_chat/stream_chat.dart'; -/// The retry queue associated to a channel +/// The retry queue associated to a channel. class RetryQueue { - /// Instantiate a new RetryQueue object + /// Instantiate a new RetryQueue object. RetryQueue({ required this.channel, this.logger, @@ -22,13 +17,13 @@ class RetryQueue { _listenFailedEvents(); } - /// The channel of this queue + /// The channel of this queue. final Channel channel; - /// The client associated with this [channel] + /// The client associated with this [channel]. final StreamChatClient client; - /// The logger associated to this queue + /// The logger associated to this queue. final Logger? logger; late final RetryPolicy _retryPolicy; @@ -68,17 +63,18 @@ class RetryQueue { }).addTo(_compositeSubscription); } - /// Add a list of messages + /// Add a list of messages. void add(List messages) { if (messages.isEmpty) return; - if (_messageQueue.containsAllMessage(messages)) return; + if (!_messageQueue.containsAllMessage(messages)) { + logger?.info('Adding ${messages.length} messages'); + final messageList = _messageQueue.toList(); + // we should not add message if already available in the queue + _messageQueue.addAll(messages.where( + (it) => !messageList.any((m) => m.id == it.id), + )); + } - logger?.info('Adding ${messages.length} messages'); - final messageList = _messageQueue.toList(); - // we should not add message if already available in the queue - _messageQueue.addAll(messages.where( - (it) => !messageList.any((m) => m.id == it.id), - )); _startRetrying(); } @@ -90,17 +86,21 @@ class RetryQueue { while (_messageQueue.isNotEmpty) { logger?.info('${_messageQueue.length} messages remaining in the queue'); final message = _messageQueue.first; - await _runAndRetry(message); + final succeeded = await _runAndRetry(message); + if (!succeeded) { + _messageQueue.toList().forEach(_sendFailedEvent); + break; + } } _isRetrying = false; } - Future _runAndRetry(Message message) async { + Future _runAndRetry(Message message) async { var attempt = 1; final maxAttempt = _retryPolicy.maxRetryAttempts; // early return in case maxAttempt is less than 0 - if (attempt > maxAttempt) return; + if (attempt > maxAttempt) return false; // ignore: literal_only_boolean_expressions while (true) { @@ -109,8 +109,13 @@ class RetryQueue { await _retryMessage(message); logger?.info('Message (${message.id}) sent successfully'); _messageQueue.removeMessage(message); - break; - } on StreamChatError catch (e) { + return true; + } catch (e) { + if (e is! StreamChatNetworkError || !e.isRetriable) { + _messageQueue.removeMessage(message); + _sendFailedEvent(message); + return true; + } // retry logic final maxAttempt = _retryPolicy.maxRetryAttempts; if (attempt < maxAttempt) { @@ -143,16 +148,9 @@ class RetryQueue { _sendFailedEvent(message); break; } - } catch (e) { - logger?.info( - 'API call failed due to unknown error (attempt $attempt). ' - 'Giving up for now, will retry when connection recovers. ' - 'Error was $e', - ); - _sendFailedEvent(message); - break; } } + return false; } void _sendFailedEvent(Message message) { @@ -177,10 +175,10 @@ class RetryQueue { } } - /// Whether our [_messageQueue] has messages or not + /// Whether our [_messageQueue] has messages or not. bool get hasMessages => _messageQueue.isNotEmpty; - /// Call this method to dispose this object + /// Call this method to dispose this object. void dispose() { _messageQueue.clear(); _compositeSubscription.dispose(); diff --git a/packages/stream_chat/lib/src/core/api/general_api.dart b/packages/stream_chat/lib/src/core/api/general_api.dart index fa08a475..9a2e773c 100644 --- a/packages/stream_chat/lib/src/core/api/general_api.dart +++ b/packages/stream_chat/lib/src/core/api/general_api.dart @@ -96,4 +96,16 @@ class GeneralApi { return QueryMembersResponse.fromJson(response.data); } + + /// Get OpenGraph data of the given [url]. + Future enrichUrl(String url) async { + final response = await _client.get( + '/og', + queryParameters: { + 'url': url, + }, + ); + + return OGAttachmentResponse.fromJson(response.data); + } } 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/api/responses.dart b/packages/stream_chat/lib/src/core/api/responses.dart index 5b52b65d..937e0150 100644 --- a/packages/stream_chat/lib/src/core/api/responses.dart +++ b/packages/stream_chat/lib/src/core/api/responses.dart @@ -442,3 +442,43 @@ class ChannelStateResponse extends _BaseResponse { static ChannelStateResponse fromJson(Map json) => _$ChannelStateResponseFromJson(json); } + +/// Model response for [Client.enrichUrl] api call. +@JsonSerializable(createToJson: false) +class OGAttachmentResponse extends _BaseResponse { + /// The URL of the page that was scraped. + late String ogScrapeUrl; + + /// The URL of the asset. + String? assetUrl; + + /// The URL of the author. + String? authorLink; + + /// The name of the author. + String? authorName; + + /// The URL of the image. + String? imageUrl; + + /// The text of the attachment. + String? text; + + /// The URL of the thumbnail. + String? thumbUrl; + + /// The title of the attachment. + String? title; + + /// The URL of the title. + String? titleLink; + + /// The type of the attachment. + /// + /// 'video' | 'audio' | 'image' + String? type; + + /// Create a new instance from a [json]. + static OGAttachmentResponse fromJson(Map json) => + _$OGAttachmentResponseFromJson(json); +} diff --git a/packages/stream_chat/lib/src/core/api/responses.g.dart b/packages/stream_chat/lib/src/core/api/responses.g.dart index 13d7d77b..fbcdd252 100644 --- a/packages/stream_chat/lib/src/core/api/responses.g.dart +++ b/packages/stream_chat/lib/src/core/api/responses.g.dart @@ -273,3 +273,18 @@ ChannelStateResponse _$ChannelStateResponseFromJson( ?.map((e) => Read.fromJson(e as Map)) .toList() ?? []; + +OGAttachmentResponse _$OGAttachmentResponseFromJson( + Map json) => + OGAttachmentResponse() + ..duration = json['duration'] as String? + ..ogScrapeUrl = json['og_scrape_url'] as String + ..assetUrl = json['asset_url'] as String? + ..authorLink = json['author_link'] as String? + ..authorName = json['author_name'] as String? + ..imageUrl = json['image_url'] as String? + ..text = json['text'] as String? + ..thumbUrl = json['thumb_url'] as String? + ..title = json['title'] as String? + ..titleLink = json['title_link'] as String? + ..type = json['type'] as String?; 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/message.dart b/packages/stream_chat/lib/src/core/models/message.dart index 10ff713e..f1408d45 100644 --- a/packages/stream_chat/lib/src/core/models/message.dart +++ b/packages/stream_chat/lib/src/core/models/message.dart @@ -14,7 +14,7 @@ class _PinExpires { const _pinExpires = _PinExpires(); -/// Enum defining the status of a sending message +/// Enum defining the status of a sending message. enum MessageSendingStatus { /// Message is being sent sending, @@ -40,10 +40,10 @@ enum MessageSendingStatus { sent, } -/// The class that contains the information about a message +/// The class that contains the information about a message. @JsonSerializable() class Message extends Equatable { - /// Constructor used for json serialization + /// Constructor used for json serialization. Message({ String? id, this.text, @@ -58,44 +58,47 @@ class Message extends Equatable { this.ownReactions, this.parentId, this.quotedMessage, - this.quotedMessageId, + String? quotedMessageId, this.replyCount = 0, this.threadParticipants, this.showInChannel, this.command, DateTime? createdAt, DateTime? updatedAt, + this.deletedAt, this.user, this.pinned = false, this.pinnedAt, DateTime? pinExpires, this.pinnedBy, this.extraData = const {}, - this.deletedAt, - this.status = MessageSendingStatus.sent, + this.status = MessageSendingStatus.sending, this.i18n, }) : id = id ?? const Uuid().v4(), pinExpires = pinExpires?.toUtc(), - createdAt = createdAt ?? DateTime.now(), - updatedAt = updatedAt ?? DateTime.now(); + _createdAt = createdAt, + _updatedAt = updatedAt, + _quotedMessageId = quotedMessageId; - /// Create a new instance from a json + /// Create a new instance from JSON. factory Message.fromJson(Map json) => _$MessageFromJson( Serializer.moveToExtraDataFromRoot(json, topLevelFields), + ).copyWith( + status: MessageSendingStatus.sent, ); /// The message ID. This is either created by Stream or set client side when /// the message is added. final String id; - /// The text of this message + /// The text of this message. final String? text; - /// The status of a sending message + /// The status of a sending message. @JsonKey(ignore: true) final MessageSendingStatus status; - /// The message type + /// The message type. @JsonKey( includeIfNull: false, toJson: Serializer.readOnly, @@ -107,15 +110,15 @@ class Message extends Equatable { @JsonKey(includeIfNull: false) final List attachments; - /// The list of user mentioned in the message + /// The list of user mentioned in the message. @JsonKey(toJson: User.toIds) final List mentionedUsers; - /// A map describing the count of number of every reaction + /// A map describing the count of number of every reaction. @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final Map? reactionCounts; - /// A map describing the count of score of every reaction + /// A map describing the count of score of every reaction. @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final Map? reactionScores; @@ -130,12 +133,14 @@ class Message extends Equatable { /// The ID of the parent message, if the message is a thread reply. final String? parentId; - /// A quoted reply message + /// A quoted reply message. @JsonKey(toJson: Serializer.readOnly) final Message? quotedMessage; + final String? _quotedMessageId; + /// The ID of the quoted message, if the message is a quoted reply. - final String? quotedMessageId; + String? get quotedMessageId => _quotedMessageId ?? quotedMessage?.id; /// Reserved field indicating the number of replies for this message. @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) @@ -148,10 +153,10 @@ class Message extends Equatable { /// Check if this message needs to show in the channel. final bool? showInChannel; - /// If true the message is silent + /// If true the message is silent. final bool silent; - /// If true the message is shadowed + /// If true the message is shadowed. @JsonKey( includeIfNull: false, toJson: Serializer.readOnly, @@ -162,56 +167,61 @@ class Message extends Equatable { @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final String? command; - /// Reserved field indicating when the message was created. - @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) - final DateTime createdAt; - - /// Reserved field indicating when the message was updated last time. - @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) - final DateTime updatedAt; - - /// User who sent the message - @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) - final User? user; - - /// If true the message is pinned - final bool pinned; - - /// Reserved field indicating when the message was pinned - @JsonKey(toJson: Serializer.readOnly) - final DateTime? pinnedAt; - - /// Reserved field indicating when the message will expire - /// - /// if `null` message has no expiry - final DateTime? pinExpires; - - /// Reserved field indicating who pinned the message - @JsonKey(toJson: Serializer.readOnly) - final User? pinnedBy; - - /// Message custom extraData - @JsonKey(includeIfNull: false) - final Map extraData; - - /// True if the message is a system info - bool get isSystem => type == 'system'; - - /// True if the message has been deleted - bool get isDeleted => type == 'deleted'; - - /// True if the message is ephemeral - bool get isEphemeral => type == 'ephemeral'; + final DateTime? _createdAt; /// Reserved field indicating when the message was deleted. @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final DateTime? deletedAt; + /// Reserved field indicating when the message was created. + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + DateTime get createdAt => _createdAt ?? DateTime.now(); + + final DateTime? _updatedAt; + + /// Reserved field indicating when the message was updated last time. + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + DateTime get updatedAt => _updatedAt ?? DateTime.now(); + + /// User who sent the message. + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + final User? user; + + /// If true the message is pinned. + final bool pinned; + + /// Reserved field indicating when the message was pinned. + @JsonKey(toJson: Serializer.readOnly) + final DateTime? pinnedAt; + + /// Reserved field indicating when the message will expire. + /// + /// If `null` message has no expiry. + final DateTime? pinExpires; + + /// Reserved field indicating who pinned the message. + @JsonKey(toJson: Serializer.readOnly) + final User? pinnedBy; + + /// Message custom extraData. + @JsonKey(includeIfNull: false) + final Map extraData; + + /// True if the message is a system info. + bool get isSystem => type == 'system'; + + /// True if the message has been deleted. + bool get isDeleted => type == 'deleted'; + + /// True if the message is ephemeral. + bool get isEphemeral => type == 'ephemeral'; + /// A Map of translations. @JsonKey(includeIfNull: false) final Map? i18n; /// Known top level fields. + /// /// Useful for [Serializer] methods. static const topLevelFields = [ 'id', @@ -244,7 +254,7 @@ class Message extends Equatable { 'i18n', ]; - /// Serialize to json + /// Serialize to json. Map toJson() => Serializer.moveFromExtraDataToRoot( _$MessageToJson(this), ); @@ -256,6 +266,8 @@ class Message extends Equatable { String? type, List? attachments, List? mentionedUsers, + bool? silent, + bool? shadowed, Map? reactionCounts, Map? reactionScores, List? latestReactions, @@ -266,8 +278,6 @@ class Message extends Equatable { int? replyCount, List? threadParticipants, bool? showInChannel, - bool? shadowed, - bool? silent, String? command, DateTime? createdAt, DateTime? updatedAt, @@ -295,30 +305,30 @@ class Message extends Equatable { type: type ?? this.type, attachments: attachments ?? this.attachments, mentionedUsers: mentionedUsers ?? this.mentionedUsers, + silent: silent ?? this.silent, + shadowed: shadowed ?? this.shadowed, reactionCounts: reactionCounts ?? this.reactionCounts, reactionScores: reactionScores ?? this.reactionScores, latestReactions: latestReactions ?? this.latestReactions, ownReactions: ownReactions ?? this.ownReactions, parentId: parentId ?? this.parentId, quotedMessage: quotedMessage ?? this.quotedMessage, - quotedMessageId: quotedMessageId ?? this.quotedMessageId, + quotedMessageId: quotedMessageId ?? _quotedMessageId, replyCount: replyCount ?? this.replyCount, threadParticipants: threadParticipants ?? this.threadParticipants, showInChannel: showInChannel ?? this.showInChannel, command: command ?? this.command, - createdAt: createdAt ?? this.createdAt, - silent: silent ?? this.silent, - extraData: extraData ?? this.extraData, - user: user ?? this.user, - shadowed: shadowed ?? this.shadowed, - updatedAt: updatedAt ?? this.updatedAt, + createdAt: createdAt ?? _createdAt, + updatedAt: updatedAt ?? _updatedAt, deletedAt: deletedAt ?? this.deletedAt, - status: status ?? this.status, + user: user ?? this.user, pinned: pinned ?? this.pinned, pinnedAt: pinnedAt ?? this.pinnedAt, - pinnedBy: pinnedBy ?? this.pinnedBy, pinExpires: pinExpires == _pinExpires ? this.pinExpires : pinExpires as DateTime?, + pinnedBy: pinnedBy ?? this.pinnedBy, + extraData: extraData ?? this.extraData, + status: status ?? this.status, i18n: i18n ?? this.i18n, ); } @@ -331,6 +341,8 @@ class Message extends Equatable { type: other.type, attachments: other.attachments, mentionedUsers: other.mentionedUsers, + silent: other.silent, + shadowed: other.shadowed, reactionCounts: other.reactionCounts, reactionScores: other.reactionScores, latestReactions: other.latestReactions, @@ -343,17 +355,15 @@ class Message extends Equatable { showInChannel: other.showInChannel, command: other.command, createdAt: other.createdAt, - silent: other.silent, - extraData: other.extraData, - user: other.user, - shadowed: other.shadowed, updatedAt: other.updatedAt, deletedAt: other.deletedAt, - status: other.status, + user: other.user, pinned: other.pinned, pinnedAt: other.pinnedAt, pinExpires: other.pinExpires, pinnedBy: other.pinnedBy, + extraData: other.extraData, + status: other.status, i18n: other.i18n, ); 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/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index 571d78f5..367b27af 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -7,6 +7,7 @@ export 'package:dio/src/options.dart'; export 'package:dio/src/options.dart' show ProgressCallback; export 'package:logging/logging.dart' show Logger, Level; export 'package:rate_limiter/rate_limiter.dart'; +export 'package:uuid/uuid.dart'; export './src/core/api/attachment_file_uploader.dart' show AttachmentFileUploader; diff --git a/packages/stream_chat/lib/version.dart b/packages/stream_chat/lib/version.dart index c839a929..ed6d9b09 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.1'; diff --git a/packages/stream_chat/pubspec.yaml b/packages/stream_chat/pubspec.yaml index 96096fd3..599b6ed8 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.1 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/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index c217431a..60e8dd93 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -244,9 +244,13 @@ void main() { group('`.sendMessage`', () { test('should work fine', () async { - final message = Message(id: 'test-message-id'); + final message = Message( + id: 'test-message-id', + user: client.state.currentUser, + ); - final sendMessageResponse = SendMessageResponse()..message = message; + final sendMessageResponse = SendMessageResponse() + ..message = message.copyWith(status: MessageSendingStatus.sent); when(() => client.sendMessage( any(that: isSameMessageAs(message)), @@ -329,6 +333,7 @@ void main() { .map((it) => it.copyWith(uploadState: const UploadState.success())) .toList(growable: false), + status: MessageSendingStatus.sent, )); expectLater( @@ -455,7 +460,10 @@ void main() { group('`.updateMessage`', () { test('should work fine', () async { - final message = Message(id: 'test-message-id'); + final message = Message( + id: 'test-message-id', + status: MessageSendingStatus.sent, + ); final updateMessageResponse = UpdateMessageResponse() ..message = message; @@ -530,6 +538,7 @@ void main() { any(that: isSameMessageAs(message)), )).thenAnswer((_) async => UpdateMessageResponse() ..message = message.copyWith( + status: MessageSendingStatus.sent, attachments: attachments .map((it) => it.copyWith(uploadState: const UploadState.success())) @@ -678,7 +687,7 @@ void main() { [ isSameMessageAs( updateMessageResponse.message.copyWith( - status: MessageSendingStatus.sent, + status: MessageSendingStatus.sending, ), matchText: true, matchSendingStatus: true, @@ -707,7 +716,10 @@ void main() { group('`.deleteMessage`', () { test('should work fine', () async { const messageId = 'test-message-id'; - final message = Message(id: messageId); + final message = Message( + id: messageId, + status: MessageSendingStatus.sent, + ); when(() => client.deleteMessage(messageId)) .thenAnswer((_) async => EmptyResponse()); @@ -1077,7 +1089,10 @@ void main() { group('`.sendReaction`', () { test('should work fine', () async { const type = 'test-reaction-type'; - final message = Message(id: 'test-message-id'); + final message = Message( + id: 'test-message-id', + status: MessageSendingStatus.sent, + ); final reaction = Reaction(type: type, messageId: message.id); @@ -1120,7 +1135,10 @@ void main() { 'should restore previous message if `client.sendReaction` throws', () async { const type = 'test-reaction-type'; - final message = Message(id: 'test-message-id'); + final message = Message( + id: 'test-message-id', + status: MessageSendingStatus.sent, + ); final reaction = Reaction(type: type, messageId: message.id); @@ -1181,6 +1199,7 @@ void main() { latestReactions: [prevReaction], reactionScores: const {prevType: 1}, reactionCounts: const {prevType: 1}, + status: MessageSendingStatus.sent, ); const type = 'test-reaction-type-2'; @@ -1212,7 +1231,7 @@ void main() { emitsInOrder([ [ isSameMessageAs( - newMessage.copyWith(status: MessageSendingStatus.sent), + newMessage, matchReactions: true, matchSendingStatus: true, ), @@ -1255,6 +1274,7 @@ void main() { latestReactions: [reaction], reactionScores: const {type: 1}, reactionCounts: const {type: 1}, + status: MessageSendingStatus.sent, ); when(() => client.deleteReaction(messageId, type)) @@ -1302,6 +1322,7 @@ void main() { latestReactions: [reaction], reactionScores: const {type: 1}, reactionCounts: const {type: 1}, + status: MessageSendingStatus.sent, ); when(() => client.deleteReaction(messageId, type)) diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 36c5919e..22e0edf8 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -1,20 +1,7 @@ import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat/src/client/client.dart'; import 'package:stream_chat/src/core/api/device_api.dart'; -import 'package:stream_chat/src/core/api/requests.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/token.dart'; -import 'package:stream_chat/src/core/models/channel_model.dart'; -import 'package:stream_chat/src/core/models/event.dart'; -import 'package:stream_chat/src/core/models/filter.dart'; -import 'package:stream_chat/src/core/models/message.dart'; -import 'package:stream_chat/src/core/models/own_user.dart'; -import 'package:stream_chat/src/core/models/user.dart'; -import 'package:stream_chat/src/event_type.dart'; -import 'package:stream_chat/src/ws/connection_status.dart'; import 'package:stream_chat/stream_chat.dart'; -import 'package:test/scaffolding.dart'; import 'package:test/test.dart'; import '../fakes.dart'; @@ -2314,6 +2301,33 @@ void main() { verifyNoMoreInteractions(api.message); }); + test('`.enrichUrl`', () async { + const url = + 'https://www.techyourchance.com/finite-state-machine-with-unit-tests-real-world-example'; + + when(() => api.general.enrichUrl(url)).thenAnswer( + (_) async => OGAttachmentResponse() + ..type = 'image' + ..ogScrapeUrl = url + ..authorName = 'TechYourChance' + ..title = 'Finite State Machine with Unit Tests: Real World Example', + ); + + final res = await client.enrichUrl(url); + + expect(res, isNotNull); + expect(res.type, 'image'); + expect(res.ogScrapeUrl, url); + expect(res.authorName, 'TechYourChance'); + expect( + res.title, + 'Finite State Machine with Unit Tests: Real World Example', + ); + + verify(() => api.general.enrichUrl(url)).called(1); + verifyNoMoreInteractions(api.general); + }); + test( '''setting the `currentUser` should also compute and update the unreadCounts''', () { diff --git a/packages/stream_chat/test/src/core/api/general_api_test.dart b/packages/stream_chat/test/src/core/api/general_api_test.dart index 570d7c77..0a27cce4 100644 --- a/packages/stream_chat/test/src/core/api/general_api_test.dart +++ b/packages/stream_chat/test/src/core/api/general_api_test.dart @@ -3,10 +3,6 @@ import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:mocktail/mocktail.dart'; import 'package:stream_chat/src/core/api/general_api.dart'; -import 'package:stream_chat/src/core/api/requests.dart'; -import 'package:stream_chat/src/core/models/channel_model.dart'; -import 'package:stream_chat/src/core/models/event.dart'; -import 'package:stream_chat/src/core/models/filter.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:test/test.dart'; @@ -281,4 +277,39 @@ void main() { verifyNoMoreInteractions(client); }); }); + + test('enrichUrl', () async { + const path = '/og'; + const url = + 'https://www.techyourchance.com/finite-state-machine-with-unit-tests-real-world-example'; + + when(() => client.get( + path, + queryParameters: {'url': url}, + )).thenAnswer((_) async => successResponse(path, data: { + 'type': 'image', + 'og_scrape_url': url, + 'author_name': 'TechYourChance', + 'title': 'Finite State Machine with Unit Tests: Real World Example', + })); + + final res = await generalApi.enrichUrl(url); + + expect(res, isNotNull); + expect(res.type, 'image'); + expect(res.ogScrapeUrl, url); + expect(res.authorName, 'TechYourChance'); + expect( + res.title, + 'Finite State Machine with Unit Tests: Real World Example', + ); + + verify( + () => client.get( + path, + queryParameters: {'url': url}, + ), + ).called(1); + verifyNoMoreInteractions(client); + }); } 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..c79ebeeb 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -6,18 +6,40 @@ - `MessageInput` now works with a `MessageInputController` instead of a `TextEditingController` +## 3.3.2 + +- Updated `stream_chat_flutter_core` dependency to [`3.3.1`](https://pub.dev/packages/stream_chat_flutter_core/changelog). + +## 3.3.1 + +✅ 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 +890,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_4.dart b/packages/stream_chat_flutter/example/lib/tutorial_part_4.dart index 5a05e4f9..ea9afa2f 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial_part_4.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial_part_4.dart @@ -126,7 +126,9 @@ class ThreadPage extends StatelessWidget { ), ), MessageInput( - parentMessage: parent, + messageInputController: MessageInputController( + message: Message(parentId: parent!.id), + ), ), ], ), 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..2555e007 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( @@ -165,7 +166,9 @@ class ThreadPage extends StatelessWidget { ), ), MessageInput( - parentMessage: parent, + messageInputController: MessageInputController( + message: Message(parentId: parent!.id), + ), ), ], ), diff --git a/packages/stream_chat_flutter/example/pubspec.yaml b/packages/stream_chat_flutter/example/pubspec.yaml index 9977c198..bd785a29 100644 --- a/packages/stream_chat_flutter/example/pubspec.yaml +++ b/packages/stream_chat_flutter/example/pubspec.yaml @@ -27,9 +27,12 @@ dependencies: cupertino_icons: ^1.0.3 flutter: sdk: flutter - stream_chat_flutter: ^2.2.1 - stream_chat_localizations: ^1.1.0 - stream_chat_persistence: ^2.2.0 + stream_chat_flutter: + path: ../ + stream_chat_localizations: + path: ../../stream_chat_localizations + stream_chat_persistence: + path: ../../stream_chat_persistence dev_dependencies: flutter_test: 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/localization/translations.dart b/packages/stream_chat_flutter/lib/src/localization/translations.dart index 8aa8d071..8039ac47 100644 --- a/packages/stream_chat_flutter/lib/src/localization/translations.dart +++ b/packages/stream_chat_flutter/lib/src/localization/translations.dart @@ -1,6 +1,6 @@ import 'package:jiffy/jiffy.dart'; import 'package:stream_chat_flutter/src/connection_status_builder.dart'; -import 'package:stream_chat_flutter/src/message_input.dart'; +import 'package:stream_chat_flutter/src/message_input/message_input.dart'; import 'package:stream_chat_flutter/src/message_list_view.dart'; import 'package:stream_chat_flutter/src/message_search_list_view.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart' diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal.dart index a67004af..e85e7cd8 100644 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_actions_modal.dart @@ -610,7 +610,9 @@ class _MessageActionsModalState extends State { widget.editMessageInputBuilder!(context, widget.message) else MessageInput( - editMessage: widget.message, + messageInputController: MessageInputController( + message: widget.message, + ), preMessageSending: (m) { FocusScope.of(context).unfocus(); Navigator.pop(context); diff --git a/packages/stream_chat_flutter/lib/src/mip/countdown_button.dart b/packages/stream_chat_flutter/lib/src/message_input/countdown_button.dart similarity index 100% rename from packages/stream_chat_flutter/lib/src/mip/countdown_button.dart rename to packages/stream_chat_flutter/lib/src/message_input/countdown_button.dart diff --git a/packages/stream_chat_flutter/lib/src/message_input.dart b/packages/stream_chat_flutter/lib/src/message_input/message_input.dart similarity index 83% rename from packages/stream_chat_flutter/lib/src/message_input.dart rename to packages/stream_chat_flutter/lib/src/message_input/message_input.dart index 61ac3ac6..caceba36 100644 --- a/packages/stream_chat_flutter/lib/src/message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/message_input.dart @@ -4,7 +4,6 @@ import 'dart:math'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:collection/collection.dart'; import 'package:file_picker/file_picker.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -14,20 +13,19 @@ import 'package:stream_chat_flutter/src/commands_overlay.dart'; import 'package:stream_chat_flutter/src/emoji/emoji.dart'; import 'package:stream_chat_flutter/src/emoji_overlay.dart'; import 'package:stream_chat_flutter/src/extension.dart'; -import 'package:stream_chat_flutter/src/message_list_view.dart'; import 'package:stream_chat_flutter/src/multi_overlay.dart'; import 'package:stream_chat_flutter/src/quoted_message_widget.dart'; -import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; -import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/user_mentions_overlay.dart'; import 'package:stream_chat_flutter/src/video_service.dart'; import 'package:stream_chat_flutter/src/video_thumbnail_image.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:video_compress/video_compress.dart'; export 'package:video_compress/video_compress.dart' show VideoQuality; +/// A function that returns true if the message is valid and can be sent. +typedef MessageValidator = bool Function(Message message); + /// A callback that can be passed to [MessageInput.onError]. /// /// This callback should not throw. @@ -42,13 +40,14 @@ typedef ErrorListener = void Function( /// /// This callback should not throw. /// -/// It exists merely for showing custom error, and should not be used otherwise. +/// It exists merely for showing a custom error, and should not be used +/// otherwise. typedef AttachmentLimitExceedListener = void Function( int limit, String error, ); -/// Builder for attachment thumbnails +/// Builder for attachment thumbnails. typedef AttachmentThumbnailBuilder = Widget Function( BuildContext, Attachment, @@ -77,8 +76,8 @@ typedef ActionButtonBuilder = Widget Function( IconButton defaultActionButton, ); -/// Widget builder for widgets that require may required data from the -/// [MessageInputController] +/// Widget builder for widgets that may require data from the +/// [MessageInputController]. typedef MessageRelatedBuilder = Widget Function( BuildContext context, MessageInputController messageInputController, @@ -91,7 +90,7 @@ typedef AttachmentsPickerBuilder = Widget Function( StreamAttachmentPicker defaultPicker, ); -/// Location for actions on the [MessageInput] +/// Location for actions on the [MessageInput]. enum ActionsLocation { /// Align to left left, @@ -106,7 +105,7 @@ enum ActionsLocation { rightInside, } -/// Default attachments for widget +/// Default attachments for widget. enum DefaultAttachmentTypes { /// Image Attachment image, @@ -118,7 +117,7 @@ enum DefaultAttachmentTypes { file, } -/// Available locations for the sendMessage button relative to the textField +/// Available locations for the `sendMessage` button relative to the textField. enum SendButtonLocation { /// inside the textField inside, @@ -131,17 +130,17 @@ const _kMinMediaPickerSize = 360.0; const _kDefaultMaxAttachmentSize = 20971520; // 20MB in Bytes -/// Inactive state +/// Inactive state: /// /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_input.png) /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_input_paint.png) /// -/// Focused state +/// Focused state: /// /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_input2.png) /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_input2_paint.png) /// -/// Widget used to enter the message and add attachments +/// Widget used to enter a message and add attachments: /// /// ```dart /// class ChannelPage extends StatelessWidget { @@ -176,20 +175,16 @@ const _kDefaultMaxAttachmentSize = 20971520; // 20MB in Bytes /// as the bottom widget. /// /// The widget renders the ui based on the first ancestor of -/// type [StreamChatTheme]. -/// Modify it to change the widget appearance. +/// type [StreamChatTheme]. Modify it to change the widget appearance. class MessageInput extends StatefulWidget { /// Instantiate a new MessageInput const MessageInput({ Key? key, this.onMessageSent, this.preMessageSending, - this.parentMessage, - this.editMessage, this.maxHeight = 150, this.keyboardType = TextInputType.multiline, this.disableAttachments = false, - this.initialMessage, this.messageInputController, this.actions = const [], this.actionsLocation = ActionsLocation.left, @@ -218,76 +213,67 @@ class MessageInput extends StatefulWidget { this.mentionAllAppUsers = false, this.attachmentsPickerBuilder, this.sendButtonBuilder, - }) : assert( - initialMessage == null || editMessage == null, - "Can't provide both `initialMessage` and `editMessage`", - ), - super(key: key); + this.shouldKeepFocusAfterMessage, + this.validator = _defaultValidator, + this.restorationId, + }) : super(key: key); - /// List of options for showing overlays + /// List of options for showing overlays. final List customOverlays; - /// Message to edit - final Message? editMessage; - - /// Video quality to use when compressing the videos + /// Video quality to use when compressing the videos. final VideoQuality compressedVideoQuality; - /// Frame rate to use when compressing the videos + /// Frame rate to use when compressing the videos. final int compressedVideoFrameRate; - /// Max attachment size in bytes - /// Defaults to 20 MB - /// do not set it if you're using our default CDN + /// Max attachment size in bytes: + /// - Defaults to 20 MB + /// - Do not set it if you're using our default CDN final int maxAttachmentSize; - /// Message to start with - final Message? initialMessage; - - /// Function called after sending the message + /// Function called after sending the message. final void Function(Message)? onMessageSent; - /// Function called right before sending the message - /// Use this to transform the message + /// Function called right before sending the message. + /// + /// Use this to transform the message. final FutureOr Function(Message)? preMessageSending; - /// Parent message in case of a thread - final Message? parentMessage; - - /// Maximum Height for the TextField to grow before it starts scrolling + /// Maximum Height for the TextField to grow before it starts scrolling. final double maxHeight; - /// The keyboard type assigned to the TextField + /// The keyboard type assigned to the TextField. final TextInputType keyboardType; - /// If true the attachments button will not be displayed + /// If true the attachments button will not be displayed. final bool disableAttachments; - /// Use this property to hide/show the commands button + /// Use this property to hide/show the commands button. final bool showCommandsButton; - /// Hide send as dm checkbox + /// Hide send as dm checkbox. final bool hideSendAsDm; - /// The text controller of the TextField + /// The text controller of the TextField. final MessageInputController? messageInputController; - /// List of action widgets + /// List of action widgets. final List actions; - /// The location of the custom actions + /// The location of the custom actions. final ActionsLocation actionsLocation; - /// Map that defines a thumbnail builder for an attachment type + /// Map that defines a thumbnail builder for an attachment type. final Map? attachmentThumbnailBuilders; - /// The focus node associated to the TextField + /// The focus node associated to the TextField. final FocusNode? focusNode; - /// + /// The message that is being quoted. final Message? quotedMessage; - /// + /// Callback invoked when the quoted message is cleared. final VoidCallback? onQuotedMessageCleared; /// The location of the send button @@ -342,6 +328,19 @@ 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; + + /// A callback function that validates the message. + final MessageValidator validator; + + /// Restoration ID to save and restore the state of the MessageInput. + final String? restorationId; + + static bool _defaultValidator(Message message) => + message.text?.isNotEmpty == true || message.attachments.isNotEmpty; + @override MessageInputState createState() => MessageInputState(); @@ -358,40 +357,77 @@ class MessageInput extends StatefulWidget { } /// State of [MessageInput] -class MessageInputState extends State { +class MessageInputState extends State + with RestorationMixin { final _imagePicker = ImagePicker(); late final _focusNode = widget.focusNode ?? FocusNode(); bool _inputEnabled = true; - bool _commandEnabled = false; + bool get _commandEnabled => _effectiveController.value.command != null; bool _showCommandsOverlay = false; bool _showMentionsOverlay = false; - Command? _chosenCommand; bool _actionsShrunk = false; bool _openFilePickerSection = false; - /// The editing controller passed to the input TextField - late final MessageInputController messageInputController = - widget.messageInputController ?? MessageInputController(); - late StreamChatThemeData _streamChatTheme; late MessageInputThemeData _messageInputTheme; - bool get _hasQuotedMessage => widget.quotedMessage != null; + bool get _hasQuotedMessage => + _effectiveController.value.quotedMessage != null; - bool get _messageIsPresent => messageInputController.text.trim().isNotEmpty; + bool get _isEditing => + _effectiveController.value.status != MessageSendingStatus.sending; + + RestorableMessageInputController? _controller; + MessageInputController get _effectiveController => + widget.messageInputController ?? _controller!.value; + + void _createLocalController([Message? message]) { + assert(_controller == null, ''); + _controller = RestorableMessageInputController(message: message); + print('_controller?.value: ${_controller?.value}'); + } + + void _registerController() { + assert(_controller != null, ''); + registerForRestoration(_controller!, 'messageInputController'); + } @override void initState() { super.initState(); - if (widget.editMessage != null || widget.initialMessage != null) { - _parseExistingMessage(widget.editMessage ?? widget.initialMessage!); + if (widget.messageInputController == null) { + _createLocalController(); + print('_controller?.value: ${_controller?.value}'); } - messageInputController.textEditingController - .addListener(_onChangedDebounced); + _effectiveController.textEditingController.addListener(_onChangedDebounced); _focusNode.addListener(_focusNodeListener); } + @override + void didUpdateWidget(covariant MessageInput oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.messageInputController == null && + oldWidget.messageInputController != null) { + _createLocalController(oldWidget.messageInputController!.value); + } else if (widget.messageInputController != null && + oldWidget.messageInputController == null) { + unregisterFromRestoration(_controller!); + _controller!.dispose(); + _controller = null; + } + } + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + if (_controller != null) { + _registerController(); + } + } + + @override + String? get restorationId => widget.restorationId; + void _focusNodeListener() { if (_focusNode.hasFocus) { _openFilePickerSection = false; @@ -430,9 +466,9 @@ class MessageInputState extends State { @override Widget build(BuildContext context) { - Widget child = ValueListenableBuilder( - valueListenable: messageInputController, - builder: (context, value, wid) => DecoratedBox( + Widget child = MessageValueListenableBuilder( + valueListenable: _effectiveController, + builder: (context, value, _) => DecoratedBox( decoration: BoxDecoration( color: _messageInputTheme.inputBackgroundColor, ), @@ -479,7 +515,8 @@ class MessageInputState extends State { padding: const EdgeInsets.symmetric(vertical: 8), child: _buildTextField(context), ), - if (widget.parentMessage != null && !widget.hideSendAsDm) + if (_effectiveController.value.parentId != null && + !widget.hideSendAsDm) Padding( padding: const EdgeInsets.only( right: 12, @@ -495,7 +532,7 @@ class MessageInputState extends State { ), ), ); - if (widget.editMessage == null) { + if (_isEditing) { child = Material( elevation: 8, child: child, @@ -512,12 +549,12 @@ class MessageInputState extends State { ), OverlayOptions( visible: _focusNode.hasFocus && - messageInputController.text.isNotEmpty && - messageInputController.baseOffset > 0 && - messageInputController.text + _effectiveController.text.isNotEmpty && + _effectiveController.baseOffset > 0 && + _effectiveController.text .substring( 0, - messageInputController.baseOffset, + _effectiveController.baseOffset, ) .contains(':'), widget: _buildEmojiOverlay(), @@ -553,7 +590,7 @@ class MessageInputState extends State { height: 16, width: 16, foregroundDecoration: BoxDecoration( - border: messageInputController.showInChannel + border: _effectiveController.showInChannel ? null : Border.all( color: _streamChatTheme.colorTheme.textHighEmphasis @@ -565,20 +602,18 @@ class MessageInputState extends State { child: Center( child: Material( borderRadius: BorderRadius.circular(3), - color: messageInputController.showInChannel + color: _effectiveController.showInChannel ? _streamChatTheme.colorTheme.accentPrimary : _streamChatTheme.colorTheme.barsBg, child: InkWell( onTap: () { - setState(() { - messageInputController.showInChannel = - !messageInputController.showInChannel; - }); + _effectiveController.showInChannel = + !_effectiveController.showInChannel; }, child: AnimatedCrossFade( duration: const Duration(milliseconds: 300), reverseDuration: const Duration(milliseconds: 300), - crossFadeState: messageInputController.showInChannel + crossFadeState: _effectiveController.showInChannel ? CrossFadeState.showFirst : CrossFadeState.showSecond, firstChild: StreamSvgIcon.check( @@ -609,14 +644,14 @@ class MessageInputState extends State { Widget _buildSendButton(BuildContext context) { if (widget.sendButtonBuilder != null) { - return widget.sendButtonBuilder!(context, messageInputController); + return widget.sendButtonBuilder!(context, _effectiveController); } return StreamMessageSendButton( onSendMessage: sendMessage, timeOut: _timeOut, - isIdle: !_messageIsPresent && messageInputController.attachments.isEmpty, - isEditEnabled: widget.editMessage != null, + isIdle: !widget.validator(_effectiveController.message), + isEditEnabled: _isEditing, idleSendButton: widget.idleSendButton, activeSendButton: widget.activeSendButton, ); @@ -663,7 +698,7 @@ class MessageInputState extends State { if (!widget.disableAttachments) _buildAttachmentButton(context), if (widget.showCommandsButton && - widget.editMessage == null && + !_isEditing && channel.state != null && channel.config?.commands.isNotEmpty == true) _buildCommandButton(context), @@ -714,7 +749,7 @@ class MessageInputState extends State { maxLines: null, onSubmitted: (_) => sendMessage(), keyboardType: widget.keyboardType, - controller: messageInputController, + controller: _effectiveController, focusNode: _focusNode, style: _messageInputTheme.inputTextStyle, autofocus: widget.autofocus, @@ -786,7 +821,7 @@ class MessageInputState extends State { size: 16, ), Text( - _chosenCommand?.name.toUpperCase() ?? '', + _effectiveController.value.command!.toUpperCase(), style: _streamChatTheme.textTheme.footnoteBold.copyWith( color: Colors.white, @@ -820,9 +855,7 @@ class MessageInputState extends State { height: 24, width: 24, ), - onPressed: () { - setState(() => _commandEnabled = false); - }, + onPressed: _effectiveController.clear, ), ), if (!_commandEnabled && @@ -837,14 +870,16 @@ class MessageInputState extends State { late final _onChangedDebounced = debounce( () { - var value = messageInputController.text; + var value = _effectiveController.text; if (!mounted) return; value = value.trim(); final channel = StreamChannel.of(context).channel; if (value.isNotEmpty) { - // ignore: no-empty-block - channel.keyStroke(widget.parentMessage?.id).catchError((e) {}); + channel + .keyStroke(_effectiveController.value.parentId) + // ignore: no-empty-block + .catchError((e) {}); } var actionsLength = widget.actions.length; @@ -864,10 +899,10 @@ class MessageInputState extends State { ); String _getHint(BuildContext context) { - if (_commandEnabled && _chosenCommand!.name == 'giphy') { + if (_commandEnabled && _effectiveController.value.command == 'giphy') { return context.translations.searchGifLabel; } - if (messageInputController.attachments.isNotEmpty) { + if (_effectiveController.attachments.isNotEmpty) { return context.translations.addACommentOrSendLabel; } if (_timeOut != 0) { @@ -879,16 +914,16 @@ class MessageInputState extends State { void _checkEmoji(String s, BuildContext context) { if (s.isNotEmpty && - messageInputController.baseOffset > 0 && - messageInputController.text + _effectiveController.baseOffset > 0 && + _effectiveController.text .substring( 0, - messageInputController.baseOffset, + _effectiveController.baseOffset, ) .contains(':')) { - final textToSelection = messageInputController.text.substring( + final textToSelection = _effectiveController.text.substring( 0, - messageInputController.selectionStart, + _effectiveController.selectionStart, ); final splits = textToSelection.split(':'); final query = splits[splits.length - 2].toLowerCase(); @@ -902,11 +937,11 @@ class MessageInputState extends State { void _checkMentions(String s, BuildContext context) { if (s.isNotEmpty && - messageInputController.baseOffset > 0 && - messageInputController.text + _effectiveController.baseOffset > 0 && + _effectiveController.text .substring( 0, - messageInputController.baseOffset, + _effectiveController.baseOffset, ) .split(' ') .last @@ -943,7 +978,7 @@ class MessageInputState extends State { } Widget _buildCommandsOverlayEntry() { - final text = messageInputController.text.trimLeft(); + final text = _effectiveController.text.trimLeft(); final renderObject = context.findRenderObject() as RenderBox?; if (renderObject == null) { @@ -959,7 +994,7 @@ class MessageInputState extends State { Widget _buildFilePickerSection() { final picker = StreamAttachmentPicker( - messageInputController: messageInputController, + messageInputController: _effectiveController, onFilePicked: pickFile, isOpen: _openFilePickerSection, pickerSize: _openFilePickerSection ? _kMinMediaPickerSize : 0, @@ -968,18 +1003,13 @@ class MessageInputState extends State { maxAttachmentSize: widget.maxAttachmentSize, compressedVideoQuality: widget.compressedVideoQuality, compressedVideoFrameRate: widget.compressedVideoFrameRate, - onChangeInputState: (val) { - setState(() { - _inputEnabled = val; - }); - }, onError: _showErrorAlert, ); if (_openFilePickerSection && widget.attachmentsPickerBuilder != null) { return widget.attachmentsPickerBuilder!( context, - messageInputController, + _effectiveController, picker, ); } @@ -988,14 +1018,15 @@ class MessageInputState extends State { } Widget _buildMentionsOverlayEntry() { - if (messageInputController.selectionStart < 0) { + final channel = StreamChannel.of(context).channel; + if (_effectiveController.selectionStart < 0 || channel.state == null) { return const Offstage(); } - final splits = messageInputController.text + final splits = _effectiveController.text .substring( 0, - messageInputController.selectionStart, + _effectiveController.selectionStart, ) .split('@'); final query = splits.last.toLowerCase(); @@ -1020,23 +1051,19 @@ 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) { - messageInputController.addMentionedUser(user); + _effectiveController.addMentionedUser(user); splits[splits.length - 1] = user.name; final rejoin = splits.join('@'); - messageInputController.textEditingController.value = TextEditingValue( - text: rejoin + - messageInputController.text.substring( - messageInputController.selectionStart, - ), - selection: TextSelection.collapsed( - offset: rejoin.length, - ), - ); + _effectiveController.text = rejoin + + _effectiveController.text.substring( + _effectiveController.selectionStart, + ); + _onChangedDebounced.cancel(); setState(() => _showMentionsOverlay = false); }, @@ -1044,14 +1071,14 @@ class MessageInputState extends State { } Widget _buildEmojiOverlay() { - if (messageInputController.baseOffset < 0) { + if (_effectiveController.baseOffset < 0) { return const Offstage(); } - final splits = messageInputController.text + final splits = _effectiveController.text .substring( 0, - messageInputController.baseOffset, + _effectiveController.baseOffset, ) .split(':'); @@ -1071,22 +1098,17 @@ class MessageInputState extends State { void _chooseEmoji(List splits, Emoji emoji) { final rejoin = splits.sublist(0, splits.length - 1).join(':') + emoji.char!; - messageInputController.textEditingController.value = TextEditingValue( - text: rejoin + - messageInputController.text.substring( - messageInputController.selectionStart, - ), - selection: TextSelection.collapsed( - offset: rejoin.length, - ), - ); + _effectiveController.text = rejoin + + _effectiveController.text.substring( + _effectiveController.selectionStart, + ); } void _setCommand(Command c) { - messageInputController.clear(); + _effectiveController + ..clear() + ..command = c; setState(() { - _chosenCommand = c; - _commandEnabled = true; _showCommandsOverlay = false; }); } @@ -1105,11 +1127,11 @@ class MessageInputState extends State { } Widget _buildAttachments() { - if (messageInputController.attachments.isEmpty) return const Offstage(); - final fileAttachments = messageInputController.attachments + if (_effectiveController.attachments.isEmpty) return const Offstage(); + final fileAttachments = _effectiveController.attachments .where((it) => it.type == 'file') .toList(growable: false); - final remainingAttachments = messageInputController.attachments + final remainingAttachments = _effectiveController.attachments .where((it) => it.type != 'file') .toList(growable: false); return Column( @@ -1127,9 +1149,7 @@ class MessageInputState extends State { (e) => ClipRRect( borderRadius: BorderRadius.circular(10), child: FileAttachment( - message: Message( - status: MessageSendingStatus.sending, - ), // dummy message + message: Message(), // dummy message attachment: e, size: Size( MediaQuery.of(context).size.width * 0.65, @@ -1196,9 +1216,10 @@ class MessageInputState extends State { focusElevation: 0, hoverElevation: 0, onPressed: () { - setState( - () => messageInputController.attachments - .removeWhere((e) => e.id == attachment.id), + _effectiveController.value = _effectiveController.value.copyWith( + attachments: _effectiveController.attachments + .where((it) => it.id != attachment.id) + .toList(), ); }, fillColor: @@ -1278,7 +1299,7 @@ class MessageInputState extends State { } Widget _buildCommandButton(BuildContext context) { - final s = messageInputController.text.trim(); + final s = _effectiveController.text.trim(); final defaultButton = IconButton( icon: StreamSvgIcon.lightning( color: s.isNotEmpty @@ -1406,14 +1427,13 @@ class MessageInputState extends State { /// /// Note: Only meant to be used from outside the state. void addAttachment(Attachment attachment) { - setState(() => _addAttachments([attachment])); + _addAttachments([attachment]); } /// Adds an attachment to the [messageInputController.attachments] map void _addAttachments(Iterable attachments) { final limit = widget.attachmentLimit; - final length = - messageInputController.attachments.length + attachments.length; + final length = _effectiveController.attachments.length + attachments.length; if (length > limit) { final onAttachmentLimitExceed = widget.onAttachmentLimitExceed; if (onAttachmentLimitExceed != null) { @@ -1427,7 +1447,7 @@ class MessageInputState extends State { ); } for (final attachment in attachments) { - messageInputController.addAttachment(attachment); + _effectiveController.addAttachment(attachment); } } @@ -1476,7 +1496,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; @@ -1534,70 +1553,26 @@ class MessageInputState extends State { } } - setState(() { - _addAttachments([ - attachment.copyWith( - file: file, - extraData: {...attachment.extraData} - ..update('file_size', ((_) => file!.size!)), - ), - ]); - }); + _addAttachments([ + attachment.copyWith( + file: file, + extraData: {...attachment.extraData} + ..update('file_size', ((_) => file!.size!)), + ), + ]); } /// Sends the current message Future sendMessage() async { - var text = messageInputController.text.trim(); - final attachments = messageInputController.attachments; + var message = _effectiveController.value; - if (text.isEmpty && attachments.isEmpty) { - return; - } + var shouldKeepFocus = widget.shouldKeepFocusAfterMessage; - final shouldUnfocus = _commandEnabled; + shouldKeepFocus ??= !_commandEnabled; - if (_commandEnabled) { - text = '${'/${_chosenCommand!.name} '}$text'; - } - - messageInputController - ..text = '' - ..clearAttachments(); + _effectiveController.reset(); widget.onQuotedMessageCleared?.call(); - setState(() { - _commandEnabled = false; - }); - - Message message; - if (widget.editMessage != null) { - message = widget.editMessage!.copyWith( - text: text, - attachments: attachments, - mentionedUsers: messageInputController.mentionedUsers - .where((u) => text.contains('@${u.name}')) - .toList(), - ); - } else { - message = (widget.initialMessage ?? Message()).copyWith( - parentId: widget.parentMessage?.id, - text: text, - attachments: attachments, - mentionedUsers: messageInputController.mentionedUsers - .where((u) => text.contains('@${u.name}')) - .toList(), - showInChannel: widget.parentMessage != null - ? messageInputController.showInChannel - : null, - ); - } - - if (widget.quotedMessage != null) { - message = message.copyWith( - quotedMessageId: widget.quotedMessage!.id, - ); - } - if (widget.preMessageSending != null) { message = await widget.preMessageSending!(message); } @@ -1608,25 +1583,23 @@ class MessageInputState extends State { await streamChannel.reloadChannel(); } - messageInputController.clearMentionedUsers(); - try { Future sendingFuture; - if (widget.editMessage == null || - widget.editMessage!.status == MessageSendingStatus.failed || - widget.editMessage!.status == MessageSendingStatus.sending) { + if (!_isEditing) { sendingFuture = channel.sendMessage(message); } else { sendingFuture = channel.updateMessage(message); } - if (!shouldUnfocus) { + if (shouldKeepFocus) { FocusScope.of(context).requestFocus(_focusNode); + } else { + FocusScope.of(context).unfocus(); } final resp = await sendingFuture; if (resp.message?.type == 'error') { - _parseExistingMessage(message); + _effectiveController.value = message; } _startSlowMode(); widget.onMessageSent?.call(resp.message); @@ -1705,36 +1678,23 @@ class MessageInputState extends State { ); } - void _parseExistingMessage(Message message) { - final messageText = message.text; - if (messageText != null) messageInputController.text = messageText; - _addAttachments(message.attachments); - } - @override void dispose() { - messageInputController.textEditingController + _effectiveController.textEditingController .removeListener(_onChangedDebounced); - messageInputController.dispose(); + _controller?.dispose(); _focusNode.removeListener(_focusNodeListener); _stopSlowMode(); _onChangedDebounced.cancel(); super.dispose(); } - bool _initialized = false; - @override void didChangeDependencies() { _streamChatTheme = StreamChatTheme.of(context); _messageInputTheme = MessageInputTheme.of(context); - if (widget.editMessage == null && _timeOut <= 0) _startSlowMode(); + if (!_isEditing && _timeOut <= 0) _startSlowMode(); - if ((widget.editMessage != null || widget.initialMessage != null) && - !_initialized) { - FocusScope.of(context).requestFocus(_focusNode); - _initialized = true; - } super.didChangeDependencies(); } } diff --git a/packages/stream_chat_flutter/lib/src/mip/stream_attachment_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_attachment_picker.dart similarity index 96% rename from packages/stream_chat_flutter/lib/src/mip/stream_attachment_picker.dart rename to packages/stream_chat_flutter/lib/src/message_input/stream_attachment_picker.dart index f075a09c..7f6a6f6e 100644 --- a/packages/stream_chat_flutter/lib/src/mip/stream_attachment_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_attachment_picker.dart @@ -21,30 +21,41 @@ typedef CustomAttachmentIconBuilder = Widget Function( bool active, ); -/// +/// A widget that allows to pick an attachment. class StreamAttachmentPicker extends StatefulWidget { + /// True if the picker is open. final bool isOpen; + + /// The picker size in height. final double pickerSize; + + /// The [MessageInputController] linked to this picker. final MessageInputController messageInputController; + + /// The limit of attachments that can be picked. final int attachmentLimit; + + /// The callback for when the attachment limit is exceeded. final AttachmentLimitExceedListener? onAttachmentLimitExceeded; - final ValueChanged? onChangeInputState; + final ValueChanged? onError; final FilePickerCallback onFilePicked; - /// Video quality to use when compressing the videos + /// Video quality to use when compressing the videos. final VideoQuality compressedVideoQuality; - /// Frame rate to use when compressing the videos + /// Frame rate to use when compressing the videos. final int compressedVideoFrameRate; - /// Max attachment size in bytes - /// Defaults to 20 MB - /// do not set it if you're using our default CDN + /// Max attachment size in bytes: + /// - Defaults to 20 MB + /// - Do not set it if you're using our default CDN final int maxAttachmentSize; + /// The list of attachment types that can be picked. final List allowedAttachmentTypes; + /// The list of custom attachment types that can be picked. final List customAttachmentTypes; const StreamAttachmentPicker({ @@ -58,7 +69,6 @@ class StreamAttachmentPicker extends StatefulWidget { this.maxAttachmentSize = 20971520, this.compressedVideoQuality = VideoQuality.DefaultQuality, this.compressedVideoFrameRate = 30, - this.onChangeInputState, this.onError, this.allowedAttachmentTypes = const [ DefaultAttachmentTypes.image, @@ -99,7 +109,6 @@ class StreamAttachmentPicker extends StatefulWidget { compressedVideoQuality ?? this.compressedVideoQuality, compressedVideoFrameRate: compressedVideoFrameRate ?? this.compressedVideoFrameRate, - onChangeInputState: onChangeInputState ?? this.onChangeInputState, onError: onError ?? this.onError, allowedAttachmentTypes: allowedAttachmentTypes ?? this.allowedAttachmentTypes, @@ -116,8 +125,8 @@ class _StreamAttachmentPickerState extends State { @override Widget build(BuildContext context) { - var _streamChatTheme = StreamChatTheme.of(context); - var messageInputController = widget.messageInputController; + final _streamChatTheme = StreamChatTheme.of(context); + final messageInputController = widget.messageInputController; final _attachmentContainsImage = messageInputController.attachments.any((it) => it.type == 'image'); diff --git a/packages/stream_chat_flutter/lib/src/mip/stream_message_send_button.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_send_button.dart similarity index 76% rename from packages/stream_chat_flutter/lib/src/mip/stream_message_send_button.dart rename to packages/stream_chat_flutter/lib/src/message_input/stream_message_send_button.dart index 5443fb18..9916f6f7 100644 --- a/packages/stream_chat_flutter/lib/src/mip/stream_message_send_button.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_send_button.dart @@ -1,15 +1,11 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +/// A widget that displays a sending button. class StreamMessageSendButton extends StatelessWidget { - final int timeOut; - final bool isIdle; - final bool isCommandEnabled; - final bool isEditEnabled; - final Widget? idleSendButton; - final Widget? activeSendButton; - final VoidCallback onSendMessage; - + /// Returns a [StreamMessageSendButton] with the given [timeOut], [isIdle], + /// [isCommandEnabled], [isEditEnabled], [idleSendButton], [activeSendButton], + /// [onSendMessage]. const StreamMessageSendButton({ Key? key, this.timeOut = 0, @@ -21,9 +17,30 @@ class StreamMessageSendButton extends StatelessWidget { required this.onSendMessage, }) : super(key: key); + /// Time out related to slow mode. + final int timeOut; + + /// If true the button will be disabled. + final bool isIdle; + + /// True if a command is being sent. + final bool isCommandEnabled; + + /// True if in editing mode. + final bool isEditEnabled; + + /// The widget to display when the button is disabled. + final Widget? idleSendButton; + + /// The widget to display when the button is enabled. + final Widget? activeSendButton; + + /// The callback to call when the button is pressed. + final VoidCallback onSendMessage; + @override Widget build(BuildContext context) { - var _streamChatTheme = StreamChatTheme.of(context); + final _streamChatTheme = StreamChatTheme.of(context); late Widget sendButton; if (timeOut > 0) { @@ -46,7 +63,7 @@ class StreamMessageSendButton extends StatelessWidget { } Widget _buildIdleSendButton(BuildContext context) { - var _messageInputTheme = MessageInputTheme.of(context); + final _messageInputTheme = MessageInputTheme.of(context); return Padding( padding: const EdgeInsets.all(8), @@ -58,7 +75,7 @@ class StreamMessageSendButton extends StatelessWidget { } Widget _buildSendButton(BuildContext context) { - var _messageInputTheme = MessageInputTheme.of(context); + final _messageInputTheme = MessageInputTheme.of(context); return Padding( padding: const EdgeInsets.all(8), diff --git a/packages/stream_chat_flutter/lib/src/mip/stream_message_text_field.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_text_field.dart similarity index 92% rename from packages/stream_chat_flutter/lib/src/mip/stream_message_text_field.dart rename to packages/stream_chat_flutter/lib/src/message_input/stream_message_text_field.dart index 43acce31..af2548a4 100644 --- a/packages/stream_chat_flutter/lib/src/mip/stream_message_text_field.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_text_field.dart @@ -1,3 +1,5 @@ +// ignore_for_file: prefer-trailing-comma, cascade_invocations + import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle; import 'package:flutter/cupertino.dart'; @@ -16,11 +18,12 @@ export 'package:flutter/services.dart' SmartQuotesType, SmartDashesType; +/// A widget the wraps the [TextField] and adds some StreamChat specifics. class StreamMessageTextField extends StatefulWidget { /// Creates a Material Design text field. /// - /// If [decoration] is non-null (which is the default), the text field requires - /// one of its ancestors to be a [Material] widget. + /// If [decoration] is non-null (which is the default), the text field + /// requires one of its ancestors to be a [Material] widget. /// /// To remove the decoration entirely (including the extra padding introduced /// by the decoration to save space for the labels), set the [decoration] to @@ -45,10 +48,6 @@ class StreamMessageTextField extends StatefulWidget { /// which is evaluated after the supplied [inputFormatters], if any. /// The [maxLength] value must be either null or greater than zero. /// - /// If [maxLengthEnforced] is set to false, then more than [maxLength] - /// characters may be entered, and the error counter and divider will - /// switch to the [decoration].errorStyle when the limit is exceeded. - /// /// The text cursor is not shown if [showCursor] is false or if [showCursor] /// is null (the default) and [readOnly] is true. /// @@ -58,7 +57,7 @@ class StreamMessageTextField extends StatefulWidget { /// must not be null. /// /// The [textAlign], [autofocus], [obscureText], [readOnly], [autocorrect], - /// [maxLengthEnforced], [scrollPadding], [maxLines], [maxLength], + /// [scrollPadding], [maxLines], [maxLength], /// [selectionHeightStyle], [selectionWidthStyle], [enableSuggestions], and /// [enableIMEPersonalizedLearning] arguments must not be null. /// @@ -113,7 +112,7 @@ class StreamMessageTextField extends StatefulWidget { this.selectionHeightStyle = ui.BoxHeightStyle.tight, this.selectionWidthStyle = ui.BoxWidthStyle.tight, this.keyboardAppearance, - this.scrollPadding = const EdgeInsets.all(20.0), + this.scrollPadding = const EdgeInsets.all(20), this.dragStartBehavior = DragStartBehavior.start, this.enableInteractiveSelection = true, this.selectionControls, @@ -125,51 +124,44 @@ class StreamMessageTextField extends StatefulWidget { this.autofillHints, this.restorationId, this.enableIMEPersonalizedLearning = true, - }) : assert(textAlign != null), - assert(readOnly != null), - assert(autofocus != null), - assert(obscuringCharacter != null && obscuringCharacter.length == 1), - assert(obscureText != null), - assert(autocorrect != null), + }) : assert(obscuringCharacter.length == 1, + '`obscuringCharacter.length` must be 1'), smartDashesType = smartDashesType ?? (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled), smartQuotesType = smartQuotesType ?? (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled), - assert(enableSuggestions != null), - assert(enableInteractiveSelection != null), - assert(maxLengthEnforced != null), assert( maxLengthEnforced || maxLengthEnforcement == null, 'maxLengthEnforced is deprecated, use only maxLengthEnforcement', ), - assert(scrollPadding != null), - assert(dragStartBehavior != null), - assert(selectionHeightStyle != null), - assert(selectionWidthStyle != null), - assert(maxLines == null || maxLines > 0), - assert(minLines == null || minLines > 0), + assert(maxLines == null || maxLines > 0, + '`maxLines` needs to be left as null or bigger than 0'), + assert(minLines == null || minLines > 0, + '`minLines` needs to be left as null or bigger than 0'), assert( (maxLines == null) || (minLines == null) || (maxLines >= minLines), "minLines can't be greater than maxLines", ), - assert(expands != null), assert( !expands || (maxLines == null && minLines == null), 'minLines and maxLines must be null when expands is true.', ), assert(!obscureText || maxLines == 1, 'Obscured fields cannot be multiline.'), - assert(maxLength == null || - maxLength == TextField.noMaxLength || - maxLength > 0), - // Assert the following instead of setting it directly to avoid surprising the user by silently changing the value they set. + assert( + maxLength == null || + maxLength == TextField.noMaxLength || + maxLength > 0, + '`maxLength` needs to be null or a positive integer'), + + // Assert the following instead of setting it directly to avoid + // surprising the user by silently changing the value they set. assert( !identical(textInputAction, TextInputAction.newline) || maxLines == 1 || !identical(keyboardType, TextInputType.text), - 'Use keyboardType TextInputType.multiline when using TextInputAction.newline on a multiline TextField.', + '''Use keyboardType TextInputType.multiline when using TextInputAction.newline on a multiline TextField.''', ), - assert(enableIMEPersonalizedLearning != null), keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline), toolbarOptions = toolbarOptions ?? @@ -228,7 +220,8 @@ class StreamMessageTextField extends StatefulWidget { /// cause the focus to change, and will not make the keyboard visible. /// /// This widget builds an [EditableText] and will ensure that the keyboard is - /// showing when it is tapped by calling [EditableTextState.requestKeyboard()]. + /// showing when it is tapped by calling + /// [EditableTextState.requestKeyboard()]. final FocusNode? focusNode; /// The decoration to show around the text field. @@ -328,16 +321,20 @@ class StreamMessageTextField extends StatefulWidget { /// If set, a character counter will be displayed below the /// field showing how many characters have been entered. If set to a number /// greater than 0, it will also display the maximum number allowed. If set - /// to [TextField.noMaxLength] then only the current character count is displayed. + /// to [TextField.noMaxLength] then only the current character count is + /// displayed. /// /// After [maxLength] characters have been input, additional input /// is ignored, unless [maxLengthEnforcement] is set to /// [MaxLengthEnforcement.none]. /// - /// The text field enforces the length with a [LengthLimitingTextInputFormatter], - /// which is evaluated after the supplied [inputFormatters], if any. + /// The text field enforces the length with a + /// [LengthLimitingTextInputFormatter], which is evaluated after the supplied + /// [inputFormatters], if any. + /// + /// This value must be either null, [TextField.noMaxLength], or greater than + /// 0. /// - /// This value must be either null, [TextField.noMaxLength], or greater than 0. /// If null (the default) then there is no limit to the number of characters /// that can be entered. If set to [TextField.noMaxLength], then no limit will /// be enforced, but the number of characters entered will still be displayed. @@ -345,12 +342,6 @@ class StreamMessageTextField extends StatefulWidget { /// Whitespace characters (e.g. newline, space, tab) are included in the /// character count. /// - /// If [maxLengthEnforced] is set to false or [maxLengthEnforcement] is - /// [MaxLengthEnforcement.none], then more than [maxLength] - /// characters may be entered, but the error counter and divider will switch - /// to the [decoration]'s [InputDecoration.errorStyle] when the limit is - /// exceeded. - /// /// {@macro flutter.services.lengthLimitingTextInputFormatter.maxLength} final int? maxLength; @@ -444,7 +435,8 @@ class StreamMessageTextField extends StatefulWidget { /// /// This setting is only honored on iOS devices. /// - /// If unset, defaults to the brightness of [ThemeData.primaryColorBrightness]. + /// If unset, defaults to the brightness of + /// [ThemeData.primaryColorBrightness]. final Brightness? keyboardAppearance; /// {@macro flutter.widgets.editableText.scrollPadding} @@ -488,14 +480,16 @@ class StreamMessageTextField extends StatefulWidget { /// widget. /// /// If [mouseCursor] is a [MaterialStateProperty], - /// [MaterialStateProperty.resolve] is used for the following [MaterialState]s: + /// [MaterialStateProperty.resolve] is used for the following + /// [MaterialState]s: /// /// * [MaterialState.error]. /// * [MaterialState.hovered]. /// * [MaterialState.focused]. /// * [MaterialState.disabled]. /// - /// If this property is null, [MaterialStateMouseCursor.textable] will be used. + /// If this property is null, [MaterialStateMouseCursor.textable] will be + /// used. /// /// The [mouseCursor] is the only property of [TextField] that controls the /// appearance of the mouse pointer. All other properties related to "cursor" @@ -610,10 +604,6 @@ class StreamMessageTextField extends StatefulWidget { properties.add( DiagnosticsProperty('expands', expands, defaultValue: false)); properties.add(IntProperty('maxLength', maxLength, defaultValue: null)); - properties.add(FlagProperty('maxLengthEnforced', - value: maxLengthEnforced, - defaultValue: true, - ifFalse: 'maxLength not enforced')); properties.add(EnumProperty( 'maxLengthEnforcement', maxLengthEnforcement, defaultValue: null)); @@ -741,7 +731,6 @@ class _StreamMessageTextFieldState extends State minLines: widget.minLines, expands: widget.expands, maxLength: widget.maxLength, - maxLengthEnforced: widget.maxLengthEnforced, maxLengthEnforcement: widget.maxLengthEnforcement, onEditingComplete: widget.onEditingComplete, onSubmitted: widget.onSubmitted, 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..4010b11b 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; @@ -830,8 +832,8 @@ class _MessageWidgetState extends State ), if (isFailedState) Positioned( - left: widget.reverse ? 0 : null, - right: widget.reverse ? null : 0, + right: widget.reverse ? 0 : null, + left: widget.reverse ? null : 0, bottom: showBottomRow ? 18 : -2, child: StreamSvgIcon.error(size: 20), ), @@ -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/src/stream_chat_theme.dart b/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart index 23d913cb..f3818b2e 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart @@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' hide TextTheme; import 'package:stream_chat_flutter/src/channel_preview.dart'; import 'package:stream_chat_flutter/src/gradient_avatar.dart'; -import 'package:stream_chat_flutter/src/message_input.dart'; +import 'package:stream_chat_flutter/src/message_input/message_input.dart'; import 'package:stream_chat_flutter/src/reaction_icon.dart'; import 'package:stream_chat_flutter/src/theme/themes.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index da0cd3db..b39a2eea 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'; @@ -22,16 +23,16 @@ export 'src/localization/stream_chat_localizations.dart'; export 'src/localization/translations.dart' show DefaultTranslations; export 'src/mention_tile.dart'; export 'src/message_action.dart'; -export 'src/message_input.dart'; +export 'src/message_input/message_input.dart'; export 'src/message_list_view.dart'; export 'src/message_search_item.dart'; export 'src/message_search_list_view.dart'; export 'src/message_text.dart'; export 'src/message_widget.dart'; -export 'src/mip/countdown_button.dart'; -export 'src/mip/stream_attachment_picker.dart'; -export 'src/mip/stream_message_send_button.dart'; -export 'src/mip/stream_message_text_field.dart'; +export 'src/message_input/countdown_button.dart'; +export 'src/message_input/stream_attachment_picker.dart'; +export 'src/message_input/stream_message_send_button.dart'; +export 'src/message_input/stream_message_text_field.dart'; export 'src/option_list_tile.dart'; export 'src/reaction_icon.dart'; export 'src/reaction_picker.dart'; diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index d89b4ccd..86dcd0f6 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.2 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -36,7 +36,7 @@ dependencies: rxdart: ^0.27.0 share_plus: ^3.0.4 shimmer: ^2.0.0 - stream_chat_flutter_core: ^3.2.0 + stream_chat_flutter_core: ^3.3.1 substring_highlight: ^1.0.26 synchronized: ^3.0.0 url_launcher: ^6.0.3 diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index 37354ebf..2e89b6e7 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -1,7 +1,17 @@ +# Upcoming + ✅ Added - Added `MessageInputController` to hold `Message` related data. +## 3.3.1 + +- Updated `stream_chat` dependency to [`3.3.1`](https://pub.dev/packages/stream_chat/changelog). + +## 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..d807091c 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,7 @@ 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/channels_bloc.dart b/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart index 0e8d48bc..6514d66b 100644 --- a/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart +++ b/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:rxdart/rxdart.dart'; import 'package:stream_chat/stream_chat.dart'; diff --git a/packages/stream_chat_flutter_core/lib/src/message_input_controller.dart b/packages/stream_chat_flutter_core/lib/src/message_input_controller.dart index 3339bc5b..2b718939 100644 --- a/packages/stream_chat_flutter_core/lib/src/message_input_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/message_input_controller.dart @@ -4,142 +4,193 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:stream_chat/stream_chat.dart'; +/// A value listenable builder related to a [Message]. +/// +/// Pass in a [MessageInputController] as the `valueListenable`. +typedef MessageValueListenableBuilder = ValueListenableBuilder; + /// Controller for storing and mutating a [Message] value. class MessageInputController extends ValueNotifier { /// Creates a controller for an editable text field. /// /// This constructor treats a null [message] argument as if it were the empty /// message. - factory MessageInputController({Message? message}) => - MessageInputController._(message ?? Message()); + factory MessageInputController({ + Message? message, + }) => + MessageInputController._( + initialMessage: message ?? Message(), + ); /// Creates a controller for an editable text field from an initial [text]. factory MessageInputController.fromText(String? text) => - MessageInputController._(Message(text: text)); + MessageInputController._( + initialMessage: Message(text: text), + ); - /// Creates a controller for an editable text field from an initial + /// Creates a controller for an editable text field from initial /// [attachments]. factory MessageInputController.fromAttachments( List attachments, ) => - MessageInputController._(Message(attachments: attachments)); + MessageInputController._( + initialMessage: Message(attachments: attachments), + ); - MessageInputController._(Message message) - : _textEditingController = TextEditingController(text: message.text), - super(message); + MessageInputController._({ + required Message initialMessage, + }) : _textEditingController = + TextEditingController(text: initialMessage.text), + _initialMessage = initialMessage, + super(initialMessage) { + addListener(_textEditingSyncer); + } - /// + void _textEditingSyncer() { + final cleanText = value.command == null + ? value.text + : value.text?.replaceFirst( + '/${value.command} ', + '', + ); + + if (cleanText != _textEditingController.text) { + final previousOffset = _textEditingController.value.selection.start; + final previousText = _textEditingController.text; + final diff = (cleanText?.length ?? 0) - previousText.length; + _textEditingController + ..text = cleanText ?? '' + ..selection = TextSelection.collapsed( + offset: previousOffset + diff, + ); + } + } + + /// Returns the current message associated with this controller. + Message get message => value; + + /// Returns the controller of the text field linked to this controller. TextEditingController get textEditingController => _textEditingController; final TextEditingController _textEditingController; - /// + /// Returns the text of the message. String get text => _textEditingController.text; - /// + Message _initialMessage; + + /// Sets the message. set message(Message message) { value = message; } + /// Sets a command for the message. + set command(Command command) { + value = value.copyWith( + command: command.name, + text: '/${command.name} ', + ); + } + + /// Sets the text of the message. set text(String newText) { - value = value.copyWith(text: newText); - _textEditingController - ..text = newText - ..selection = TextSelection.fromPosition( - TextPosition(offset: _textEditingController.text.length), - ); + var newTextWithCommand = newText; + if (value.command != null) { + if (!newText.startsWith('/${value.command}')) { + newTextWithCommand = '/${value.command} $newText'; + } + } + value = value.copyWith(text: newTextWithCommand); } - /// - set textEditingValue(TextEditingValue newValue) { - _textEditingController.value = newValue; - value = value.copyWith(text: _textEditingController.text); - } - - /// + /// Returns the baseOffset of the text field. int get baseOffset => textEditingController.selection.baseOffset; - /// + /// Returns the start of the selection of the text field. int get selectionStart => textEditingController.selection.start; + /// Sets the [showInChannel] flag of the message. set showInChannel(bool newValue) { value = value.copyWith(showInChannel: newValue); } - /// + /// Returns true if the message is in a thread and + /// should be shown in the main channel as well. bool get showInChannel => value.showInChannel ?? false; - /// + /// Returns the attachments of the message. List get attachments => value.attachments; + /// Sets the list of [attachments] for the message. set attachments(List attachments) { value = value.copyWith(attachments: attachments); } - /// + /// Adds a new attachment to the message. void addAttachment(Attachment attachment) { attachments = [...attachments, attachment]; } - /// + /// Adds a new attachment at the specified [index]. void addAttachmentAt(int index, Attachment attachment) { attachments = [...attachments]..insert(index, attachment); } - /// + /// Removes the specified [attachment] from the message. void removeAttachment(Attachment attachment) { attachments = [...attachments]..remove(attachment); } - /// + /// Remove the attachment with the given [attachmentId]. void removeAttachmentById(String attachmentId) { attachments = [...attachments]..removeWhere((it) => it.id == attachmentId); } - /// + /// Removes the attachment at the given [index]. void removeAttachmentAt(int index) { attachments = [...attachments]..removeAt(index); } - /// + /// Clears the message attachments. void clearAttachments() { attachments = []; } - /// + /// Returns the list of mentioned users in the message. List get mentionedUsers => value.mentionedUsers; + /// Sets the mentioned users. set mentionedUsers(List users) { value = value.copyWith(mentionedUsers: users); } - /// + /// Adds a user to the list of mentioned users. void addMentionedUser(User user) { mentionedUsers = [...mentionedUsers, user]; } - /// + /// Removes the specified [user] from the mentioned users list. void removeMentionedUser(User user) { mentionedUsers = [...mentionedUsers]..remove(user); } - /// + /// Removes the mentioned user with the given [userId]. void removeMentionedUserById(String userId) { mentionedUsers = [...mentionedUsers]..removeWhere((it) => it.id == userId); } - /// + /// Removes all mentioned users from the message. void clearMentionedUsers() { mentionedUsers = []; } - /// Set the [value] to empty. + /// Sets the [message], or [value], to empty. /// /// After calling this function, [text], [attachments] and [mentionedUsers] - /// all will be empty. + /// will all be empty. /// /// Calling this will notify all the listeners of this /// [MessageInputController] that they need to update - /// (it calls [notifyListeners]). For this reason, + /// (calls [notifyListeners]). For this reason, /// this method should only be called between frames, e.g. in response to user /// actions, not during the build, layout, or paint phases. void clear() { @@ -147,10 +198,21 @@ class MessageInputController extends ValueNotifier { _textEditingController.clear(); } + /// Sets the [value] to the initial [Message] value. + void reset({bool resetId = true}) { + if (resetId) { + _initialMessage = _initialMessage.copyWith( + id: const Uuid().v4(), + ); + } + value = _initialMessage; + } + @override void dispose() { - super.dispose(); + removeListener(_textEditingSyncer); _textEditingController.dispose(); + super.dispose(); } } @@ -165,16 +227,13 @@ class RestorableMessageInputController extends RestorableChangeNotifier { /// Creates a [RestorableMessageInputController]. /// - /// This constructor treats a null `text` argument as if it were the empty - /// string. + /// This constructor creates a default [Message] when no `message` argument + /// is supplied. RestorableMessageInputController({Message? message}) : _initialValue = message ?? Message(); /// Creates a [RestorableMessageInputController] from an initial - /// [TextEditingValue]. - /// - /// This constructor treats a null `value` argument as if it were - /// [TextEditingValue.empty]. + /// [text] value. factory RestorableMessageInputController.fromText(String? text) => RestorableMessageInputController(message: Message(text: text)); diff --git a/packages/stream_chat_flutter_core/lib/src/message_list_core.dart b/packages/stream_chat_flutter_core/lib/src/message_list_core.dart index 74e40d7f..a28d6d73 100644 --- a/packages/stream_chat_flutter_core/lib/src/message_list_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/message_list_core.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_flutter_core/src/better_stream_builder.dart'; 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..fb9307c1 100644 --- a/packages/stream_chat_flutter_core/pubspec.yaml +++ b/packages/stream_chat_flutter_core/pubspec.yaml @@ -1,22 +1,22 @@ 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.1 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues environment: - sdk: '>=2.12.0 <3.0.0' + sdk: '>=2.14.0 <3.0.0' flutter: ">=1.17.0" dependencies: collection: ^1.15.0 - connectivity_plus: ^2.0.2 + connectivity_plus: ^2.1.0 flutter: sdk: flutter meta: ^1.3.0 rxdart: ^0.27.0 - stream_chat: ^3.2.0 + stream_chat: ^3.3.1 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..5f7c0e03 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 @@ -9,7 +9,7 @@ import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'mocks.dart'; void main() { - const pagination = PaginationParams(limit: 3); + const pagination = PaginationParams(limit: 3, offset: 0); List _generateChannels( StreamChatClient client, { @@ -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, offset: 0); when(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), diff --git a/packages/stream_chat_flutter_core/test/message_input_controller_test.dart b/packages/stream_chat_flutter_core/test/message_input_controller_test.dart new file mode 100644 index 00000000..842a58bb --- /dev/null +++ b/packages/stream_chat_flutter_core/test/message_input_controller_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +void main() { + testWidgets( + 'should instantiate a new MessageInputController with default validator' + ' and empty message', + (tester) async { + final controller = MessageInputController(); + + expect(controller.isValid, false); + controller.text = 'test'; + expect(controller.isValid, true); + }, + ); + + testWidgets( + 'should instantiate a new MessageInputController with default validator' + ' and specified message', + (tester) async { + final message = Message(text: 'test'); + final controller = MessageInputController( + message: message, + ); + + expect(controller.message, message); + expect(controller.isValid, true); + }, + ); +} 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(), ); diff --git a/packages/stream_chat_persistence/test/src/mapper/message_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/message_mapper_test.dart index d2ea8b93..8a06f02d 100644 --- a/packages/stream_chat_persistence/test/src/mapper/message_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/message_mapper_test.dart @@ -157,7 +157,6 @@ void main() { (prev, curr) => prev?..update(curr.type, (value) => value + 1, ifAbsent: () => 1), ), - status: MessageSendingStatus.sending, updatedAt: DateTime.now(), extraData: const {'extra_test_data': 'extraData'}, user: user, diff --git a/packages/stream_chat_persistence/test/src/mapper/pinned_message_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/pinned_message_mapper_test.dart index 9c2378fd..9c4d99a2 100644 --- a/packages/stream_chat_persistence/test/src/mapper/pinned_message_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/pinned_message_mapper_test.dart @@ -147,7 +147,6 @@ void main() { (prev, curr) => prev?..update(curr.type, (value) => value + 1, ifAbsent: () => 1), ), - status: MessageSendingStatus.sending, updatedAt: DateTime.now(), extraData: const {'extra_test_data': 'extraData'}, user: user,