Merge branch 'develop' into develop

This commit is contained in:
Salvatore Giordano
2022-04-26 16:07:48 +02:00
committed by GitHub
47 changed files with 870 additions and 263 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

@@ -64,30 +64,34 @@ The second type looks like this:
We can use a `Stack` for achieving this:
```dart
Scaffold(
body: Stack(
children: <Widget>[
// Add your video implementation here
ShaderMask(
shaderCallback: (rect) {
return LinearGradient(
Stack(
children: <Widget>[
// 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(),
],
),
),
],
),
```
@@ -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
@@ -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 Streams [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 Streams 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<void> 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 dont 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 [email protected]: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 <USER-ID>
```
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<void> 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);
```
+26 -1
View File
@@ -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.
@@ -1033,10 +1033,23 @@ class Channel {
return _client.deleteChannel(id!, type);
}
/// Removes all messages from the channel.
Future<EmptyResponse> 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<EmptyResponse> 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<Map<String, List<Message>>> _threadsController =
BehaviorSubject.seeded({});
set _threads(Map<String, List<Message>> v) {
_channel.client.chatPersistenceClient?.updateMessages(
set _threads(Map<String, List<Message>> 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.
+38 -20
View File
@@ -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<String, Object?> 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<ConnectionStatus>? _connectionStatusSubscription;
@@ -214,17 +213,18 @@ class StreamChatClient {
Stream<ConnectionStatus> 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<OwnUser> openConnection() async {
/// If [includeUserDetailsInConnectCall] is true it will include the current
/// user details in the connect call.
Future<OwnUser> 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<EmptyResponse> 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
@@ -265,10 +265,18 @@ class ChannelApi {
/// Removes all messages from the channel
Future<EmptyResponse> 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);
}
@@ -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,
);
@@ -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<String, dynamic> 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;
}
@@ -0,0 +1,17 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'channel_mute.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
ChannelMute _$ChannelMuteFromJson(Map<String, dynamic> json) => ChannelMute(
user: User.fromJson(json['user'] as Map<String, dynamic>),
channel: ChannelModel.fromJson(json['channel'] as Map<String, dynamic>),
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),
);
@@ -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<String, dynamic> 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;
}
@@ -8,7 +8,10 @@ part of 'mute.dart';
Mute _$MuteFromJson(Map<String, dynamic> json) => Mute(
user: User.fromJson(json['user'] as Map<String, dynamic>),
channel: ChannelModel.fromJson(json['channel'] as Map<String, dynamic>),
target: User.fromJson(json['target'] as Map<String, dynamic>),
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),
);
@@ -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<String>? teams,
List<Mute>? channelMutes,
List<ChannelMute>? channelMutes,
List<Device>? devices,
List<Mute>? mutes,
int? totalUnreadCount,
@@ -142,7 +143,7 @@ class OwnUser extends User {
/// List of channels muted by the user.
@JsonKey(includeIfNull: false)
final List<Mute> channelMutes;
final List<ChannelMute> channelMutes;
/// Total unread messages by the user.
@JsonKey(includeIfNull: false)
@@ -18,7 +18,7 @@ OwnUser _$OwnUserFromJson(Map<String, dynamic> json) => OwnUser(
totalUnreadCount: json['total_unread_count'] as int? ?? 0,
unreadChannels: json['unread_channels'] as int? ?? 0,
channelMutes: (json['channel_mutes'] as List<dynamic>?)
?.map((e) => Mute.fromJson(e as Map<String, dynamic>))
?.map((e) => ChannelMute.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
id: json['id'] as String,
@@ -197,6 +197,28 @@ abstract class ChatPersistenceClient {
/// Deletes all the members by channel [cids]
Future<void> deleteMembersByCids(List<String> cids);
/// Updates the channel [cid] threads data along with reactions and users.
Future<void> updateChannelThreads(
String cid,
Map<String, List<Message>> 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<void> updateChannelState(ChannelState channelState) =>
updateChannelStates([channelState]);
@@ -239,17 +261,8 @@ abstract class ChatPersistenceClient {
channelWithMessages[cid] = messages;
channelWithPinnedMessages[cid] = pinnedMessages;
List<Reaction> 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<Reaction> _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),
];
}
}
+16 -5
View File
@@ -147,12 +147,15 @@ class WebSocket with TimerHelper {
}
}
Future<Uri> _buildUri({bool refreshToken = false}) async {
Future<Uri> _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<Event> connect(User user) async {
Future<Event> 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<Event>();
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) {
+29 -30
View File
@@ -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';
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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
+74
View File
@@ -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"
}
+13 -55
View File
@@ -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"
@@ -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(),
@@ -481,14 +481,21 @@ void main() {
final path = '${_getChannelUrl(channelId, channelType)}/truncate';
when(() => client.post(path)).thenAnswer(
(_) async => successResponse(path, data: <String, dynamic>{}));
when(() => client.post(
path,
data: {},
))
.thenAnswer(
(_) async => successResponse(path, data: <String, dynamic>{}));
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);
});
@@ -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<User>());
expect(mute.channel, isA<ChannelModel>());
expect(mute.createdAt, DateTime.parse('2020-12-04T10:39:06.512021Z'));
expect(mute.updatedAt, DateTime.parse('2020-12-04T10:39:06.512021Z'));
});
});
}
@@ -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<ChannelModel>());
expect(mute.user, isA<User>());
expect(mute.target, isA<User>());
expect(mute.createdAt, DateTime.parse('2020-12-04T10:39:06.512021Z'));
expect(mute.updatedAt, DateTime.parse('2020-12-04T10:39:06.512021Z'));
});
});
}
@@ -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');
@@ -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);
+8 -2
View File
@@ -124,7 +124,10 @@ class FakeWebSocket extends Fake implements WebSocket {
Completer<Event>? connectionCompleter;
@override
Future<Event> connect(User user) async {
Future<Event> 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<Event>? connectionCompleter;
@override
Future<Event> connect(User user) async {
Future<Event> connect(
User user, {
bool? includeUserDetails = true,
}) async {
connectionStatus = ConnectionStatus.connecting;
const error = StreamWebSocketError('Error Connecting');
connectionCompleter = Completer()..completeError(error);
+20
View File
@@ -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
@@ -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
@@ -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';
@@ -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';
@@ -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),
],
),
),
@@ -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(
@@ -225,3 +225,12 @@ extension UserListX on List<User> {
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()}');
}
}
@@ -480,6 +480,7 @@ class MessageInputState extends State<MessageInput> {
if (widget.editMessage == null) {
child = Material(
elevation: 8,
color: _messageInputTheme.inputBackgroundColor,
child: child,
);
}
@@ -679,6 +680,7 @@ class MessageInputState extends State<MessageInput> {
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<MessageInput> {
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,
@@ -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<MessageListView> {
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<MessageListView> {
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<MessageListView> {
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<MessageListView> {
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<MessageListView> {
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<MessageListView> {
_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<MessageListView> {
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<MessageListView> {
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<MessageListView> {
_scrollController = widget.scrollController ?? ItemScrollController();
_itemPositionListener =
widget.itemPositionListener ?? ItemPositionsListener.create();
_itemPositionListener.itemPositions
.addListener(_handleItemPositionsChanged);
_getOnThreadTap();
super.initState();
@@ -1332,6 +1325,34 @@ class _MessageListViewState extends State<MessageListView> {
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<MessageListView> {
streamChannel!.reloadChannel();
}
_messageNewListener?.cancel();
_itemPositionListener.itemPositions
.removeListener(_handleItemPositionsChanged);
super.dispose();
}
}
@@ -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,
),
],
),
@@ -574,11 +574,11 @@ class _MessageWidgetState extends State<MessageWidget>
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<MessageWidget>
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<MessageWidget>
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) {
@@ -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 {
@@ -8,9 +8,9 @@ import 'package:url_launcher/url_launcher.dart';
/// Launch URL
Future<void> 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)),
);
+3 -4
View File
@@ -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:
@@ -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).
@@ -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
@@ -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';