diff --git a/docusaurus/docs/Flutter/assets/chat_overview_page-2fbd5bbfb70c5623bd37ff7d6c41bf4d.png b/docusaurus/docs/Flutter/assets/chat_overview_page-2fbd5bbfb70c5623bd37ff7d6c41bf4d.png new file mode 100644 index 00000000..9e92437d Binary files /dev/null and b/docusaurus/docs/Flutter/assets/chat_overview_page-2fbd5bbfb70c5623bd37ff7d6c41bf4d.png differ diff --git a/docusaurus/docs/Flutter/assets/firebase_notifications_toggle-5aeabfcbdc24cb8f1fea7d41d0e845fc.png b/docusaurus/docs/Flutter/assets/firebase_notifications_toggle-5aeabfcbdc24cb8f1fea7d41d0e845fc.png new file mode 100644 index 00000000..c5670d0d Binary files /dev/null and b/docusaurus/docs/Flutter/assets/firebase_notifications_toggle-5aeabfcbdc24cb8f1fea7d41d0e845fc.png differ diff --git a/docusaurus/docs/Flutter/guides/adding_chat_to_video_livestreams.mdx b/docusaurus/docs/Flutter/guides/adding_chat_to_video_livestreams.mdx index 9e2afb44..96d24a05 100644 --- a/docusaurus/docs/Flutter/guides/adding_chat_to_video_livestreams.mdx +++ b/docusaurus/docs/Flutter/guides/adding_chat_to_video_livestreams.mdx @@ -64,30 +64,34 @@ The second type looks like this: We can use a `Stack` for achieving this: ```dart -Scaffold( - body: Stack( - children: [ - // Add your video implementation here - ShaderMask( - shaderCallback: (rect) { - return LinearGradient( +Stack( + children: [ + // Add your video implementation here + ShaderMask( + shaderCallback: (rect) { + return const LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, - colors: [Colors.black, Colors.transparent], - stops: [0.4, 0.65] - ).createShader(Rect.fromLTRB(0, 0, rect.width, rect.height)); - }, - blendMode: BlendMode.dstIn, - child: Column( - children: [ - Expanded( - child: MessageListView(), - ), - MessageInput(), - ], - ), - ), - ], - ), - ) -``` + colors: [Colors.black, Colors.transparent], + stops: [0.4, 0.8]).createShader( + Rect.fromLTRB(0, 0, rect.width, rect.height), + ); + }, + blendMode: BlendMode.dstIn, + child: Column( + children: const [ + Expanded( + child: MessageListViewTheme( + data: MessageListViewThemeData( + backgroundColor: Colors.transparent, + ), + child: MessageListView(), + ), + ), + MessageInput(), + ], + ), + ), + ], +), +``` \ No newline at end of file diff --git a/docusaurus/docs/Flutter/guides/adding_push_notifications.mdx b/docusaurus/docs/Flutter/guides/adding_push_notifications.mdx index c90f6814..328ba546 100644 --- a/docusaurus/docs/Flutter/guides/adding_push_notifications.mdx +++ b/docusaurus/docs/Flutter/guides/adding_push_notifications.mdx @@ -1,11 +1,15 @@ --- id: adding_push_notifications sidebar_position: 1 -title: Adding Push Notifications +title: Adding Push Notifications (V1 legacy) --- Adding Push Notifications To Your Application +:::note +Version 1 (legacy) of push notifications won't be removed immediately but there won't be any new features. That's why new applications are highly recommended to use version 2 from the beginning to leverage upcoming new features. +::: + ### Introduction Push notifications are a core part of the experience for a messaging app. Users often need to be notified diff --git a/docusaurus/docs/Flutter/guides/adding_push_notifications_v2.mdx b/docusaurus/docs/Flutter/guides/adding_push_notifications_v2.mdx new file mode 100644 index 00000000..285c2205 --- /dev/null +++ b/docusaurus/docs/Flutter/guides/adding_push_notifications_v2.mdx @@ -0,0 +1,301 @@ +--- +id: adding_push_notifications_v2 +sidebar_position: 1 +title: Adding Push Notifications (V2) +--- + +Adding Push Notifications To Your Application + +### Introduction + +Push notifications are a core part of the experience for a messaging app. Users often need to be notified +of new messages and old notifications sometimes need to be updated silently. + +This guide details how to add push notifications to your app. + +You can read more about Stream’s [push delivery logic](https://getstream.io/chat/docs/flutter-dart/push_introduction/?language=dart#push-delivery-rules). + +### Setup FCM + +To integrate push notifications in your Flutter app, you need to use the package [firebase_messaging](https://pub.dev/packages/firebase_messaging). + + +Follow the [Firebase documentation](https://firebase.flutter.dev/docs/messaging/overview/) to set up the plugin for Android and iOS. + + +Once that's done, FCM should be able to send push notifications to your devices. + +### Integration With Stream + +#### Step 1 - Get the Firebase Credentials + +These credentials are the [private key file](https://firebase.google.com/docs/admin/setup#:~:text=To%20generate%20a%20private%20key%20file%20for%20your%20service%20account%3A) for your service account, in firebase console. + +To generate a private key file for your service account, in the Firebase console: + +- Open Settings > Service Accounts. + +- Click **Generate New Private Key**, then confirm by clicking **Generate Key**. + +- Securely store the JSON file containing the key. + +This JSON file contains the credentials which needs to be uploaded to Stream’s server as explained in next step. + +#### Step 2 - Upload the Firebase Credentials to Stream + +You can upload your Firebase credentials using either the dashboard or the app settings API (available only in backend SDKs). + +##### Using the Stream Dashboard + +1. Go to the **Chat Overview** page on Stream Dashboard + +![](../assets/chat_overview_page-2fbd5bbfb70c5623bd37ff7d6c41bf4d.png) + +2. Enable **Firebase Notification** toggle on **Chat Overview** + +![](../assets/firebase_notifications_toggle-5aeabfcbdc24cb8f1fea7d41d0e845fc.png) + +3. Enter your Firebase Credentials and press "Save". + +##### Using the API + +You can also enable Firebase notifications and upload the Firebase credentials using one of our server SDKs. + +For example, using the JavaScript SDK: + +```js +const client = StreamChat.getInstance('api_key', 'api_secret'); +client.updateAppSettings({ + push_config: { + version: 'v2' + }, + firebase_config: { + credentials_json: fs.readFileSync( + './firebase-credentials.json', + 'utf-8', + ), + }); +``` +### Registering a Device With Stream Backend + +Once you configure a Firebase server key and set it up on Stream dashboard then a device that is supposed to receive push notifications needs to be registered on the Stream backend. This is usually done by listening for Firebase device token updates and passing them to the backend as follows: + +```dart +firebaseMessaging.onTokenRefresh.listen((token) { + client.addDevice(token, PushProvider.firebase); +}); +``` + +### Receiving Notifications + +Push notifications behave a bit differently depending on whether you are using iOS or Android. +See [here](https://firebase.flutter.dev/docs/messaging/usage#message-types) to understand the difference between **notification** and **data** payloads. + +#### iOS + +On iOS we send both a **notification** and a **data** payload. +This means you don't need to do anything special to get the notification to show up. However, you might want to handle the data payload to perform some logic when the user taps on the notification. + +To update the template, you can use a backend SDK. +For example, using the javascript SDK: + +```js +const client = StreamChat.getInstance(‘api_key’, ‘api_secret’); +const apn_template = `{ + "aps": { + "alert": { + "title": "New message from {{ sender.name }}", + "body": "{{ truncate message.text 2000 }}" + }, + "mutable-content": 1, + "category": "stream.chat" + }, + "stream": { + "sender": "stream.chat", + "type": "message.new", + "version": "v2", + "id": "{{ message.id }}", + "cid": "{{ channel.cid }}" + } +}`; + +client.updateAppSettings({ + firebase_config: { + apn_template, + }); +``` + +#### Android +On Android we send only a **data** payload. This gives you more flexibility and lets you decide what to do with the notification. + +For example, you can listen and generate a notification from them. + +To generate a notification when a **data-only** message is received and the app is in background: + +```dart +Future onBackgroundMessage(RemoteMessage message) async { + final chatClient = StreamChatClient(apiKey); + + chatClient.connectUser( + User(id: userId), + userToken, + connectWebSocket: false, + ); + + handleNotification(message, chatClient); +} + +void handleNotification( + RemoteMessage message, + StreamChatClient chatClient, +) async { + + final data = message.data; + + if (data['type'] == 'message.new') { + final flutterLocalNotificationsPlugin = await setupLocalNotifications(); + final messageId = data['id']; + final response = await chatClient.getMessage(messageId); + + flutterLocalNotificationsPlugin.show( + 1, + 'New message from ${response.message.user.name} in ${response.channel.name}', + response.message.text, + NotificationDetails( + android: AndroidNotificationDetails( + 'new_message', + 'New message notifications channel', + )), + ); + } +} + +FirebaseMessaging.onBackgroundMessage(onBackgroundMessage); +``` + +In the above example, you get the message details using the `getMessage` method and then you use the [flutter_local_notifications](https://pub.dev/packages/flutter_local_notifications) package to show the actual notification. + +##### Using a Template on Android + +It's still possible to add a **notification** payload to Android notifications. +You can do so by adding a template using a backend SDK. +For example, using the javascript SDK: + +```js +const client = StreamChat.getInstance(‘api_key’, ‘api_secret’); +const notification_template = ` +{ + "title": "{{ sender.name }} @ {{ channel.name }}", + "body": "{{ message.text }}", + "click_action": "OPEN_ACTIVITY_1", + "sound": "default" +}`; + +client.updateAppSettings({ + firebase_config: { + notification_template, + }); +``` + +### Possible Issues + +Make sure to read the [general push notification docs](https://getstream.io/chat/docs/flutter-dart/push_introduction/?language=dart) in order to avoid known gotchas that may make your relationship with notifications difficult 😢. + +### Testing if Push Notifications are Setup Correctly + +If you're not sure whether you've set up push notifications correctly, for example, you don't always receive them, or they don’t work reliably, then you can follow these steps to make sure your config is correct and working: +1. Clone our repo for push testing: `git clone git@github.com:GetStream/chat-push-test.git` +2. `cd flutter` +3. In that folder run `flutter pub get` +4. Input your api key and secret in `lib/main.dart` +5. Change the bundle identifier/application ID and development team/user so you can run the app on your physical device.**Do not** run on an iOS simulator, as it will not work. Testing on an Android emulator is fine. +6. Add your `google-services.json/GoogleService-Info.plist` +7. Run the app +8. Accept push notification permission (iOS only) +9. Tap on `Device ID` and copy it +11. After configuring [stream-cli](https://github.com/GetStream/stream-cli), run the following command using your user ID: +```shell +stream chat:push:test -u +``` + +You should get a test push notification 🥳 + + +### Foreground Notifications + +Sometimes you may want to show a notification when the app is in the foreground. +For example, when you're in a channel and you receive a new message from someone in another channel. + +For this scenario, you can also use the `flutter_local_notifications` package to show a notification. + +You need to listen for new events using `FirebaseMessaging.onMessage.listen()` and handle them accordingly: + +```dart +FirebaseMessaging.onMessage.listen((message) async { + handleNotification( + message, + chatClient, + ); +}); +``` + +:::note +You should also check that the channel of the message is different than the channel in the foreground. +How you do this depends on your app infrastructure and how you handle navigation. +Take a look at the [Stream Chat v1 sample app](https://github.com/GetStream/flutter-samples/blob/main/packages/stream_chat_v1/lib/home_page.dart#L11) to see how we're doing it over there. +::: + +### Saving Notification Messages to the Offline Storage (Only Android) + +When the app is closed you may want to save received messages when you receive them via a notification so that later on when you open the app they're already there. + +To do this you need to integrate the package [stream_chat_persistence](https://pub.dev/packages/stream_chat_persistence) in our app that exports a persistence client, see [here](https://pub.dev/packages/stream_chat_persistence#usage) how to set it up. + +Then calling `FirebaseMessaging.onBackgroundMessage(...)` you need to use a TOP-LEVEL or STATIC function to handle background messages; here is an example: + +```dart +Future onBackgroundMessage(RemoteMessage message) async { + final chatClient = StreamChatClient(apiKey); + final persistenceClient = StreamChatPersistenceClient(); + + await persistenceClient.connect(userId); + + chatClient.connectUser( + User(id: userId), + userToken, + connectWebSocket: false, + ); + + handleNotification(message, chatClient); +} + +void handleNotification( + RemoteMessage message, + StreamChatClient chatClient, +) async { + final data = message.data; + if (data['type'] == 'message.new') { + final flutterLocalNotificationsPlugin = await setupLocalNotifications(); + final messageId = data['id']; + final cid = data['cid']; + final response = await chatClient.getMessage(messageId); + await persistenceClient.updateMessages(cid, [response.message]); + + persistenceClient.disconnect(); + + flutterLocalNotificationsPlugin.show( + 1, + 'New message from ${response.message.user.name} in ${response.channel.name}', + response.message.text, + NotificationDetails( + android: AndroidNotificationDetails( + 'new_message', + 'New message notifications channel', + )), + ); + } +} + +FirebaseMessaging.onBackgroundMessage(onBackgroundMessage); +``` + diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index a74b920e..f0707cdb 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -1,6 +1,31 @@ +## 3.6.1 + +🐞 Fixed + +- [[#1081]](https://github.com/GetStream/stream-chat-flutter/issues/1081) Fixed a bug with user reconnection. + +## 3.6.0 + +🐞 Fixed + +- Fixed reactions not working for threads in offline mode. +- [[#1046]](https://github.com/GetStream/stream-chat-flutter/issues/1046) After `/mute` command on reload cannot access + any channel. +- [[#1047]](https://github.com/GetStream/stream-chat-flutter/issues/1047) `own_capabilities` extraData missing after + channel update. +- [[#1054]](https://github.com/GetStream/stream-chat-flutter/issues/1054) Fix `Unsupported operation: Cannot remove from an unmodifiable list`. +- [[#1033]](https://github.com/GetStream/stream-chat-flutter/issues/1033) Hard delete from dashboard does not delete message from client. +- Send only `user_id` while reconnecting. + +✅ Added + +- Handle `event.message` in `channel.truncate` events +- Added additional parameters to `channel.truncate` + ## 3.5.1 🐞 Fixed + - `channel.unreadCount` was being set as using global unread count on a very specific case. - The reconnection logic for the WebSocket connection is now more robust. @@ -16,7 +41,7 @@ - [[#890]](https://github.com/GetStream/stream-chat-flutter/pull/890) Fixed Reactions not updating on thread messages. Thanks [bstolinski](https://github.com/bstolinski). - [[#897]](https://github.com/GetStream/stream-chat-flutter/issues/897) Fixed error type mis-match in `AuthInterceptor`. -- [[#891]](https://github.com/GetStream/stream-chat-flutter/pull/891) Fixed reply counter for parent message not +- [[#891]](https://github.com/GetStream/stream-chat-flutter/pull/891) Fixed reply counter for parent message not updating correctly after deleting thread message. - Fix `channelState.copyWith` with respect to pinnedMessages. diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index f2effe04..abe8cf48 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -1033,10 +1033,23 @@ class Channel { return _client.deleteChannel(id!, type); } - /// Removes all messages from the channel. - Future truncate() async { + /// Removes all messages from the channel up to [truncatedAt] or now if + /// [truncatedAt] is not provided. + /// If [skipPush] is true, no push notification will be sent. + /// [Message] is the system message that will be sent to the channel. + Future truncate({ + Message? message, + bool? skipPush, + DateTime? truncatedAt, + }) async { _checkInitialized(); - return _client.truncateChannel(id!, type); + return _client.truncateChannel( + id!, + type, + message: message, + skipPush: skipPush, + truncatedAt: truncatedAt, + ); } /// Accept invitation to the channel. @@ -1094,7 +1107,6 @@ class Channel { // remove the passed message if response does // not contain message state!.removeMessage(message); - await _client.chatPersistenceClient?.deleteMessageById(messageId); } return res; } @@ -1586,10 +1598,12 @@ class ChannelClientState { _subscriptions.add(_channel.on(EventType.memberRemoved).listen((Event e) { final user = e.user; updateChannelState(channelState.copyWith( - members: List.from( - channelState.members..removeWhere((m) => m.userId == user!.id), - ), - read: channelState.read..removeWhere((r) => r.user.id == user!.id), + members: channelState.members + .where((m) => m.userId != user!.id) + .toList(growable: false), + read: channelState.read + .where((r) => r.user.id != user!.id) + .toList(growable: false), )); })); } @@ -1598,7 +1612,7 @@ class ChannelClientState { _subscriptions.add(_channel.on(EventType.channelUpdated).listen((Event e) { final channel = e.channel!; updateChannelState(channelState.copyWith( - channel: channel, + channel: channelState.channel?.merge(channel), members: channel.members, )); })); @@ -1612,6 +1626,9 @@ class ChannelClientState { await _channel._client.chatPersistenceClient ?.deleteMessageByCid(channel.cid); truncate(); + if (event.message != null) { + updateMessage(event.message!); + } })); } @@ -1846,7 +1863,9 @@ class ChannelClientState { } /// Remove a [message] from this [channelState]. - void removeMessage(Message message) { + void removeMessage(Message message) async { + await _channel._client.chatPersistenceClient?.deleteMessageById(message.id); + final parentId = message.parentId; // i.e. it's a thread message, Remove it if (parentId != null) { @@ -2119,12 +2138,12 @@ class ChannelClientState { final BehaviorSubject>> _threadsController = BehaviorSubject.seeded({}); - set _threads(Map> v) { - _channel.client.chatPersistenceClient?.updateMessages( + set _threads(Map> threads) { + _threadsController.add(threads); + _channel.client.chatPersistenceClient?.updateChannelThreads( _channel.cid!, - v.values.expand((v) => v).toList(), + threads, ); - _threadsController.add(v); } /// Channel related typing users last value. diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index c3605ac5..18254389 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -64,7 +64,7 @@ class StreamChatClient { StreamChatClient( String apiKey, { this.logLevel = Level.WARNING, - LogHandlerFunction? logHandlerFunction, + this.logHandlerFunction = StreamChatClient.defaultLogHandler, RetryPolicy? retryPolicy, @Deprecated(''' Location is now deprecated in favor of the new edge server. Will be removed in v4.0.0. @@ -77,7 +77,6 @@ class StreamChatClient { WebSocket? ws, AttachmentFileUploader? attachmentFileUploader, }) { - this.logHandlerFunction = logHandlerFunction ?? _defaultLogHandler; logger.info('Initiating new StreamChatClient'); final options = StreamHttpClientOptions( @@ -134,7 +133,7 @@ class StreamChatClient { '${CurrentPlatform.name}-' '${PACKAGE_VERSION.split('+')[0]}'; - /// Additionals headers for all requests + /// Additional headers for all requests static Map additionalHeaders = {}; ChatPersistenceClient? _originalChatPersistenceClient; @@ -189,7 +188,7 @@ class StreamChatClient { /// final client = StreamChatClient("stream-chat-api-key", /// logHandlerFunction: myLogHandlerFunction); ///``` - late LogHandlerFunction logHandlerFunction; + final LogHandlerFunction logHandlerFunction; StreamSubscription? _connectionStatusSubscription; @@ -214,17 +213,18 @@ class StreamChatClient { Stream get wsConnectionStatusStream => _wsConnectionStatusController.stream.distinct(); - LogHandlerFunction get _defaultLogHandler => (LogRecord record) { - print( - '${record.time} ' - '${_levelEmojiMapper[record.level] ?? record.level.name} ' - '${record.loggerName} ${record.message} ', - ); - if (record.error != null) print(record.error); - if (record.stackTrace != null) print(record.stackTrace); - }; + /// Default log handler function for the [StreamChatClient] logger. + static void defaultLogHandler(LogRecord record) { + print( + '${record.time} ' + '${_levelEmojiMapper[record.level] ?? record.level.name} ' + '${record.loggerName} ${record.message} ', + ); + if (record.error != null) print(record.error); + if (record.stackTrace != null) print(record.stackTrace); + } - /// + /// Default logger for the [StreamChatClient]. Logger detachedLogger(String name) => Logger.detached(name) ..level = logLevel ..onRecord.listen(logHandlerFunction); @@ -328,7 +328,9 @@ class StreamChatClient { _chatPersistenceClient = _originalChatPersistenceClient; await _chatPersistenceClient!.connect(ownUser.id); } - final connectedUser = await openConnection(); + final connectedUser = await openConnection( + includeUserDetailsInConnectCall: true, + ); return state.currentUser = connectedUser; } catch (e, stk) { if (e is StreamWebSocketError && e.isRetriable) { @@ -341,7 +343,11 @@ class StreamChatClient { } /// Creates a new WebSocket connection with the current user. - Future openConnection() async { + /// If [includeUserDetailsInConnectCall] is true it will include the current + /// user details in the connect call. + Future openConnection({ + bool includeUserDetailsInConnectCall = false, + }) async { assert( state.currentUser != null, 'User is not set on client, ' @@ -371,7 +377,10 @@ class StreamChatClient { _ws.connectionStatusStream.skip(1).listen(_connectionStatusHandler); try { - final event = await _ws.connect(user); + final event = await _ws.connect( + user, + includeUserDetails: includeUserDetailsInConnectCall, + ); return user.merge(event.me); } catch (e, stk) { logger.severe('error connecting ws', e, stk); @@ -938,14 +947,23 @@ class StreamChatClient { channelType, ); - /// Removes all messages from the channel + /// Removes all messages from the channel up to [truncatedAt] or now if + /// [truncatedAt] is not provided. + /// If [skipPush] is true, no push notification will be sent. + /// [Message] is the system message that will be sent to the channel. Future truncateChannel( String channelId, - String channelType, - ) => + String channelType, { + Message? message, + bool? skipPush, + DateTime? truncatedAt, + }) => _chatApi.channel.truncateChannel( channelId, channelType, + message: message, + skipPush: skipPush, + truncatedAt: truncatedAt, ); /// Mutes the channel diff --git a/packages/stream_chat/lib/src/core/api/channel_api.dart b/packages/stream_chat/lib/src/core/api/channel_api.dart index 8744661a..93d17870 100644 --- a/packages/stream_chat/lib/src/core/api/channel_api.dart +++ b/packages/stream_chat/lib/src/core/api/channel_api.dart @@ -265,10 +265,18 @@ class ChannelApi { /// Removes all messages from the channel Future truncateChannel( String channelId, - String channelType, - ) async { + String channelType, { + Message? message, + bool? skipPush, + DateTime? truncatedAt, + }) async { final response = await _client.post( '${_getChannelUrl(channelId, channelType)}/truncate', + data: { + if (message != null) 'message': message, + if (skipPush != null) 'skip_push': skipPush, + if (truncatedAt != null) 'truncated_at': truncatedAt, + }, ); return EmptyResponse.fromJson(response.data); } diff --git a/packages/stream_chat/lib/src/core/models/channel_model.dart b/packages/stream_chat/lib/src/core/models/channel_model.dart index ed7c2c2e..037c1431 100644 --- a/packages/stream_chat/lib/src/core/models/channel_model.dart +++ b/packages/stream_chat/lib/src/core/models/channel_model.dart @@ -172,7 +172,7 @@ class ChannelModel { updatedAt: other.updatedAt, deletedAt: other.deletedAt, memberCount: other.memberCount, - extraData: other.extraData, + extraData: {...extraData, ...other.extraData}, team: other.team, cooldown: other.cooldown, ); diff --git a/packages/stream_chat/lib/src/core/models/channel_mute.dart b/packages/stream_chat/lib/src/core/models/channel_mute.dart new file mode 100644 index 00000000..ab17161b --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/channel_mute.dart @@ -0,0 +1,37 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/user.dart'; + +part 'channel_mute.g.dart'; + +/// The class that contains the information about a muted channel +@JsonSerializable(createToJson: false) +class ChannelMute { + /// Constructor used for json serialization + ChannelMute({ + required this.user, + required this.channel, + required this.createdAt, + required this.updatedAt, + this.expires, + }); + + /// Create a new instance from a json + factory ChannelMute.fromJson(Map json) => + _$ChannelMuteFromJson(json); + + /// The user that performed the muting action + final User user; + + /// The target channel + final ChannelModel channel; + + /// The date in which the channel was muted + final DateTime createdAt; + + /// The date of the last update + final DateTime updatedAt; + + /// The date in which the mute expires + final DateTime? expires; +} diff --git a/packages/stream_chat/lib/src/core/models/channel_mute.g.dart b/packages/stream_chat/lib/src/core/models/channel_mute.g.dart new file mode 100644 index 00000000..9b6d00c1 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/channel_mute.g.dart @@ -0,0 +1,17 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'channel_mute.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ChannelMute _$ChannelMuteFromJson(Map json) => ChannelMute( + user: User.fromJson(json['user'] as Map), + channel: ChannelModel.fromJson(json['channel'] as Map), + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + expires: json['expires'] == null + ? null + : DateTime.parse(json['expires'] as String), + ); diff --git a/packages/stream_chat/lib/src/core/models/mute.dart b/packages/stream_chat/lib/src/core/models/mute.dart index 857b34a1..02c91784 100644 --- a/packages/stream_chat/lib/src/core/models/mute.dart +++ b/packages/stream_chat/lib/src/core/models/mute.dart @@ -1,7 +1,5 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/core/models/channel_model.dart'; import 'package:stream_chat/src/core/models/user.dart'; -import 'package:stream_chat/src/core/util/serializer.dart'; part 'mute.g.dart'; @@ -11,27 +9,27 @@ class Mute { /// Constructor used for json serialization Mute({ required this.user, - required this.channel, + required this.target, required this.createdAt, required this.updatedAt, + this.expires, }); /// Create a new instance from a json factory Mute.fromJson(Map json) => _$MuteFromJson(json); /// The user that performed the muting action - @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final User user; /// The target user - @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) - final ChannelModel channel; + final User target; /// The date in which the use was muted - @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final DateTime createdAt; /// The date of the last update - @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final DateTime updatedAt; + + /// The date in which the mute expires + final DateTime? expires; } diff --git a/packages/stream_chat/lib/src/core/models/mute.g.dart b/packages/stream_chat/lib/src/core/models/mute.g.dart index f57fd312..a0d908a8 100644 --- a/packages/stream_chat/lib/src/core/models/mute.g.dart +++ b/packages/stream_chat/lib/src/core/models/mute.g.dart @@ -8,7 +8,10 @@ part of 'mute.dart'; Mute _$MuteFromJson(Map json) => Mute( user: User.fromJson(json['user'] as Map), - channel: ChannelModel.fromJson(json['channel'] as Map), + target: User.fromJson(json['target'] as Map), createdAt: DateTime.parse(json['created_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String), + expires: json['expires'] == null + ? null + : DateTime.parse(json['expires'] as String), ); diff --git a/packages/stream_chat/lib/src/core/models/own_user.dart b/packages/stream_chat/lib/src/core/models/own_user.dart index 5246f48e..6133dd76 100644 --- a/packages/stream_chat/lib/src/core/models/own_user.dart +++ b/packages/stream_chat/lib/src/core/models/own_user.dart @@ -1,4 +1,5 @@ import 'package:json_annotation/json_annotation.dart'; +import 'package:stream_chat/src/core/models/channel_mute.dart'; import 'package:stream_chat/src/core/util/serializer.dart'; import 'package:stream_chat/stream_chat.dart'; @@ -79,7 +80,7 @@ class OwnUser extends User { bool? banned, DateTime? banExpires, List? teams, - List? channelMutes, + List? channelMutes, List? devices, List? mutes, int? totalUnreadCount, @@ -142,7 +143,7 @@ class OwnUser extends User { /// List of channels muted by the user. @JsonKey(includeIfNull: false) - final List channelMutes; + final List channelMutes; /// Total unread messages by the user. @JsonKey(includeIfNull: false) diff --git a/packages/stream_chat/lib/src/core/models/own_user.g.dart b/packages/stream_chat/lib/src/core/models/own_user.g.dart index be8f2d45..6cae18ee 100644 --- a/packages/stream_chat/lib/src/core/models/own_user.g.dart +++ b/packages/stream_chat/lib/src/core/models/own_user.g.dart @@ -18,7 +18,7 @@ OwnUser _$OwnUserFromJson(Map json) => OwnUser( totalUnreadCount: json['total_unread_count'] as int? ?? 0, unreadChannels: json['unread_channels'] as int? ?? 0, channelMutes: (json['channel_mutes'] as List?) - ?.map((e) => Mute.fromJson(e as Map)) + ?.map((e) => ChannelMute.fromJson(e as Map)) .toList() ?? const [], id: json['id'] as String, 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 82ba181a..2dfb7cd7 100644 --- a/packages/stream_chat/lib/src/db/chat_persistence_client.dart +++ b/packages/stream_chat/lib/src/db/chat_persistence_client.dart @@ -197,6 +197,28 @@ abstract class ChatPersistenceClient { /// Deletes all the members by channel [cids] Future deleteMembersByCids(List cids); + /// Updates the channel [cid] threads data along with reactions and users. + Future updateChannelThreads( + String cid, + Map> threads, + ) async { + final messages = threads.values.expand((it) => it).toList(); + + // Removing old reactions before saving the new + final oldReactions = messages.map((it) => it.id).toList(); + await deleteReactionsByMessageId(oldReactions); + + // Adding new reactions and users data + final reactions = messages.expand(_expandReactions).toList(); + final users = messages.map((it) => it.user).withNullifyer.toList(); + + await Future.wait([ + updateMessages(cid, messages), + updateReactions(reactions), + updateUsers(users), + ]); + } + /// Update the channel state data using [channelState] Future updateChannelState(ChannelState channelState) => updateChannelStates([channelState]); @@ -239,17 +261,8 @@ abstract class ChatPersistenceClient { channelWithMessages[cid] = messages; channelWithPinnedMessages[cid] = pinnedMessages; - List expandReactions(Message message) { - final own = message.ownReactions; - final latest = message.latestReactions; - return [ - if (own != null) ...own.where((r) => r.userId != null), - if (latest != null) ...latest.where((r) => r.userId != null), - ]; - } - - reactions.addAll(messages.expand(expandReactions)); - pinnedReactions.addAll(pinnedMessages.expand(expandReactions)); + reactions.addAll(messages.expand(_expandReactions)); + pinnedReactions.addAll(pinnedMessages.expand(_expandReactions)); users.addAll([ channel.createdBy, @@ -292,4 +305,13 @@ abstract class ChatPersistenceClient { ), ]); } + + List _expandReactions(Message message) { + final own = message.ownReactions; + final latest = message.latestReactions; + return [ + if (own != null) ...own.where((r) => r.userId != null), + if (latest != null) ...latest.where((r) => r.userId != null), + ]; + } } diff --git a/packages/stream_chat/lib/src/ws/websocket.dart b/packages/stream_chat/lib/src/ws/websocket.dart index 888ba08b..a26bd1f6 100644 --- a/packages/stream_chat/lib/src/ws/websocket.dart +++ b/packages/stream_chat/lib/src/ws/websocket.dart @@ -147,12 +147,15 @@ class WebSocket with TimerHelper { } } - Future _buildUri({bool refreshToken = false}) async { + Future _buildUri({ + bool refreshToken = false, + bool includeUserDetails = true, + }) async { final user = _user!; final token = await tokenManager.loadToken(refresh: refreshToken); final params = { 'user_id': user.id, - 'user_details': user, + 'user_details': includeUserDetails ? user : {'id': user.id}, 'user_token': token.rawValue, 'server_determines_connection_id': true, }; @@ -176,7 +179,10 @@ class WebSocket with TimerHelper { bool _connectRequestInProgress = false; /// Connect the WS using the parameters passed in the constructor - Future connect(User user) async { + Future connect( + User user, { + bool includeUserDetails = false, + }) async { if (_connectRequestInProgress) { throw const StreamWebSocketError(''' You've called connect twice, @@ -191,7 +197,9 @@ class WebSocket with TimerHelper { connectionCompleter = Completer(); try { - final uri = await _buildUri(); + final uri = await _buildUri( + includeUserDetails: includeUserDetails, + ); _initWebSocketChannel(uri); } catch (e, stk) { _onConnectionError(e, stk); @@ -219,7 +227,10 @@ class WebSocket with TimerHelper { setTimer( Duration(milliseconds: delay), () async { - final uri = await _buildUri(refreshToken: refreshToken); + final uri = await _buildUri( + refreshToken: refreshToken, + includeUserDetails: false, + ); try { _initWebSocketChannel(uri); } catch (e, stk) { diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index 571d78f5..794d7505 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -5,37 +5,36 @@ export 'package:dio/src/dio_error.dart'; export 'package:dio/src/multipart_file.dart'; export 'package:dio/src/options.dart'; export 'package:dio/src/options.dart' show ProgressCallback; -export 'package:logging/logging.dart' show Logger, Level; +export 'package:logging/logging.dart' show Logger, Level, LogRecord; export 'package:rate_limiter/rate_limiter.dart'; -export './src/core/api/attachment_file_uploader.dart' - show AttachmentFileUploader; -export './src/core/api/requests.dart'; -export './src/core/api/requests.dart'; -export './src/core/api/responses.dart'; -export './src/core/api/stream_chat_api.dart' show PushProvider; -export './src/core/error/error.dart'; -export './src/core/models/action.dart'; -export './src/core/models/attachment.dart'; -export './src/core/models/attachment_file.dart'; -export './src/core/models/channel_config.dart'; -export './src/core/models/channel_model.dart'; -export './src/core/models/channel_state.dart'; -export './src/core/models/command.dart'; -export './src/core/models/device.dart'; -export './src/core/models/event.dart'; -export './src/core/models/filter.dart' show Filter; -export './src/core/models/member.dart'; -export './src/core/models/message.dart'; -export './src/core/models/mute.dart'; -export './src/core/models/own_user.dart'; -export './src/core/models/reaction.dart'; -export './src/core/models/read.dart'; -export './src/core/models/user.dart'; -export './src/core/util/extension.dart'; -export './src/db/chat_persistence_client.dart'; -export './src/event_type.dart'; -export './src/location.dart'; -export './src/ws/connection_status.dart'; export 'src/client/channel.dart'; export 'src/client/client.dart'; +export 'src/core/api/attachment_file_uploader.dart' show AttachmentFileUploader; +export 'src/core/api/requests.dart'; +export 'src/core/api/requests.dart'; +export 'src/core/api/responses.dart'; +export 'src/core/api/stream_chat_api.dart' show PushProvider; +export 'src/core/error/error.dart'; +export 'src/core/models/action.dart'; +export 'src/core/models/attachment.dart'; +export 'src/core/models/attachment_file.dart'; +export 'src/core/models/channel_config.dart'; +export 'src/core/models/channel_model.dart'; +export 'src/core/models/channel_state.dart'; +export 'src/core/models/command.dart'; +export 'src/core/models/device.dart'; +export 'src/core/models/event.dart'; +export 'src/core/models/filter.dart' show Filter; +export 'src/core/models/member.dart'; +export 'src/core/models/message.dart'; +export 'src/core/models/mute.dart'; +export 'src/core/models/own_user.dart'; +export 'src/core/models/reaction.dart'; +export 'src/core/models/read.dart'; +export 'src/core/models/user.dart'; +export 'src/core/util/extension.dart'; +export 'src/db/chat_persistence_client.dart'; +export 'src/event_type.dart'; +export 'src/location.dart'; +export 'src/ws/connection_status.dart'; diff --git a/packages/stream_chat/lib/version.dart b/packages/stream_chat/lib/version.dart index 8d0584a0..cc2c6eb9 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.5.1'; +const PACKAGE_VERSION = '3.6.1'; diff --git a/packages/stream_chat/pubspec.yaml b/packages/stream_chat/pubspec.yaml index 2e195f6b..c5705195 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.5.1 +version: 3.6.1 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues diff --git a/packages/stream_chat/test/fixtures/channel_mute.json b/packages/stream_chat/test/fixtures/channel_mute.json new file mode 100644 index 00000000..cc6c3a8c --- /dev/null +++ b/packages/stream_chat/test/fixtures/channel_mute.json @@ -0,0 +1,74 @@ +{ + "user": { + "id": "super-band-9", + "role": "user", + "created_at": "2020-03-03T16:48:28.853674Z", + "updated_at": "2021-05-26T03:22:20.296181Z", + "last_active": "2021-06-16T11:42:29.466165498Z", + "banned": false, + "online": true, + "username": "Rioland", + "image": "https://placehold.jp/150x150.png", + "invisible": false, + "name": "Proud darkness", + "unread_count": 0 + }, + "channel": { + "id": "!members-Qsp7PpigdPkW0rJk0603y5GnTiF1iRfoDc4SAngMMmw", + "type": "messaging", + "cid": "messaging:!members-Qsp7PpigdPkW0rJk0603y5GnTiF1iRfoDc4SAngMMmw", + "last_message_at": "2020-12-02T06:56:18.003432Z", + "created_at": "2020-11-30T10:25:32.494601Z", + "updated_at": "2020-11-30T10:25:32.494601Z", + "created_by": { + "id": "super-band-9", + "role": "user", + "created_at": "2020-03-03T16:48:28.853674Z", + "updated_at": "2021-05-26T03:22:20.296181Z", + "last_active": "2021-06-16T11:42:29.466165498Z", + "banned": false, + "online": true, + "image": "https://placehold.jp/150x150.png", + "invisible": false, + "name": "Proud darkness", + "unread_count": 0, + "username": "Rioland" + }, + "frozen": false, + "disabled": false, + "member_count": 2, + "config": { + "created_at": "2020-04-15T14:57:17.00966Z", + "updated_at": "2021-05-25T14:25:30.405621Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "custom_events": false, + "push_notifications": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "blocklist": "profanity_en_2020_v1", + "blocklist_behavior": "block", + "automod_thresholds": {}, + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + } + }, + "created_at": "2020-12-04T10:39:06.512021Z", + "updated_at": "2020-12-04T10:39:06.512021Z" +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/mute.json b/packages/stream_chat/test/fixtures/mute.json index cc6c3a8c..5e425392 100644 --- a/packages/stream_chat/test/fixtures/mute.json +++ b/packages/stream_chat/test/fixtures/mute.json @@ -13,61 +13,19 @@ "name": "Proud darkness", "unread_count": 0 }, - "channel": { - "id": "!members-Qsp7PpigdPkW0rJk0603y5GnTiF1iRfoDc4SAngMMmw", - "type": "messaging", - "cid": "messaging:!members-Qsp7PpigdPkW0rJk0603y5GnTiF1iRfoDc4SAngMMmw", - "last_message_at": "2020-12-02T06:56:18.003432Z", - "created_at": "2020-11-30T10:25:32.494601Z", - "updated_at": "2020-11-30T10:25:32.494601Z", - "created_by": { - "id": "super-band-9", - "role": "user", - "created_at": "2020-03-03T16:48:28.853674Z", - "updated_at": "2021-05-26T03:22:20.296181Z", - "last_active": "2021-06-16T11:42:29.466165498Z", - "banned": false, - "online": true, - "image": "https://placehold.jp/150x150.png", - "invisible": false, - "name": "Proud darkness", - "unread_count": 0, - "username": "Rioland" - }, - "frozen": false, - "disabled": false, - "member_count": 2, - "config": { - "created_at": "2020-04-15T14:57:17.00966Z", - "updated_at": "2021-05-25T14:25:30.405621Z", - "name": "messaging", - "typing_events": true, - "read_events": true, - "connect_events": true, - "search": true, - "reactions": true, - "replies": true, - "mutes": true, - "uploads": true, - "url_enrichment": true, - "custom_events": false, - "push_notifications": true, - "message_retention": "infinite", - "max_message_length": 5000, - "automod": "disabled", - "automod_behavior": "flag", - "blocklist": "profanity_en_2020_v1", - "blocklist_behavior": "block", - "automod_thresholds": {}, - "commands": [ - { - "name": "giphy", - "description": "Post a random gif to the channel", - "args": "[text]", - "set": "fun_set" - } - ] - } + "target": { + "id": "super-band-10", + "role": "user", + "created_at": "2020-03-03T16:48:28.853674Z", + "updated_at": "2021-05-26T03:22:20.296181Z", + "last_active": "2021-06-16T11:42:29.466165498Z", + "banned": false, + "online": true, + "username": "Holland", + "image": "https://placehold.jp/150x150.png", + "invisible": false, + "name": "Proud brightness", + "unread_count": 0 }, "created_at": "2020-12-04T10:39:06.512021Z", "updated_at": "2020-12-04T10:39:06.512021Z" diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index b59937bb..5ec2bfeb 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -645,8 +645,8 @@ void main() { when(() => persistence.getChannelThreads(any())) .thenAnswer((_) async => {}); - when(() => persistence.updateMessages(any(), any())) - .thenAnswer((_) => Future.value()); + when(() => persistence.updateChannelThreads(any(), any())) + .thenAnswer((_) async => {}); when(() => persistence.getChannelStateByCid(any(), messagePagination: any(named: 'messagePagination'), pinnedMessagePagination: @@ -692,7 +692,7 @@ void main() { verify(() => persistence.getChannelThreads(any())) .called((persistentChannelStates + channelStates).length); - verify(() => persistence.updateMessages(any(), any())) + verify(() => persistence.updateChannelThreads(any(), any())) .called((persistentChannelStates + channelStates).length); verify( () => persistence.getChannelStateByCid(any(), @@ -733,8 +733,8 @@ void main() { when(() => persistence.getChannelThreads(any())) .thenAnswer((_) async => {}); - when(() => persistence.updateMessages(any(), any())) - .thenAnswer((_) => Future.value()); + when(() => persistence.updateChannelThreads(any(), any())) + .thenAnswer((_) async => {}); when(() => persistence.getChannelStateByCid(any(), messagePagination: any(named: 'messagePagination'), pinnedMessagePagination: @@ -775,7 +775,7 @@ void main() { verify(() => persistence.getChannelThreads(any())) .called(persistentChannelStates.length); - verify(() => persistence.updateMessages(any(), any())) + verify(() => persistence.updateChannelThreads(any(), any())) .called(persistentChannelStates.length); verify( () => persistence.getChannelStateByCid(any(), diff --git a/packages/stream_chat/test/src/core/api/channel_api_test.dart b/packages/stream_chat/test/src/core/api/channel_api_test.dart index e631fdb2..a7856c92 100644 --- a/packages/stream_chat/test/src/core/api/channel_api_test.dart +++ b/packages/stream_chat/test/src/core/api/channel_api_test.dart @@ -481,14 +481,21 @@ void main() { final path = '${_getChannelUrl(channelId, channelType)}/truncate'; - when(() => client.post(path)).thenAnswer( - (_) async => successResponse(path, data: {})); + when(() => client.post( + path, + data: {}, + )) + .thenAnswer( + (_) async => successResponse(path, data: {})); final res = await channelApi.truncateChannel(channelId, channelType); expect(res, isNotNull); - verify(() => client.post(path)).called(1); + verify(() => client.post( + path, + data: {}, + )).called(1); verifyNoMoreInteractions(client); }); diff --git a/packages/stream_chat/test/src/core/models/channel_mute_test.dart b/packages/stream_chat/test/src/core/models/channel_mute_test.dart new file mode 100644 index 00000000..9a84f263 --- /dev/null +++ b/packages/stream_chat/test/src/core/models/channel_mute_test.dart @@ -0,0 +1,18 @@ +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/channel_mute.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + group('src/models/channel_mute', () { + test('should parse json correctly', () { + final mute = ChannelMute.fromJson(jsonFixture('channel_mute.json')); + expect(mute.user, isA()); + expect(mute.channel, isA()); + expect(mute.createdAt, DateTime.parse('2020-12-04T10:39:06.512021Z')); + expect(mute.updatedAt, DateTime.parse('2020-12-04T10:39:06.512021Z')); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/models/mute_test.dart b/packages/stream_chat/test/src/core/models/mute_test.dart index f1f7b606..f5361506 100644 --- a/packages/stream_chat/test/src/core/models/mute_test.dart +++ b/packages/stream_chat/test/src/core/models/mute_test.dart @@ -1,4 +1,3 @@ -import 'package:stream_chat/src/core/models/channel_model.dart'; import 'package:stream_chat/src/core/models/mute.dart'; import 'package:stream_chat/src/core/models/user.dart'; import 'package:test/test.dart'; @@ -6,12 +5,13 @@ import 'package:test/test.dart'; import '../../utils.dart'; void main() { - group('src/models/mute', () { + group('src/models/channel_mute', () { test('should parse json correctly', () { final mute = Mute.fromJson(jsonFixture('mute.json')); - expect(mute.channel, isA()); expect(mute.user, isA()); + expect(mute.target, isA()); expect(mute.createdAt, DateTime.parse('2020-12-04T10:39:06.512021Z')); + expect(mute.updatedAt, DateTime.parse('2020-12-04T10:39:06.512021Z')); }); }); } diff --git a/packages/stream_chat/test/src/core/models/own_user_test.dart b/packages/stream_chat/test/src/core/models/own_user_test.dart index 430e9c36..e0dc7801 100644 --- a/packages/stream_chat/test/src/core/models/own_user_test.dart +++ b/packages/stream_chat/test/src/core/models/own_user_test.dart @@ -1,4 +1,5 @@ import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/models/channel_mute.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:test/test.dart'; @@ -6,12 +7,14 @@ import '../../utils.dart'; class MockMute extends Mock implements Mute {} +class ChannelMockMute extends Mock implements ChannelMute {} + class MockDevice extends Mock implements Device {} void main() { final devices = [MockDevice(), MockDevice()]; final mutes = [MockMute(), MockMute()]; - final channelMutes = [MockMute()]; + final channelMutes = [ChannelMockMute()]; final createdAt = DateTime.parse('2021-05-03 12:39:21.817646'); final updatedAt = DateTime.parse('2021-04-03 12:39:21.817646'); final lastActive = DateTime.parse('2021-03-03 12:39:21.817646'); diff --git a/packages/stream_chat/test/src/db/chat_persistence_client_test.dart b/packages/stream_chat/test/src/db/chat_persistence_client_test.dart index 897bbcb0..7c49cc7d 100644 --- a/packages/stream_chat/test/src/db/chat_persistence_client_test.dart +++ b/packages/stream_chat/test/src/db/chat_persistence_client_test.dart @@ -162,6 +162,23 @@ void main() { expect(channelState, isNotNull); }); + test('updateChannelThreads', () async { + const cid = 'test:cid'; + final user = User(id: 'test-user-id'); + final threads = { + 'parent-test-message': [ + Message( + id: 'test-message', + text: 'test-message', + user: user, + ownReactions: [Reaction(type: 'test', user: user)], + latestReactions: [Reaction(type: 'test', user: user)], + ) + ] + }; + persistenceClient.updateChannelThreads(cid, threads); + }); + test('updateChannelState', () async { final channelState = ChannelState(); persistenceClient.updateChannelState(channelState); diff --git a/packages/stream_chat/test/src/fakes.dart b/packages/stream_chat/test/src/fakes.dart index b4089512..409418cd 100644 --- a/packages/stream_chat/test/src/fakes.dart +++ b/packages/stream_chat/test/src/fakes.dart @@ -124,7 +124,10 @@ class FakeWebSocket extends Fake implements WebSocket { Completer? connectionCompleter; @override - Future connect(User user) async { + Future connect( + User user, { + bool? includeUserDetails = true, + }) async { connectionStatus = ConnectionStatus.connecting; final event = Event( type: EventType.healthCheck, @@ -167,7 +170,10 @@ class FakeWebSocketWithConnectionError extends Fake implements WebSocket { Completer? connectionCompleter; @override - Future connect(User user) async { + Future connect( + User user, { + bool? includeUserDetails = true, + }) async { connectionStatus = ConnectionStatus.connecting; const error = StreamWebSocketError('Error Connecting'); connectionCompleter = Completer()..completeError(error); diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 64f86c01..12af5a99 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -4,6 +4,25 @@ - `centerTitle` and `elevation` properties to `ChannelHeader`, `ThreadHeader` and `ChannelListHeader`. +🐞 Fixed + +- [[#1067]](https://github.com/GetStream/stream-chat-flutter/issues/1067): Fix name text overflow in reaction card. +- [[#842]](https://github.com/GetStream/stream-chat-flutter/issues/842): show date divider for first message. +- Loosen up url check for attachment download. +- Use `ogScrapeUrl` for LinkAttachments. + +## 3.6.1 + +- Updated `stream_chat_flutter_core` dependency to [`3.6.1`](https://pub.dev/packages/stream_chat_flutter_core/changelog). + +## 3.6.0 + +🐞 Fixed + +- [[#892]](https://github.com/GetStream/stream-chat-flutter/issues/892): Fix default `initialAlignment` in `MessageListView`. +- Fix `MessageInputTheme.inputBackgroundColor` color not being used in some widgets of `MessageInput` +- Removed dependency on `visibility_detector` + ## 3.5.1 🐞 Fixed @@ -27,6 +46,7 @@ - Fix default `Channel` route not opening from `ChannelListView` when `ChannelAvatar` is tapped ## 3.4.0 + - Updated `stream_chat_flutter_core` dependency to [`3.4.0`](https://pub.dev/packages/stream_chat_flutter_core/changelog). 🐞 Fixed diff --git a/packages/stream_chat_flutter/example/android/app/build.gradle b/packages/stream_chat_flutter/example/android/app/build.gradle index 3974f949..6576512a 100644 --- a/packages/stream_chat_flutter/example/android/app/build.gradle +++ b/packages/stream_chat_flutter/example/android/app/build.gradle @@ -40,7 +40,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.example.example" - minSdkVersion 21 + minSdkVersion 22 targetSdkVersion 31 versionCode flutterVersionCode.toInteger() versionName flutterVersionName 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 c77b668c..bc24ed46 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 @@ -2,7 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; 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 f3203ff6..ab3ab00d 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 @@ -6,7 +6,6 @@ import 'dart:async'; import 'dart:math'; import 'package:collection/collection.dart' show IterableExtension; -import 'package:flutter/foundation.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; diff --git a/packages/stream_chat_flutter/lib/src/attachment/attachment_title.dart b/packages/stream_chat_flutter/lib/src/attachment/attachment_title.dart index f1369d49..4a85f2bb 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/attachment_title.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/attachment_title.dart @@ -18,14 +18,11 @@ class AttachmentTitle extends StatelessWidget { @override Widget build(BuildContext context) { - final normalizedTitleLink = attachment.titleLink?.replaceFirst( - RegExp(r'https?://(www\.)?'), - '', - ); + final ogScrapeUrl = attachment.ogScrapeUrl; return GestureDetector( onTap: () { - final titleLink = attachment.titleLink; - if (titleLink != null) launchURL(context, titleLink); + final ogScrapeUrl = attachment.ogScrapeUrl; + if (ogScrapeUrl != null) launchURL(context, ogScrapeUrl); }, child: Padding( padding: const EdgeInsets.all(8), @@ -42,8 +39,8 @@ class AttachmentTitle extends StatelessWidget { fontWeight: FontWeight.bold, ), ), - if (normalizedTitleLink != null) - Text(normalizedTitleLink, style: messageTheme.messageTextStyle), + if (ogScrapeUrl != null) + Text(ogScrapeUrl, style: messageTheme.messageTextStyle), ], ), ), diff --git a/packages/stream_chat_flutter/lib/src/attachment/url_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/url_attachment.dart index df05ba5e..8d4dfbfb 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/url_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/url_attachment.dart @@ -37,11 +37,11 @@ class UrlAttachment extends StatelessWidget { final chatThemeData = StreamChatTheme.of(context); return GestureDetector( onTap: () { - final titleLink = urlAttachment.titleLink; - if (titleLink != null) { + final ogScrapeUrl = urlAttachment.ogScrapeUrl; + if (ogScrapeUrl != null) { onLinkTap != null - ? onLinkTap!(titleLink) - : launchURL(context, titleLink); + ? onLinkTap!(ogScrapeUrl) + : launchURL(context, ogScrapeUrl); } }, child: Column( diff --git a/packages/stream_chat_flutter/lib/src/extension.dart b/packages/stream_chat_flutter/lib/src/extension.dart index f955ca99..cc41614c 100644 --- a/packages/stream_chat_flutter/lib/src/extension.dart +++ b/packages/stream_chat_flutter/lib/src/extension.dart @@ -225,3 +225,12 @@ extension UserListX on List { return entries.map((e) => e.key).toList(growable: false); } } + +/// Extensions on [Uri] +extension UriX on Uri { + /// Return the URI adding the http scheme if it is missing + Uri get withScheme { + if (hasScheme) return this; + return Uri.parse('http://${toString()}'); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_input.dart b/packages/stream_chat_flutter/lib/src/message_input.dart index afb2c749..e3d6747f 100644 --- a/packages/stream_chat_flutter/lib/src/message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input.dart @@ -480,6 +480,7 @@ class MessageInputState extends State { if (widget.editMessage == null) { child = Material( elevation: 8, + color: _messageInputTheme.inputBackgroundColor, child: child, ); } @@ -679,6 +680,7 @@ class MessageInputState extends State { gradient: _focusNode.hasFocus ? _messageInputTheme.activeBorderGradient : _messageInputTheme.idleBorderGradient, + color: _messageInputTheme.inputBackgroundColor, ), child: Padding( padding: const EdgeInsets.all(1.5), @@ -1272,7 +1274,7 @@ class MessageInputState extends State { Widget _buildReplyToMessage() { if (!_hasQuotedMessage) return const Offstage(); final containsUrl = widget.quotedMessage!.attachments - .any((element) => element.titleLink != null); + .any((element) => element.ogScrapeUrl != null); return QuotedMessageWidget( reverse: true, showBorder: !containsUrl, 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 02b258db..ed1ea56e 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -8,7 +8,6 @@ import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positi import 'package:stream_chat_flutter/src/extension.dart'; import 'package:stream_chat_flutter/src/swipeable.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:visibility_detector/visibility_detector.dart'; /// Widget builder for message /// [defaultMessageWidget] is the default [MessageWidget] configuration @@ -369,7 +368,7 @@ class _MessageListViewState extends State { double get _initialAlignment { final initialAlignment = widget.initialAlignment; if (initialAlignment != null) return initialAlignment; - return 0.1; + return streamChannel!.initialMessageId == null ? 0 : 0.1; } bool _isInitialMessage(String id) => streamChannel!.initialMessageId == id; @@ -561,6 +560,9 @@ class _MessageListViewState extends State { if (widget.reverse ? widget.headerBuilder == null : widget.footerBuilder == null) { + if (messages.isNotEmpty) { + return _buildDateDivider(messages.last); + } if (_isThreadConversation) return const Offstage(); return const SizedBox(height: 52); } @@ -585,21 +587,12 @@ class _MessageListViewState extends State { message = messages[i - 2]; nextMessage = messages[i - 1]; } + if (!Jiffy(message.createdAt.toLocal()).isSame( nextMessage.createdAt.toLocal(), Units.DAY, )) { - final divider = widget.dateDividerBuilder != null - ? widget.dateDividerBuilder!( - nextMessage.createdAt.toLocal(), - ) - : Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: DateDivider( - dateTime: nextMessage.createdAt.toLocal(), - ), - ); - return divider; + return _buildDateDivider(nextMessage); } final timeDiff = Jiffy(nextMessage.createdAt.toLocal()).diff( @@ -749,6 +742,20 @@ class _MessageListViewState extends State { return child; } + Widget _buildDateDivider(Message message) { + final divider = widget.dateDividerBuilder != null + ? widget.dateDividerBuilder!( + message.createdAt.toLocal(), + ) + : Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: DateDivider( + dateTime: message.createdAt.toLocal(), + ), + ); + return divider; + } + Widget _buildThreadSeparator() { if (widget.threadSeparatorBuilder != null) { return widget.threadSeparatorBuilder!.call(context); @@ -805,7 +812,11 @@ class _MessageListViewState extends State { index = _getBottomElementIndex(values); } - if (index == null) return const Offstage(); + if ((index == null) || + (!_isThreadConversation && index == itemCount - 2) || + (_isThreadConversation && index == itemCount - 1)) { + return const Offstage(); + } if (index <= 2 || index >= itemCount - 3) { if (widget.reverse) { @@ -884,7 +895,6 @@ class _MessageListViewState extends State { _scrollController!.jumpTo(index: 0); }); } else { - _showScrollToBottom.value = false; _scrollController!.scrollTo( index: 0, duration: const Duration(seconds: 1), @@ -946,26 +956,7 @@ class _MessageListViewState extends State { int index, ) { final messageWidget = buildMessage(message, messages, index); - return VisibilityDetector( - key: ValueKey('visibility: ${message.id}'), - onVisibilityChanged: (visibility) { - final isVisible = visibility.visibleBounds != Rect.zero; - if (isVisible) { - final channel = streamChannel.channel; - if (_upToDate && - channel.config?.readEvents == true && - channel.state!.unreadCount > 0) { - streamChannel.channel.markRead(); - } - } - if (mounted) { - if (_showScrollToBottom.value == isVisible) { - _showScrollToBottom.value = !isVisible; - } - } - }, - child: messageWidget, - ); + return messageWidget; } Widget buildParentMessage( @@ -1101,7 +1092,7 @@ class _MessageListViewState extends State { final isOnlyEmoji = message.text?.isOnlyEmoji ?? false; final hasUrlAttachment = - message.attachments.any((it) => it.titleLink != null); + message.attachments.any((it) => it.ogScrapeUrl != null); final borderSide = isOnlyEmoji || hasUrlAttachment || (isMyMessage && !hasFileAttachment) @@ -1284,6 +1275,8 @@ class _MessageListViewState extends State { _scrollController = widget.scrollController ?? ItemScrollController(); _itemPositionListener = widget.itemPositionListener ?? ItemPositionsListener.create(); + _itemPositionListener.itemPositions + .addListener(_handleItemPositionsChanged); _getOnThreadTap(); super.initState(); @@ -1332,6 +1325,34 @@ class _MessageListViewState extends State { super.didChangeDependencies(); } + void _handleItemPositionsChanged() { + final _itemPositions = _itemPositionListener.itemPositions.value.toList(); + final _firstItemIndex = + _itemPositions.indexWhere((element) => element.index == 1); + var _isFirstItemVisible = false; + if (_firstItemIndex != -1) { + final _firstItem = _itemPositions[_firstItemIndex]; + _isFirstItemVisible = + _firstItem.itemLeadingEdge > 0 && _firstItem.itemTrailingEdge < 1; + } + if (_isFirstItemVisible) { + // most recent message is visible + final channel = streamChannel?.channel; + if (channel != null) { + if (_upToDate && + channel.config?.readEvents == true && + channel.state!.unreadCount > 0) { + streamChannel!.channel.markRead(); + } + } + } + if (mounted) { + if (_showScrollToBottom.value == _isFirstItemVisible) { + _showScrollToBottom.value = !_isFirstItemVisible; + } + } + } + void _getOnThreadTap() { if (widget.onThreadTap != null) { _onThreadTap = (Message message) { @@ -1369,6 +1390,8 @@ class _MessageListViewState extends State { streamChannel!.reloadChannel(); } _messageNewListener?.cancel(); + _itemPositionListener.itemPositions + .removeListener(_handleItemPositionsChanged); super.dispose(); } } diff --git a/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart b/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart index 98e64c25..2127a98d 100644 --- a/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart @@ -236,6 +236,8 @@ class MessageReactionsModal extends StatelessWidget { reaction.user!.name.split(' ')[0], style: chatThemeData.textTheme.footnoteBold, textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + maxLines: 1, ), ], ), diff --git a/packages/stream_chat_flutter/lib/src/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget.dart index 3ffca84a..812a7595 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget.dart @@ -574,11 +574,11 @@ class _MessageWidgetState extends State bool get isOnlyEmoji => widget.message.text?.isOnlyEmoji == true; bool get hasNonUrlAttachments => widget.message.attachments - .where((it) => it.titleLink == null || it.type == 'giphy') + .where((it) => it.ogScrapeUrl == null || it.type == 'giphy') .isNotEmpty; bool get hasUrlAttachments => widget.message.attachments - .any((it) => it.titleLink != null && it.type != 'giphy'); + .any((it) => it.ogScrapeUrl != null && it.type != 'giphy'); bool get showBottomRow => showThreadReplyIndicator || @@ -999,9 +999,9 @@ class _MessageWidgetState extends State Widget _buildUrlAttachment() { final urlAttachment = widget.message.attachments - .firstWhere((element) => element.titleLink != null); + .firstWhere((element) => element.ogScrapeUrl != null); - final host = Uri.parse(urlAttachment.titleLink!).host; + final host = Uri.parse(urlAttachment.ogScrapeUrl!).withScheme.host; final splitList = host.split('.'); final hostName = splitList.length == 3 ? splitList[1] : splitList[0]; final hostDisplayName = urlAttachment.authorName?.capitalize() ?? @@ -1173,7 +1173,7 @@ class _MessageWidgetState extends State widget.message.attachments .where((element) => - (element.titleLink == null && element.type != null) || + (element.ogScrapeUrl == null && element.type != null) || element.type == 'giphy') .forEach((e) { if (attachmentGroups[e.type] == null) { diff --git a/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart b/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart index 131ca60b..8994eece 100644 --- a/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart @@ -97,7 +97,7 @@ class QuotedMessageWidget extends StatelessWidget { bool get _hasAttachments => message.attachments.isNotEmpty; bool get _containsLinkAttachment => - message.attachments.any((element) => element.titleLink != null); + message.attachments.any((element) => element.ogScrapeUrl != null); bool get _containsText => message.text?.isNotEmpty == true; @@ -201,7 +201,7 @@ class QuotedMessageWidget extends StatelessWidget { Attachment attachment; if (_containsLinkAttachment) { attachment = message.attachments.firstWhere( - (element) => element.titleLink != null, + (element) => element.ogScrapeUrl != null, ); child = _buildUrlAttachment(attachment); } else { diff --git a/packages/stream_chat_flutter/lib/src/utils.dart b/packages/stream_chat_flutter/lib/src/utils.dart index 2d661fbc..3825263d 100644 --- a/packages/stream_chat_flutter/lib/src/utils.dart +++ b/packages/stream_chat_flutter/lib/src/utils.dart @@ -8,9 +8,9 @@ import 'package:url_launcher/url_launcher.dart'; /// Launch URL Future launchURL(BuildContext context, String url) async { - if (await canLaunch(url)) { - await launch(url); - } else { + try { + await launch(Uri.parse(url).withScheme.toString()); + } catch (e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.translations.launchUrlError)), ); diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 2abc216f..4ebc8f9e 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.5.1 +version: 3.6.1 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -36,14 +36,13 @@ dependencies: rxdart: ^0.27.0 share_plus: ^4.0.1 shimmer: ^2.0.0 - stream_chat_flutter_core: ^3.5.1 + stream_chat_flutter_core: ^3.6.1 substring_highlight: ^1.0.26 synchronized: ^3.0.0 url_launcher: ^6.0.3 video_compress: ^3.0.0 video_player: ^2.1.0 - video_thumbnail: ^0.4.3 - visibility_detector: ^0.2.0 + video_thumbnail: ^0.5.0 flutter: assets: diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index d18b2ca0..a2a2c4d8 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -1,3 +1,11 @@ +## 3.6.1 + +- Updated `stream_chat` dependency to [`3.6.1`](https://pub.dev/packages/stream_chat/changelog). + +## 3.6.0 + +- Updated `stream_chat` dependency to [`3.6.0`](https://pub.dev/packages/stream_chat/changelog). + ## 3.5.1 - Updated `stream_chat` dependency to [`3.5.1`](https://pub.dev/packages/stream_chat/changelog). diff --git a/packages/stream_chat_flutter_core/pubspec.yaml b/packages/stream_chat_flutter_core/pubspec.yaml index 3fde274b..25acb90a 100644 --- a/packages/stream_chat_flutter_core/pubspec.yaml +++ b/packages/stream_chat_flutter_core/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat_flutter_core homepage: https://github.com/GetStream/stream-chat-flutter description: Stream Chat official Flutter SDK Core. Build your own chat experience using Dart and Flutter. -version: 3.5.1 +version: 3.6.1 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -16,7 +16,7 @@ dependencies: sdk: flutter meta: ^1.3.0 rxdart: ^0.27.0 - stream_chat: ^3.5.1 + stream_chat: ^3.6.1 dev_dependencies: dart_code_metrics: ^4.4.0 diff --git a/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart b/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart index 2dc61f18..f8dfe9e5 100644 --- a/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart +++ b/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart @@ -1,5 +1,4 @@ import 'package:flutter/foundation.dart'; -import 'package:logging/logging.dart' show LogRecord; import 'package:mutex/mutex.dart'; import 'package:stream_chat/stream_chat.dart';