Merge pull request #1625 from GetStream/release/6.4.0

This commit is contained in:
Sahil Kumar
2023-06-19 16:54:12 +05:30
committed by GitHub
51 changed files with 1841 additions and 559 deletions
+9
View File
@@ -1,3 +1,12 @@
## 6.4.0
🐞 Fixed
- [[#1293]](https://github.com/GetStream/stream-chat-flutter/issues/1293) Fixed wrong message order when sending
messages quickly.
- [[#1612]](https://github.com/GetStream/stream-chat-flutter/issues/1612) Fixed `Channel.isMutedStream` does not emit
when channel mute expires.
## 6.3.0 ## 6.3.0
🐞 Fixed 🐞 Fixed
+104 -45
View File
@@ -7,6 +7,7 @@ import 'package:rxdart/rxdart.dart';
import 'package:stream_chat/src/client/retry_queue.dart'; import 'package:stream_chat/src/client/retry_queue.dart';
import 'package:stream_chat/src/core/util/utils.dart'; import 'package:stream_chat/src/core/util/utils.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
import 'package:synchronized/synchronized.dart';
/// The maximum time the incoming [Event.typingStart] event is valid before a /// The maximum time the incoming [Event.typingStart] event is valid before a
/// [Event.typingStop] event is emitted automatically. /// [Event.typingStop] event is emitted automatically.
@@ -563,6 +564,8 @@ class Channel {
}); });
} }
final _sendMessageLock = Lock();
/// Send a [message] to this channel. /// Send a [message] to this channel.
/// ///
/// If [skipPush] is true the message will not send a push notification. /// If [skipPush] is true the message will not send a push notification.
@@ -586,7 +589,7 @@ class Channel {
); );
// ignore: parameter_assignments // ignore: parameter_assignments
message = message.copyWith( message = message.copyWith(
createdAt: message.createdAt, localCreatedAt: DateTime.now(),
user: _client.state.currentUser, user: _client.state.currentUser,
quotedMessage: quotedMessage, quotedMessage: quotedMessage,
status: MessageSendingStatus.sending, status: MessageSendingStatus.sending,
@@ -615,14 +618,21 @@ class Channel {
message = await attachmentsUploadCompleter.future; message = await attachmentsUploadCompleter.future;
} }
final response = await _client.sendMessage( // Wait for the previous sendMessage call to finish. Otherwise, the order
message, // of messages will not be maintained.
id!, final response = await _sendMessageLock.synchronized(
type, () => _client.sendMessage(
skipPush: skipPush, message,
skipEnrichUrl: skipEnrichUrl, id!,
type,
skipPush: skipPush,
skipEnrichUrl: skipEnrichUrl,
),
); );
state!.updateMessage(response.message);
final sentMessage = response.message.syncWith(message);
state!.updateMessage(sentMessage);
if (cooldown > 0) cooldownStartedAt = DateTime.now(); if (cooldown > 0) cooldownStartedAt = DateTime.now();
return response; return response;
} catch (e) { } catch (e) {
@@ -633,6 +643,8 @@ class Channel {
} }
} }
final _updateMessageLock = Lock();
/// Updates the [message] in this channel. /// Updates the [message] in this channel.
/// ///
/// Waits for a [_messageAttachmentsUploadCompleter] to complete /// Waits for a [_messageAttachmentsUploadCompleter] to complete
@@ -652,7 +664,7 @@ class Channel {
// ignore: parameter_assignments // ignore: parameter_assignments
message = message.copyWith( message = message.copyWith(
status: MessageSendingStatus.updating, status: MessageSendingStatus.updating,
updatedAt: message.updatedAt, localUpdatedAt: DateTime.now(),
attachments: message.attachments.map( attachments: message.attachments.map(
(it) { (it) {
if (it.uploadState.isSuccess) return it; if (it.uploadState.isSuccess) return it;
@@ -678,16 +690,20 @@ class Channel {
message = await attachmentsUploadCompleter.future; message = await attachmentsUploadCompleter.future;
} }
final response = await _client.updateMessage( // Wait for the previous update call to finish. Otherwise, the order of
message, // messages will not be maintained.
skipEnrichUrl: skipEnrichUrl, final response = await _updateMessageLock.synchronized(
() => _client.updateMessage(
message,
skipEnrichUrl: skipEnrichUrl,
),
); );
final m = response.message.copyWith( final updatedMessage = response.message
ownReactions: message.ownReactions, .syncWith(message)
); .copyWith(ownReactions: message.ownReactions);
state?.updateMessage(m); state?.updateMessage(updatedMessage);
return response; return response;
} catch (e) { } catch (e) {
@@ -714,16 +730,20 @@ class Channel {
bool skipEnrichUrl = false, bool skipEnrichUrl = false,
}) async { }) async {
try { try {
final response = await _client.partialUpdateMessage( // Wait for the previous update call to finish. Otherwise, the order of
message.id, // messages will not be maintained.
set: set, final response = await _updateMessageLock.synchronized(
unset: unset, () => _client.partialUpdateMessage(
skipEnrichUrl: skipEnrichUrl, message.id,
set: set,
unset: unset,
skipEnrichUrl: skipEnrichUrl,
),
); );
final updatedMessage = response.message.copyWith( final updatedMessage = response.message
ownReactions: message.ownReactions, .syncWith(message)
); .copyWith(ownReactions: message.ownReactions);
state?.updateMessage(updatedMessage); state?.updateMessage(updatedMessage);
@@ -736,6 +756,8 @@ class Channel {
} }
} }
final _deleteMessageLock = Lock();
/// Deletes the [message] from the channel. /// Deletes the [message] from the channel.
Future<EmptyResponse> deleteMessage(Message message, {bool? hard}) async { Future<EmptyResponse> deleteMessage(Message message, {bool? hard}) async {
final hardDelete = hard ?? false; final hardDelete = hard ?? false;
@@ -746,7 +768,7 @@ class Channel {
state!.deleteMessage( state!.deleteMessage(
message.copyWith( message.copyWith(
type: 'deleted', type: 'deleted',
deletedAt: message.deletedAt ?? DateTime.now(), localDeletedAt: DateTime.now(),
status: MessageSendingStatus.sent, status: MessageSendingStatus.sent,
), ),
hardDelete: hardDelete, hardDelete: hardDelete,
@@ -770,13 +792,18 @@ class Channel {
state?.deleteMessage(message, hardDelete: hardDelete); state?.deleteMessage(message, hardDelete: hardDelete);
final response = await _client.deleteMessage(message.id, hard: hard); // Wait for the previous delete call to finish. Otherwise, the order of
// messages will not be maintained.
state?.deleteMessage( final response = await _deleteMessageLock.synchronized(
message.copyWith(status: MessageSendingStatus.sent), () => _client.deleteMessage(message.id, hard: hard),
hardDelete: hardDelete,
); );
final deletedMessage = message.copyWith(
status: MessageSendingStatus.sent,
);
state?.deleteMessage(deletedMessage, hardDelete: hardDelete);
return response; return response;
} catch (e) { } catch (e) {
if (e is StreamChatNetworkError && e.isRetriable) { if (e is StreamChatNetworkError && e.isRetriable) {
@@ -1420,15 +1447,32 @@ class Channel {
); );
} }
// Timer to keep track of mute expiration. This is used to update the channel
// state when the mute expires.
Timer? _muteExpirationTimer;
/// Mutes the channel. /// Mutes the channel.
Future<EmptyResponse> mute({Duration? expiration}) { Future<EmptyResponse> mute({Duration? expiration}) {
_checkInitialized(); _checkInitialized();
// If there is a expiration set, we will set a timer to automatically unmute
// the channel when the mute expires.
if (expiration != null) {
_muteExpirationTimer?.cancel();
_muteExpirationTimer = Timer(expiration, unmute);
}
return _client.muteChannel(cid!, expiration: expiration); return _client.muteChannel(cid!, expiration: expiration);
} }
/// Unmute the channel. /// Unmute the channel.
Future<EmptyResponse> unmute() { Future<EmptyResponse> unmute() {
_checkInitialized(); _checkInitialized();
// Cancel the mute expiration timer if it is set.
_muteExpirationTimer?.cancel();
_muteExpirationTimer = null;
return _client.unmuteChannel(cid!); return _client.unmuteChannel(cid!);
} }
@@ -1558,6 +1602,7 @@ class Channel {
void dispose() { void dispose() {
client.state.removeChannel('$cid'); client.state.removeChannel('$cid');
state?.dispose(); state?.dispose();
_muteExpirationTimer?.cancel();
_keyStrokeHandler.cancel(); _keyStrokeHandler.cancel();
} }
@@ -1942,8 +1987,9 @@ class ChannelClientState {
) )
.listen((event) { .listen((event) {
final message = event.message!; final message = event.message!;
if (isUpToDate || final showInChannel =
(message.parentId != null && message.showInChannel != true)) { message.parentId != null && message.showInChannel != true;
if (isUpToDate || showInChannel) {
updateMessage(message); updateMessage(message);
} }
@@ -1960,10 +2006,10 @@ class ChannelClientState {
var newMessages = [...messages]; var newMessages = [...messages];
final oldIndex = newMessages.indexWhere((m) => m.id == message.id); final oldIndex = newMessages.indexWhere((m) => m.id == message.id);
if (oldIndex != -1) { if (oldIndex != -1) {
var updatedMessage = message; final oldMessage = newMessages[oldIndex];
var updatedMessage = message.syncWith(oldMessage);
// Add quoted message to the message if it is not present. // Add quoted message to the message if it is not present.
if (message.quotedMessageId != null && message.quotedMessage == null) { if (message.quotedMessageId != null && message.quotedMessage == null) {
final oldMessage = newMessages[oldIndex];
updatedMessage = updatedMessage.copyWith( updatedMessage = updatedMessage.copyWith(
quotedMessage: oldMessage.quotedMessage, quotedMessage: oldMessage.quotedMessage,
); );
@@ -1980,7 +2026,7 @@ class ChannelClientState {
return it.copyWith( return it.copyWith(
quotedMessage: updatedMessage.copyWith( quotedMessage: updatedMessage.copyWith(
type: 'deleted', type: 'deleted',
deletedAt: updatedMessage.deletedAt ?? DateTime.now(), deletedAt: DateTime.now(),
), ),
); );
}).toList(); }).toList();
@@ -2004,7 +2050,7 @@ class ChannelClientState {
} }
_channelState = _channelState.copyWith( _channelState = _channelState.copyWith(
messages: newMessages..sort(_sortByCreatedAt), messages: newMessages.sorted(_sortByCreatedAt),
pinnedMessages: newPinnedMessages, pinnedMessages: newPinnedMessages,
channel: _channelState.channel?.copyWith( channel: _channelState.channel?.copyWith(
lastMessageAt: message.createdAt, lastMessageAt: message.createdAt,
@@ -2226,7 +2272,7 @@ class ChannelClientState {
...newThreads[parentId]!.where( ...newThreads[parentId]!.where(
(newMessage) => !messages.any((m) => m.id == newMessage.id), (newMessage) => !messages.any((m) => m.id == newMessage.id),
), ),
]..sort(_sortByCreatedAt); ].sorted(_sortByCreatedAt);
} else { } else {
newThreads[parentId] = messages; newThreads[parentId] = messages;
} }
@@ -2245,15 +2291,10 @@ class ChannelClientState {
/// Update channelState with updated information. /// Update channelState with updated information.
void updateChannelState(ChannelState updatedState) { void updateChannelState(ChannelState updatedState) {
final _existingStateMessages = _channelState.messages ?? []; final _existingStateMessages = [...messages];
final _updatedStateMessages = updatedState.messages ?? [];
final newMessages = <Message>[ final newMessages = <Message>[
..._updatedStateMessages, ..._existingStateMessages.merge(updatedState.messages),
..._existingStateMessages ].sorted(_sortByCreatedAt);
.where((m) =>
!_updatedStateMessages.any((newMessage) => newMessage.id == m.id))
.toList(),
]..sort(_sortByCreatedAt);
final _existingStateWatchers = _channelState.watchers ?? []; final _existingStateWatchers = _channelState.watchers ?? [];
final _updatedStateWatchers = updatedState.watchers ?? []; final _updatedStateWatchers = updatedState.watchers ?? [];
@@ -2471,3 +2512,21 @@ bool _pinIsValid(Message message) {
final now = DateTime.now(); final now = DateTime.now();
return message.pinExpires!.isAfter(now); return message.pinExpires!.isAfter(now);
} }
extension on Iterable<Message> {
Iterable<Message> merge(Iterable<Message>? other) {
if (other == null) return this;
final messageMap = {for (final message in this) message.id: message};
for (final message in other) {
messageMap.update(
message.id,
message.syncWith,
ifAbsent: () => message,
);
}
return messageMap.values;
}
}
@@ -32,7 +32,7 @@ import 'package:stream_chat/src/event_type.dart';
import 'package:stream_chat/src/ws/connection_status.dart'; import 'package:stream_chat/src/ws/connection_status.dart';
import 'package:stream_chat/src/ws/websocket.dart'; import 'package:stream_chat/src/ws/websocket.dart';
import 'package:stream_chat/version.dart'; import 'package:stream_chat/version.dart';
import 'package:synchronized/extension.dart'; import 'package:synchronized/synchronized.dart';
/// Handler function used for logging records. Function requires a single /// Handler function used for logging records. Function requires a single
/// [LogRecord] as the only parameter. /// [LogRecord] as the only parameter.
@@ -526,10 +526,13 @@ class StreamChatClient {
event.type == eventType4); event.type == eventType4);
} }
// Lock to make sure only one sync process is running at a time.
final _syncLock = Lock();
/// Get the events missed while offline to sync the offline storage /// Get the events missed while offline to sync the offline storage
/// Will automatically fetch [cids] and [lastSyncedAt] if [persistenceEnabled] /// Will automatically fetch [cids] and [lastSyncedAt] if [persistenceEnabled]
Future<void> sync({List<String>? cids, DateTime? lastSyncAt}) { Future<void> sync({List<String>? cids, DateTime? lastSyncAt}) {
return synchronized(() async { return _syncLock.synchronized(() async {
final channels = cids ?? await chatPersistenceClient?.getChannelCids(); final channels = cids ?? await chatPersistenceClient?.getChannelCids();
if (channels == null || channels.isEmpty) { if (channels == null || channels.isEmpty) {
return; return;
@@ -64,8 +64,11 @@ class Message extends Equatable {
this.showInChannel, this.showInChannel,
this.command, this.command,
DateTime? createdAt, DateTime? createdAt,
this.localCreatedAt,
DateTime? updatedAt, DateTime? updatedAt,
this.deletedAt, this.localUpdatedAt,
DateTime? deletedAt,
this.localDeletedAt,
this.user, this.user,
this.pinned = false, this.pinned = false,
this.pinnedAt, this.pinnedAt,
@@ -76,8 +79,9 @@ class Message extends Equatable {
this.i18n, this.i18n,
}) : id = id ?? const Uuid().v4(), }) : id = id ?? const Uuid().v4(),
pinExpires = pinExpires?.toUtc(), pinExpires = pinExpires?.toUtc(),
_createdAt = createdAt, remoteCreatedAt = createdAt,
_updatedAt = updatedAt, remoteUpdatedAt = updatedAt,
remoteDeletedAt = deletedAt,
_quotedMessageId = quotedMessageId; _quotedMessageId = quotedMessageId;
/// Create a new instance from JSON. /// Create a new instance from JSON.
@@ -161,21 +165,49 @@ class Message extends Equatable {
@JsonKey(includeToJson: false) @JsonKey(includeToJson: false)
final String? command; final String? command;
final DateTime? _createdAt; /// Indicates when the message was created.
///
/// Reserved field indicating when the message was deleted. /// Returns the latest between [localCreatedAt] and [remoteCreatedAt].
/// If both are null, returns [DateTime.now].
@JsonKey(includeToJson: false) @JsonKey(includeToJson: false)
final DateTime? deletedAt; DateTime get createdAt => localCreatedAt ?? remoteCreatedAt ?? DateTime.now();
/// Reserved field indicating when the message was created. /// Indicates when the message was created locally.
@JsonKey(includeToJson: false, includeFromJson: false)
final DateTime? localCreatedAt;
/// Indicates when the message was created on the server.
@JsonKey(includeToJson: false, includeFromJson: false)
final DateTime? remoteCreatedAt;
/// Indicates when the message was updated last time.
///
/// Returns the latest between [localUpdatedAt] and [remoteUpdatedAt].
/// If both are null, returns [createdAt].
@JsonKey(includeToJson: false) @JsonKey(includeToJson: false)
DateTime get createdAt => _createdAt ?? DateTime.now(); DateTime get updatedAt => localUpdatedAt ?? remoteUpdatedAt ?? createdAt;
final DateTime? _updatedAt; /// Indicates when the message was updated locally.
@JsonKey(includeToJson: false, includeFromJson: false)
final DateTime? localUpdatedAt;
/// Reserved field indicating when the message was updated last time. /// Indicates when the message was updated on the server.
@JsonKey(includeToJson: false, includeFromJson: false)
final DateTime? remoteUpdatedAt;
/// Indicates when the message was deleted.
///
/// Returns the latest between [localDeletedAt] and [remoteDeletedAt].
@JsonKey(includeToJson: false) @JsonKey(includeToJson: false)
DateTime get updatedAt => _updatedAt ?? DateTime.now(); DateTime? get deletedAt => localDeletedAt ?? remoteDeletedAt;
/// Indicates when the message was deleted locally.
@JsonKey(includeToJson: false, includeFromJson: false)
final DateTime? localDeletedAt;
/// Indicates when the message was deleted on the server.
@JsonKey(includeToJson: false, includeFromJson: false)
final DateTime? remoteDeletedAt;
/// User who sent the message. /// User who sent the message.
@JsonKey(includeToJson: false) @JsonKey(includeToJson: false)
@@ -273,8 +305,11 @@ class Message extends Equatable {
bool? showInChannel, bool? showInChannel,
String? command, String? command,
DateTime? createdAt, DateTime? createdAt,
DateTime? localCreatedAt,
DateTime? updatedAt, DateTime? updatedAt,
DateTime? localUpdatedAt,
DateTime? deletedAt, DateTime? deletedAt,
DateTime? localDeletedAt,
User? user, User? user,
bool? pinned, bool? pinned,
DateTime? pinnedAt, DateTime? pinnedAt,
@@ -338,9 +373,12 @@ class Message extends Equatable {
threadParticipants: threadParticipants ?? this.threadParticipants, threadParticipants: threadParticipants ?? this.threadParticipants,
showInChannel: showInChannel ?? this.showInChannel, showInChannel: showInChannel ?? this.showInChannel,
command: command ?? this.command, command: command ?? this.command,
createdAt: createdAt ?? _createdAt, createdAt: createdAt ?? remoteCreatedAt,
updatedAt: updatedAt ?? _updatedAt, localCreatedAt: localCreatedAt ?? this.localCreatedAt,
deletedAt: deletedAt ?? this.deletedAt, updatedAt: updatedAt ?? remoteUpdatedAt,
localUpdatedAt: localUpdatedAt ?? this.localUpdatedAt,
deletedAt: deletedAt ?? remoteDeletedAt,
localDeletedAt: localDeletedAt ?? this.localDeletedAt,
user: user ?? this.user, user: user ?? this.user,
pinned: pinned ?? this.pinned, pinned: pinned ?? this.pinned,
pinnedAt: pinnedAt ?? this.pinnedAt, pinnedAt: pinnedAt ?? this.pinnedAt,
@@ -374,9 +412,12 @@ class Message extends Equatable {
threadParticipants: other.threadParticipants, threadParticipants: other.threadParticipants,
showInChannel: other.showInChannel, showInChannel: other.showInChannel,
command: other.command, command: other.command,
createdAt: other.createdAt, createdAt: other.remoteCreatedAt,
updatedAt: other.updatedAt, localCreatedAt: other.localCreatedAt,
deletedAt: other.deletedAt, updatedAt: other.remoteUpdatedAt,
localUpdatedAt: other.localUpdatedAt,
deletedAt: other.remoteDeletedAt,
localDeletedAt: other.localDeletedAt,
user: other.user, user: other.user,
pinned: other.pinned, pinned: other.pinned,
pinnedAt: other.pinnedAt, pinnedAt: other.pinnedAt,
@@ -387,6 +428,28 @@ class Message extends Equatable {
i18n: other.i18n, i18n: other.i18n,
); );
/// Returns a new [Message] that is [other] with local changes applied to it.
///
/// This ensures that the local sync changes are not lost when the message is
/// updated on the server.
///
/// For example, when a message is sent, it is immediately shown
/// optimistically in the UI. When the message is received from the server,
/// it will not contain the local changes. This method can be used to merge
/// the local changes back into the message.
///
/// This also helps in maintaining the order of the messages in the channel
/// when the messages are sorted by the [createdAt] field.
Message syncWith(Message? other) {
if (other == null) return this;
return copyWith(
localCreatedAt: other.localCreatedAt,
localUpdatedAt: other.localUpdatedAt,
localDeletedAt: other.localDeletedAt,
);
}
@override @override
List<Object?> get props => [ List<Object?> get props => [
id, id,
@@ -407,9 +470,12 @@ class Message extends Equatable {
shadowed, shadowed,
silent, silent,
command, command,
_createdAt, localCreatedAt,
_updatedAt, remoteCreatedAt,
deletedAt, localUpdatedAt,
remoteUpdatedAt,
localDeletedAt,
remoteDeletedAt,
user, user,
pinned, pinned,
pinnedAt, pinnedAt,
+1 -1
View File
@@ -3,4 +3,4 @@ import 'package:stream_chat/src/client/client.dart';
/// Current package version /// Current package version
/// Used in [StreamChatClient] to build the `x-stream-client` header /// Used in [StreamChatClient] to build the `x-stream-client` header
// ignore: constant_identifier_names // ignore: constant_identifier_names
const PACKAGE_VERSION = '6.3.0'; const PACKAGE_VERSION = '6.4.0';
+1 -1
View File
@@ -1,7 +1,7 @@
name: stream_chat name: stream_chat
homepage: https://getstream.io/ homepage: https://getstream.io/
description: The official Dart client for Stream Chat, a service for building chat applications. description: The official Dart client for Stream Chat, a service for building chat applications.
version: 6.3.0 version: 6.4.0
repository: https://github.com/GetStream/stream-chat-flutter repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
@@ -2481,6 +2481,31 @@ void main() {
)).called(1); )).called(1);
}); });
test('`.mute with expiration`', () async {
const expiration = Duration(seconds: 3);
when(() => client.muteChannel(
channelCid,
expiration: expiration,
)).thenAnswer((_) async => EmptyResponse());
when(() => client.unmuteChannel(channelCid))
.thenAnswer((_) async => EmptyResponse());
final res = await channel.mute(expiration: expiration);
expect(res, isNotNull);
verify(() => client.muteChannel(
channelCid,
expiration: expiration,
)).called(1);
// wait for expiration
await Future.delayed(expiration);
verify(() => client.unmuteChannel(channelCid)).called(1);
});
test('`.unmute`', () async { test('`.unmute`', () async {
when( when(
() => client.unmuteChannel(channelCid), () => client.unmuteChannel(channelCid),
+97
View File
@@ -1,3 +1,100 @@
## 6.4.0
🐞 Fixed
- [[#1600]](https://github.com/GetStream/stream-chat-flutter/issues/1600) Fixed type `ImageDecoderCallback` not found
error on pre-Flutter 3.10.0 versions.
- [[#1605]](https://github.com/GetStream/stream-chat-flutter/issues/1605) Fixed Null exception is thrown on message list
for unread messages when `ScrollToBottomButton` is pressed.
- [[#1615]](https://github.com/GetStream/stream-chat-flutter/issues/1615) Fixed `StreamAttachmentPickerBottomSheet` not
able to find the `StreamChatTheme` when used in nested MaterialApp.
✅ Added
- Added support for `StreamMessageInput.allowedAttachmentPickerTypes` to specify the allowed attachment picker types.
[#1601](https://github.com/GetStream/stream-chat-flutter/issues/1376)
```dart
StreamMessageInput(
...,
allowedAttachmentPickerTypes: const [
AttachmentPickerType.files,
AttachmentPickerType.images,
],
)
```
- Added support for `StreamMessageWidget.onConfirmDeleteTap` to override the default action on delete confirmation.
[#1604](https://github.com/GetStream/stream-chat-flutter/issues/1604)
```dart
StreamMessageWidget(
...,
onConfirmDeleteTap: (message) async {
final channel = StreamChannel.of(context).channel;
await channel.deleteMessage(message, hard: false);
},
)
```
- Added support for `StreamMessageWidget.quotedMessageBuilder` and `StreamMessageInput.quotedMessageBuilder` to override
the default quoted message widget. [#1547](https://github.com/GetStream/stream-chat-flutter/issues/1547)
```dart
StreamMessageWidget(
...,
quotedMessageBuilder: (context, message) {
return Container(
color: Colors.red,
child: Text('Quoted Message'),
);
},
)
```
- Added support for `StreamChannelAvatar.ownSpaceAvatarBuilder`, `StreamChannelAvatar.oneToOneAvatarBuilder` and
`StreamChannelAvatar.groupAvatarBuilder` to override the default avatar
widget.[#1614](https://github.com/GetStream/stream-chat-flutter/issues/1614)
```dart
StreamChannelAvatar(
...,
ownSpaceAvatarBuilder: (context, channel) {
return Container(
color: Colors.red,
child: Text('Own Space Avatar'),
);
},
oneToOneAvatarBuilder: (context, channel) {
return Container(
color: Colors.red,
child: Text('One to One Avatar'),
);
},
groupAvatarBuilder: (context, channel) {
return Container(
color: Colors.red,
child: Text('Group Avatar'),
);
},
)
```
- Added support for `StreamMessageInput.contentInsertionConfiguration` to specify the content insertion configuration.
[#1613](https://github.com/GetStream/stream-chat-flutter/issues/1613)
```dart
StreamMessageInput(
...,
contentInsertionConfiguration: ContentInsertionConfiguration(
onContentInserted: (content) {
// Do something with the content.
controller.addAttachment(...);
},
),
)
```
## 6.3.0 ## 6.3.0
🐞 Fixed 🐞 Fixed
@@ -0,0 +1,41 @@
// ignore_for_file: public_member_api_docs
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_example/debug/error_dialog.dart';
class DebugBanUser extends StatelessWidget {
const DebugBanUser({
super.key,
required this.client,
});
final StreamChatClient client;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: TextField(
decoration: const InputDecoration(
labelText: 'Ban User',
hintText: 'User Id',
isDense: true,
border: OutlineInputBorder(),
),
onSubmitted: (value) async {
final userId = value.trim();
try {
debugPrint('[banUser] userId: $userId');
final result = await client.banUser(userId);
debugPrint('[banUser] completed: $result');
} catch (e) {
debugPrint('[banUser] failed: $e');
showErrorDialog(context, e, 'Ban User');
}
},
),
);
}
}
@@ -0,0 +1,41 @@
// ignore_for_file: public_member_api_docs
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_example/debug/error_dialog.dart';
class DebugMuteUser extends StatelessWidget {
const DebugMuteUser({
super.key,
required this.client,
});
final StreamChatClient client;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: TextField(
decoration: const InputDecoration(
labelText: 'Mute User',
hintText: 'User Id',
isDense: true,
border: OutlineInputBorder(),
),
onSubmitted: (value) async {
final userId = value.trim();
try {
debugPrint('[muteUser] userId: $userId');
final result = await client.muteUser(userId);
debugPrint('[muteUser] completed: $result');
} catch (e) {
debugPrint('[muteUser] failed: $e');
showErrorDialog(context, e, 'Mute User');
}
},
),
);
}
}
@@ -0,0 +1,41 @@
// ignore_for_file: public_member_api_docs
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_example/debug/error_dialog.dart';
class DebugRemoveShadowBan extends StatelessWidget {
const DebugRemoveShadowBan({
super.key,
required this.client,
});
final StreamChatClient client;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: TextField(
decoration: const InputDecoration(
labelText: 'Remove Shadow Ban',
hintText: 'User Id',
isDense: true,
border: OutlineInputBorder(),
),
onSubmitted: (value) async {
final userId = value.trim();
try {
debugPrint('[removeShadowBan] userId: $userId');
final result = await client.removeShadowBan(userId);
debugPrint('[removeShadowBan] result: $result');
} catch (e) {
debugPrint('[removeShadowBan] failed: $e');
showErrorDialog(context, e, 'Remove Shadow Ban');
}
},
),
);
}
}
@@ -0,0 +1,41 @@
// ignore_for_file: public_member_api_docs
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_example/debug/error_dialog.dart';
class DebugShadowBan extends StatelessWidget {
const DebugShadowBan({
super.key,
required this.client,
});
final StreamChatClient client;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: TextField(
decoration: const InputDecoration(
labelText: 'Shadow Ban',
hintText: 'User Id',
isDense: true,
border: OutlineInputBorder(),
),
onSubmitted: (value) async {
final userId = value.trim();
try {
debugPrint('[shadowBan] userId: $userId');
final result = await client.shadowBan(userId);
debugPrint('[shadowBan] completed: $result');
} catch (e) {
debugPrint('[shadowBan] failed: $e');
showErrorDialog(context, e, 'Shadow Ban');
}
},
),
);
}
}
@@ -0,0 +1,41 @@
// ignore_for_file: public_member_api_docs
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_example/debug/error_dialog.dart';
class DebugUnbanUser extends StatelessWidget {
const DebugUnbanUser({
super.key,
required this.client,
});
final StreamChatClient client;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: TextField(
decoration: const InputDecoration(
labelText: 'Unban User',
hintText: 'User Id',
isDense: true,
border: OutlineInputBorder(),
),
onSubmitted: (value) async {
final userId = value.trim();
try {
debugPrint('[unbanUser] userId: $userId');
final result = await client.unbanUser(userId);
debugPrint('[unbanUser] completed: $result');
} catch (e) {
debugPrint('[unbanUser] failed: $e');
showErrorDialog(context, e, 'Unban User');
}
},
),
);
}
}
@@ -0,0 +1,41 @@
// ignore_for_file: public_member_api_docs
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_example/debug/error_dialog.dart';
class DebugUnmuteUser extends StatelessWidget {
const DebugUnmuteUser({
super.key,
required this.client,
});
final StreamChatClient client;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: TextField(
decoration: const InputDecoration(
labelText: 'Unmute User',
hintText: 'User Id',
isDense: true,
border: OutlineInputBorder(),
),
onSubmitted: (value) async {
final userId = value.trim();
try {
debugPrint('[unmuteUser] userId: $userId');
final result = await client.unmuteUser(userId);
debugPrint('[unmuteUser] completed: $result');
} catch (e) {
debugPrint('[unmuteUser] failed: $e');
showErrorDialog(context, e, 'Unmute User');
}
},
),
);
}
}
@@ -0,0 +1,126 @@
// ignore_for_file: public_member_api_docs
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_example/debug/actions/ban_user.dart';
import 'package:stream_chat_flutter_example/debug/actions/mute_user.dart';
import 'package:stream_chat_flutter_example/debug/actions/remove_shadow_ban.dart';
import 'package:stream_chat_flutter_example/debug/actions/shadow_ban.dart';
import 'package:stream_chat_flutter_example/debug/actions/unban_user.dart';
import 'package:stream_chat_flutter_example/debug/actions/unmute_user.dart';
import 'package:stream_chat_flutter_example/debug/members.dart';
import 'package:stream_chat_flutter_example/debug/mutes.dart';
class DebugChannelPage extends StatefulWidget {
const DebugChannelPage({super.key});
@override
State<StatefulWidget> createState() {
return _DebugChannelPageState();
}
}
class _DebugChannelPageState extends State<DebugChannelPage> {
late final Channel _channel = StreamChannel.of(context).channel;
StreamSubscription<ChannelState>? _channelSubscription;
StreamSubscription<OwnUser?>? _ownUserSubscription;
ChannelState? _channelState;
OwnUser? _ownUser;
@override
void initState() {
super.initState();
_channelSubscription = _channel.state!.channelStateStream.listen((state) {
setState(() => _channelState = state);
});
_ownUserSubscription =
_channel.client.state.currentUserStream.listen((ownUser) {
setState(() => _ownUser = ownUser);
});
}
@override
void dispose() {
super.dispose();
_channelSubscription?.cancel();
_ownUserSubscription?.cancel();
}
@override
Widget build(BuildContext context) {
final members =
_channelState?.members ?? _channel.state?.members ?? const [];
final mutes =
_ownUser?.mutes ?? _channel.client.state.currentUser?.mutes ?? const [];
//SingleChildScrollView
return Scaffold(
appBar: AppBar(
title: Text(_channel.name ?? _channel.cid ?? '?'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
DebugMe(client: _channel.client),
DebugMembers(members: members),
const SizedBox(height: 8),
DebugMutes(mutes: mutes),
const SizedBox(height: 16),
DebugMuteUser(client: _channel.client),
const SizedBox(height: 8),
DebugUnmuteUser(client: _channel.client),
const SizedBox(height: 8),
DebugBanUser(client: _channel.client),
const SizedBox(height: 8),
DebugUnbanUser(client: _channel.client),
const SizedBox(height: 8),
DebugShadowBan(client: _channel.client),
const SizedBox(height: 8),
DebugRemoveShadowBan(client: _channel.client),
],
),
),
);
}
}
class DebugMe extends StatelessWidget {
const DebugMe({
super.key,
required this.client,
});
final StreamChatClient client;
@override
Widget build(BuildContext context) {
return Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Row(
children: [
const Text(
'Me: ',
style: TextStyle(
color: Colors.red,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
client.state.currentUser?.id ?? '?',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
}
@@ -0,0 +1,28 @@
// ignore_for_file: public_member_api_docs
import 'package:flutter/material.dart';
Future<void> showErrorDialog(
BuildContext context,
Object e,
String operation,
) async {
return showDialog<void>(
context: context,
barrierDismissible: false, // user must tap button!
builder: (BuildContext context) {
return AlertDialog(
title: Text('$operation Failed'),
content: SingleChildScrollView(child: Text('$e')),
actions: [
TextButton(
child: const Text('Close'),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
},
);
}
@@ -0,0 +1,57 @@
// ignore_for_file: public_member_api_docs
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
class DebugMembers extends StatelessWidget {
const DebugMembers({
super.key,
required this.members,
});
final List<Member> members;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
color: Colors.orange,
padding: const EdgeInsets.all(8),
child: const Text('Members'),
),
Container(
color: Colors.orange,
height: 100,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: members.length,
itemBuilder: (BuildContext context, int index) {
final member = members[index];
return Padding(
padding: const EdgeInsets.all(8),
child: ColoredBox(
color: Colors.yellow,
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(member.user?.name ?? '?'),
Text('ID: ${member.user?.id ?? '?'}'),
Text('Ban: ${member.banned ? 'T' : 'F'}'),
Text('ShBan: ${member.shadowBanned ? 'T' : 'F'}'),
],
),
),
),
);
},
),
),
],
);
}
}
@@ -0,0 +1,55 @@
// ignore_for_file: public_member_api_docs
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
class DebugMutes extends StatelessWidget {
const DebugMutes({super.key, required this.mutes});
final List<Mute> mutes;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
color: Colors.lightBlueAccent,
padding: const EdgeInsets.all(8),
child: const Text('Mutes'),
),
Container(
color: Colors.lightBlueAccent,
height: 80,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: mutes.length,
itemBuilder: (BuildContext context, int index) {
final mute = mutes[index];
return Padding(
padding: const EdgeInsets.all(8),
child: ColoredBox(
color: Colors.yellow,
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('By: ${mute.user.name} (${mute.user.id})'),
Text(
'Who: ${mute.target.name} (${mute.target.id})',
),
Text('Exp: ${mute.expires}'),
],
),
),
),
);
},
),
),
],
);
}
}
@@ -1,8 +1,11 @@
// ignore_for_file: public_member_api_docs // ignore_for_file: public_member_api_docs
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:responsive_builder/responsive_builder.dart'; import 'package:responsive_builder/responsive_builder.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_example/debug/channel_page.dart';
import 'package:stream_chat_localizations/stream_chat_localizations.dart'; import 'package:stream_chat_localizations/stream_chat_localizations.dart';
Future<void> main() async { Future<void> main() async {
@@ -255,6 +258,19 @@ class _ChannelPageState extends State<ChannelPage> {
widget.onBackPressed!(context); widget.onBackPressed!(context);
} }
: null, : null,
onImageTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return StreamChannel(
channel: StreamChannel.of(context).channel,
child: const DebugChannelPage(),
);
},
),
);
},
showBackButton: widget.showBackButton, showBackButton: widget.showBackButton,
), ),
body: Column( body: Column(
@@ -1,6 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// WidgetBuilder for [StreamGroupAvatar].
typedef StreamGroupAvatarBuilder = Widget Function(
BuildContext context,
List<Member> members,
// ignore: avoid_positional_boolean_parameters
bool isSelected,
);
/// {@template streamGroupAvatar} /// {@template streamGroupAvatar}
/// Widget for constructing a group of images /// Widget for constructing a group of images
/// {@endtemplate} /// {@endtemplate}
@@ -2,6 +2,14 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// WidgetBuilder for [StreamUserAvatar].
typedef StreamUserAvatarBuilder = Widget Function(
BuildContext context,
User user,
// ignore: avoid_positional_boolean_parameters
bool isSelected,
);
/// {@template streamUserAvatar} /// {@template streamUserAvatar}
/// Displays a user's avatar. /// Displays a user's avatar.
/// {@endtemplate} /// {@endtemplate}
@@ -54,6 +54,9 @@ class StreamChannelAvatar extends StatelessWidget {
this.selected = false, this.selected = false,
this.selectionColor, this.selectionColor,
this.selectionThickness = 4, this.selectionThickness = 4,
this.ownSpaceAvatarBuilder,
this.oneToOneAvatarBuilder,
this.groupAvatarBuilder,
}) : assert( }) : assert(
channel.state != null, channel.state != null,
'Channel ${channel.id} is not initialized', 'Channel ${channel.id} is not initialized',
@@ -80,6 +83,21 @@ class StreamChannelAvatar extends StatelessWidget {
/// Thickness of selection image /// Thickness of selection image
final double selectionThickness; final double selectionThickness;
/// Builder to create avatar for own space channel.
///
/// Defaults to [StreamUserAvatar].
final StreamUserAvatarBuilder? ownSpaceAvatarBuilder;
/// Builder to create avatar for one to one channel.
///
/// Defaults to [StreamUserAvatar].
final StreamUserAvatarBuilder? oneToOneAvatarBuilder;
/// Builder to create avatar for group channel.
///
/// Defaults to [StreamGroupAvatar].
final StreamGroupAvatarBuilder? groupAvatarBuilder;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final client = channel.client.state; final client = channel.client.state;
@@ -146,15 +164,22 @@ class StreamChannelAvatar extends StatelessWidget {
return BetterStreamBuilder<User>( return BetterStreamBuilder<User>(
stream: client.currentUserStream.map((it) => it!), stream: client.currentUserStream.map((it) => it!),
initialData: currentUser, initialData: currentUser,
builder: (context, user) => StreamUserAvatar( builder: (context, user) {
borderRadius: borderRadius ?? previewTheme?.borderRadius, final ownSpaceBuilder = ownSpaceAvatarBuilder;
user: user, if (ownSpaceBuilder != null) {
constraints: constraints ?? previewTheme?.constraints, return ownSpaceBuilder(context, user, selected);
onTap: onTap != null ? (_) => onTap!() : null, }
selected: selected,
selectionColor: selectionColor ?? colorTheme.accentPrimary, return StreamUserAvatar(
selectionThickness: selectionThickness, borderRadius: borderRadius ?? previewTheme?.borderRadius,
), user: user,
constraints: constraints ?? previewTheme?.constraints,
onTap: onTap != null ? (_) => onTap!() : null,
selected: selected,
selectionColor: selectionColor ?? colorTheme.accentPrimary,
selectionThickness: selectionThickness,
);
},
); );
} }
@@ -169,18 +194,30 @@ class StreamChannelAvatar extends StatelessWidget {
), ),
), ),
initialData: member, initialData: member,
builder: (context, member) => StreamUserAvatar( builder: (context, member) {
borderRadius: borderRadius ?? previewTheme?.borderRadius, final oneToOneBuilder = oneToOneAvatarBuilder;
user: member.user!, if (oneToOneBuilder != null) {
constraints: constraints ?? previewTheme?.constraints, return oneToOneBuilder(context, member.user!, selected);
onTap: onTap != null ? (_) => onTap!() : null, }
selected: selected,
selectionColor: selectionColor ?? colorTheme.accentPrimary, return StreamUserAvatar(
selectionThickness: selectionThickness, borderRadius: borderRadius ?? previewTheme?.borderRadius,
), user: member.user!,
constraints: constraints ?? previewTheme?.constraints,
onTap: onTap != null ? (_) => onTap!() : null,
selected: selected,
selectionColor: selectionColor ?? colorTheme.accentPrimary,
selectionThickness: selectionThickness,
);
},
); );
} }
final groupBuilder = groupAvatarBuilder;
if (groupBuilder != null) {
return groupBuilder(context, otherMembers, selected);
}
// Group conversation // Group conversation
return StreamGroupAvatar( return StreamGroupAvatar(
channel: channel, channel: channel,
@@ -19,6 +19,7 @@ class MessageActionsModal extends StatefulWidget {
this.showDeleteMessage = true, this.showDeleteMessage = true,
this.showEditMessage = true, this.showEditMessage = true,
this.onReplyTap, this.onReplyTap,
this.onConfirmDeleteTap,
this.onThreadReplyTap, this.onThreadReplyTap,
this.showCopyMessage = true, this.showCopyMessage = true,
this.showReplyMessage = true, this.showReplyMessage = true,
@@ -44,6 +45,9 @@ class MessageActionsModal extends StatefulWidget {
/// The action to perform when "reply" is tapped /// The action to perform when "reply" is tapped
final OnMessageTap? onReplyTap; final OnMessageTap? onReplyTap;
/// The action to perform when delete confirmation button is tapped.
final Future<void> Function(Message)? onConfirmDeleteTap;
/// Message in focus for actions /// Message in focus for actions
final Message message; final Message message;
@@ -363,7 +367,12 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
if (answer == true) { if (answer == true) {
try { try {
Navigator.of(context).pop(); Navigator.of(context).pop();
await StreamChannel.of(context).channel.deleteMessage(widget.message); final onConfirmDeleteTap = widget.onConfirmDeleteTap;
if (onConfirmDeleteTap != null) {
await onConfirmDeleteTap(widget.message);
} else {
await StreamChannel.of(context).channel.deleteMessage(widget.message);
}
} catch (err) { } catch (err) {
_showErrorAlertBottomSheet(); _showErrorAlertBottomSheet();
} }
@@ -693,6 +693,7 @@ Widget mobileAttachmentPickerBuilder({
required BuildContext context, required BuildContext context,
required StreamAttachmentPickerController controller, required StreamAttachmentPickerController controller,
Iterable<AttachmentPickerOption>? customOptions, Iterable<AttachmentPickerOption>? customOptions,
List<AttachmentPickerType> allowedTypes = AttachmentPickerType.values,
ThumbnailSize attachmentThumbnailSize = const ThumbnailSize(400, 400), ThumbnailSize attachmentThumbnailSize = const ThumbnailSize(400, 400),
ThumbnailFormat attachmentThumbnailFormat = ThumbnailFormat.jpeg, ThumbnailFormat attachmentThumbnailFormat = ThumbnailFormat.jpeg,
int attachmentThumbnailQuality = 100, int attachmentThumbnailQuality = 100,
@@ -702,74 +703,76 @@ Widget mobileAttachmentPickerBuilder({
controller: controller, controller: controller,
onSendAttachments: Navigator.of(context).pop, onSendAttachments: Navigator.of(context).pop,
options: { options: {
if (customOptions != null) ...customOptions, ...{
AttachmentPickerOption( if (customOptions != null) ...customOptions,
key: 'gallery-picker', AttachmentPickerOption(
icon: StreamSvgIcon.pictures(size: 36).toIconThemeSvgIcon(), key: 'gallery-picker',
supportedTypes: [ icon: StreamSvgIcon.pictures(size: 36).toIconThemeSvgIcon(),
AttachmentPickerType.images, supportedTypes: [
AttachmentPickerType.videos, AttachmentPickerType.images,
], AttachmentPickerType.videos,
optionViewBuilder: (context, controller) { ],
final selectedIds = controller.value.map((it) => it.id); optionViewBuilder: (context, controller) {
return StreamGalleryPicker( final selectedIds = controller.value.map((it) => it.id);
selectedMediaItems: selectedIds, return StreamGalleryPicker(
mediaThumbnailSize: attachmentThumbnailSize, selectedMediaItems: selectedIds,
mediaThumbnailFormat: attachmentThumbnailFormat, mediaThumbnailSize: attachmentThumbnailSize,
mediaThumbnailQuality: attachmentThumbnailQuality, mediaThumbnailFormat: attachmentThumbnailFormat,
mediaThumbnailScale: attachmentThumbnailScale, mediaThumbnailQuality: attachmentThumbnailQuality,
onMediaItemSelected: (media) async { mediaThumbnailScale: attachmentThumbnailScale,
if (selectedIds.contains(media.id)) { onMediaItemSelected: (media) async {
return controller.removeAssetAttachment(media); if (selectedIds.contains(media.id)) {
} return controller.removeAssetAttachment(media);
return controller.addAssetAttachment(media); }
}, return controller.addAssetAttachment(media);
); },
}, );
), },
AttachmentPickerOption( ),
key: 'file-picker', AttachmentPickerOption(
icon: StreamSvgIcon.files(size: 36).toIconThemeSvgIcon(), key: 'file-picker',
supportedTypes: [AttachmentPickerType.files], icon: StreamSvgIcon.files(size: 36).toIconThemeSvgIcon(),
optionViewBuilder: (context, controller) { supportedTypes: [AttachmentPickerType.files],
return StreamFilePicker( optionViewBuilder: (context, controller) {
onFilePicked: (file) async { return StreamFilePicker(
if (file != null) await controller.addAttachment(file); onFilePicked: (file) async {
return Navigator.pop(context, controller.value); if (file != null) await controller.addAttachment(file);
}, return Navigator.pop(context, controller.value);
); },
}, );
), },
AttachmentPickerOption( ),
key: 'image-picker', AttachmentPickerOption(
icon: StreamSvgIcon.camera(size: 36).toIconThemeSvgIcon(), key: 'image-picker',
supportedTypes: [AttachmentPickerType.images], icon: StreamSvgIcon.camera(size: 36).toIconThemeSvgIcon(),
optionViewBuilder: (context, controller) { supportedTypes: [AttachmentPickerType.images],
return StreamImagePicker( optionViewBuilder: (context, controller) {
onImagePicked: (image) async { return StreamImagePicker(
if (image != null) { onImagePicked: (image) async {
await controller.addAttachment(image); if (image != null) {
} await controller.addAttachment(image);
return Navigator.pop(context, controller.value); }
}, return Navigator.pop(context, controller.value);
); },
}, );
), },
AttachmentPickerOption( ),
key: 'video-picker', AttachmentPickerOption(
icon: StreamSvgIcon.record(size: 36).toIconThemeSvgIcon(), key: 'video-picker',
supportedTypes: [AttachmentPickerType.videos], icon: StreamSvgIcon.record(size: 36).toIconThemeSvgIcon(),
optionViewBuilder: (context, controller) { supportedTypes: [AttachmentPickerType.videos],
return StreamVideoPicker( optionViewBuilder: (context, controller) {
onVideoPicked: (video) async { return StreamVideoPicker(
if (video != null) { onVideoPicked: (video) async {
await controller.addAttachment(video); if (video != null) {
} await controller.addAttachment(video);
return Navigator.pop(context, controller.value); }
}, return Navigator.pop(context, controller.value);
); },
}, );
), },
),
}..where((option) => option.supportedTypes.every(allowedTypes.contains)),
}, },
); );
} }
@@ -779,6 +782,7 @@ Widget webOrDesktopAttachmentPickerBuilder({
required BuildContext context, required BuildContext context,
required StreamAttachmentPickerController controller, required StreamAttachmentPickerController controller,
Iterable<WebOrDesktopAttachmentPickerOption>? customOptions, Iterable<WebOrDesktopAttachmentPickerOption>? customOptions,
List<AttachmentPickerType> allowedTypes = AttachmentPickerType.values,
ThumbnailSize attachmentThumbnailSize = const ThumbnailSize(400, 400), ThumbnailSize attachmentThumbnailSize = const ThumbnailSize(400, 400),
ThumbnailFormat attachmentThumbnailFormat = ThumbnailFormat.jpeg, ThumbnailFormat attachmentThumbnailFormat = ThumbnailFormat.jpeg,
int attachmentThumbnailQuality = 100, int attachmentThumbnailQuality = 100,
@@ -787,25 +791,27 @@ Widget webOrDesktopAttachmentPickerBuilder({
return StreamWebOrDesktopAttachmentPickerBottomSheet( return StreamWebOrDesktopAttachmentPickerBottomSheet(
controller: controller, controller: controller,
options: { options: {
if (customOptions != null) ...customOptions, ...{
WebOrDesktopAttachmentPickerOption( if (customOptions != null) ...customOptions,
key: 'image-picker', WebOrDesktopAttachmentPickerOption(
type: AttachmentPickerType.images, key: 'image-picker',
icon: StreamSvgIcon.pictures(size: 36).toIconThemeSvgIcon(), type: AttachmentPickerType.images,
title: context.translations.uploadAPhotoLabel, icon: StreamSvgIcon.pictures(size: 36).toIconThemeSvgIcon(),
), title: context.translations.uploadAPhotoLabel,
WebOrDesktopAttachmentPickerOption( ),
key: 'video-picker', WebOrDesktopAttachmentPickerOption(
type: AttachmentPickerType.videos, key: 'video-picker',
icon: StreamSvgIcon.record(size: 36).toIconThemeSvgIcon(), type: AttachmentPickerType.videos,
title: context.translations.uploadAVideoLabel, icon: StreamSvgIcon.record(size: 36).toIconThemeSvgIcon(),
), title: context.translations.uploadAVideoLabel,
WebOrDesktopAttachmentPickerOption( ),
key: 'file-picker', WebOrDesktopAttachmentPickerOption(
type: AttachmentPickerType.files, key: 'file-picker',
icon: StreamSvgIcon.files(size: 36).toIconThemeSvgIcon(), type: AttachmentPickerType.files,
title: context.translations.uploadAFileLabel, icon: StreamSvgIcon.files(size: 36).toIconThemeSvgIcon(),
), title: context.translations.uploadAFileLabel,
),
}.where((option) => option.supportedTypes.every(allowedTypes.contains)),
}, },
onOptionTap: (context, controller, option) async { onOptionTap: (context, controller, option) async {
final attachment = await StreamAttachmentHandler.instance.pickFile( final attachment = await StreamAttachmentHandler.instance.pickFile(
@@ -66,6 +66,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart';
Future<T?> showStreamAttachmentPickerModalBottomSheet<T>({ Future<T?> showStreamAttachmentPickerModalBottomSheet<T>({
required BuildContext context, required BuildContext context,
Iterable<AttachmentPickerOption>? customOptions, Iterable<AttachmentPickerOption>? customOptions,
List<AttachmentPickerType> allowedTypes = AttachmentPickerType.values,
List<Attachment>? initialAttachments, List<Attachment>? initialAttachments,
StreamAttachmentPickerController? controller, StreamAttachmentPickerController? controller,
Color? backgroundColor, Color? backgroundColor,
@@ -117,6 +118,7 @@ Future<T?> showStreamAttachmentPickerModalBottomSheet<T>({
return webOrDesktopAttachmentPickerBuilder.call( return webOrDesktopAttachmentPickerBuilder.call(
context: context, context: context,
controller: controller, controller: controller,
allowedTypes: allowedTypes,
customOptions: customOptions?.map( customOptions: customOptions?.map(
WebOrDesktopAttachmentPickerOption.fromAttachmentPickerOption, WebOrDesktopAttachmentPickerOption.fromAttachmentPickerOption,
), ),
@@ -130,6 +132,7 @@ Future<T?> showStreamAttachmentPickerModalBottomSheet<T>({
return mobileAttachmentPickerBuilder.call( return mobileAttachmentPickerBuilder.call(
context: context, context: context,
controller: controller, controller: controller,
allowedTypes: allowedTypes,
customOptions: customOptions, customOptions: customOptions,
attachmentThumbnailSize: attachmentThumbnailSize, attachmentThumbnailSize: attachmentThumbnailSize,
attachmentThumbnailFormat: attachmentThumbnailFormat, attachmentThumbnailFormat: attachmentThumbnailFormat,
@@ -20,7 +20,6 @@ class StreamQuotedMessageWidget extends StatelessWidget {
this.textLimit = 170, this.textLimit = 170,
this.attachmentThumbnailBuilders, this.attachmentThumbnailBuilders,
this.padding = const EdgeInsets.all(8), this.padding = const EdgeInsets.all(8),
this.onTap,
this.onQuotedMessageClear, this.onQuotedMessageClear,
}); });
@@ -46,9 +45,6 @@ class StreamQuotedMessageWidget extends StatelessWidget {
/// Padding around the widget /// Padding around the widget
final EdgeInsetsGeometry padding; final EdgeInsetsGeometry padding;
/// Callback for tap on widget
final GestureTapCallback? onTap;
/// Callback for clearing quoted messages. /// Callback for clearing quoted messages.
final VoidCallback? onQuotedMessageClear; final VoidCallback? onQuotedMessageClear;
@@ -77,19 +73,12 @@ class StreamQuotedMessageWidget extends StatelessWidget {
showOnlineStatus: false, showOnlineStatus: false,
), ),
]; ];
return MouseRegion( return Padding(
cursor: SystemMouseCursors.click, padding: padding,
child: GestureDetector( child: Row(
behavior: HitTestBehavior.opaque, crossAxisAlignment: CrossAxisAlignment.end,
onTap: onTap, mainAxisSize: MainAxisSize.min,
child: Padding( children: reverse ? children.reversed.toList() : children,
padding: padding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: reverse ? children.reversed.toList() : children,
),
),
), ),
); );
} }
@@ -258,23 +247,26 @@ class _ParseAttachments extends StatelessWidget {
child = attachmentBuilder(context, attachment); child = attachmentBuilder(context, attachment);
} }
} }
child = AbsorbPointer(child: child);
final isImageFile = attachment.title?.mimeType?.type == 'image';
final isVideoFile = attachment.title?.mimeType?.type == 'video';
return Material( return Material(
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
type: MaterialType.transparency, type: MaterialType.transparency,
shape: attachment.type == 'file' shape: attachment.type == 'file' && (!isImageFile && !isVideoFile)
? null ? null
: RoundedRectangleBorder( : RoundedRectangleBorder(
side: const BorderSide(width: 0, color: Colors.transparent), side: const BorderSide(width: 0, color: Colors.transparent),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: child, child: AbsorbPointer(child: child),
); );
} }
Map<String, QuotedMessageAttachmentThumbnailBuilder> Map<String, QuotedMessageAttachmentThumbnailBuilder>
get _defaultAttachmentBuilder { get _defaultAttachmentBuilder {
return { final builders = <String, QuotedMessageAttachmentThumbnailBuilder>{
'image': (_, attachment) { 'image': (_, attachment) {
return StreamImageAttachment( return StreamImageAttachment(
attachment: attachment, attachment: attachment,
@@ -315,16 +307,33 @@ class _ParseAttachments extends StatelessWidget {
fit: BoxFit.cover, fit: BoxFit.cover,
); );
}, },
'file': (_, attachment) {
return SizedBox(
height: 32,
width: 32,
child: getFileTypeImage(
attachment.extraData['mime_type'] as String?,
),
);
},
}; };
builders['file'] = (_, attachment) {
return SizedBox(
height: 32,
width: 32,
child: Builder(
builder: (context) {
final isImageFile = attachment.title?.mimeType?.type == 'image';
if (isImageFile) {
return builders['image']!(context, attachment);
}
final isVideoFile = attachment.title?.mimeType?.type == 'video';
if (isVideoFile) {
return builders['video']!(context, attachment);
}
return getFileTypeImage(
attachment.extraData['mime_type'] as String?,
);
},
),
);
};
return builders;
} }
} }
@@ -122,12 +122,14 @@ class StreamMessageInput extends StatefulWidget {
this.maxAttachmentSize = kDefaultMaxAttachmentSize, this.maxAttachmentSize = kDefaultMaxAttachmentSize,
this.onError, this.onError,
this.attachmentLimit = 10, this.attachmentLimit = 10,
this.allowedAttachmentPickerTypes = AttachmentPickerType.values,
this.onAttachmentLimitExceed, this.onAttachmentLimitExceed,
this.attachmentButtonBuilder, this.attachmentButtonBuilder,
this.commandButtonBuilder, this.commandButtonBuilder,
this.customAutocompleteTriggers = const [], this.customAutocompleteTriggers = const [],
this.mentionAllAppUsers = false, this.mentionAllAppUsers = false,
this.sendButtonBuilder, this.sendButtonBuilder,
this.quotedMessageBuilder,
this.shouldKeepFocusAfterMessage, this.shouldKeepFocusAfterMessage,
this.validator = _defaultValidator, this.validator = _defaultValidator,
this.restorationId, this.restorationId,
@@ -143,6 +145,7 @@ class StreamMessageInput extends StatefulWidget {
_defaultClearQuotedMessageKeyPredicate, _defaultClearQuotedMessageKeyPredicate,
this.ogPreviewFilter = _defaultOgPreviewFilter, this.ogPreviewFilter = _defaultOgPreviewFilter,
this.hintGetter = _defaultHintGetter, this.hintGetter = _defaultHintGetter,
this.contentInsertionConfiguration,
}); });
/// The predicate used to send a message on desktop/web /// The predicate used to send a message on desktop/web
@@ -233,6 +236,12 @@ class StreamMessageInput extends StatefulWidget {
/// A limit for the no. of attachments that can be sent with a single message. /// A limit for the no. of attachments that can be sent with a single message.
final int attachmentLimit; final int attachmentLimit;
/// The list of allowed attachment types which can be picked using the
/// attachment button.
///
/// By default, all the attachment types are allowed.
final List<AttachmentPickerType> allowedAttachmentPickerTypes;
/// A callback for when the [attachmentLimit] is exceeded. /// A callback for when the [attachmentLimit] is exceeded.
/// ///
/// This will override the default error alert behaviour. /// This will override the default error alert behaviour.
@@ -258,6 +267,9 @@ class StreamMessageInput extends StatefulWidget {
/// Builder for creating send button /// Builder for creating send button
final MessageRelatedBuilder? sendButtonBuilder; final MessageRelatedBuilder? sendButtonBuilder;
/// Builder for building quoted message
final Widget Function(BuildContext, Message)? quotedMessageBuilder;
/// Defines if the [StreamMessageInput] loses focuses after a message is sent. /// Defines if the [StreamMessageInput] loses focuses after a message is sent.
/// The default behaviour keeps focus until a command is enabled. /// The default behaviour keeps focus until a command is enabled.
final bool? shouldKeepFocusAfterMessage; final bool? shouldKeepFocusAfterMessage;
@@ -295,6 +307,9 @@ class StreamMessageInput extends StatefulWidget {
/// Returns the hint text for the message input. /// Returns the hint text for the message input.
final HintGetter hintGetter; final HintGetter hintGetter;
/// {@macro flutter.widgets.editableText.contentInsertionConfiguration}
final ContentInsertionConfiguration? contentInsertionConfiguration;
static String? _defaultHintGetter( static String? _defaultHintGetter(
BuildContext context, BuildContext context,
HintType type, HintType type,
@@ -771,8 +786,8 @@ class StreamMessageInputState extends State<StreamMessageInput>
Future<void> _onAttachmentButtonPressed() async { Future<void> _onAttachmentButtonPressed() async {
final attachments = await showStreamAttachmentPickerModalBottomSheet( final attachments = await showStreamAttachmentPickerModalBottomSheet(
context: context, context: context,
allowedTypes: widget.allowedAttachmentPickerTypes,
initialAttachments: _effectiveController.attachments, initialAttachments: _effectiveController.attachments,
useRootNavigator: true,
); );
if (attachments != null) { if (attachments != null) {
@@ -860,6 +875,8 @@ class StreamMessageInputState extends State<StreamMessageInput>
decoration: _getInputDecoration(context), decoration: _getInputDecoration(context),
textCapitalization: widget.textCapitalization, textCapitalization: widget.textCapitalization,
autocorrect: widget.autoCorrect, autocorrect: widget.autoCorrect,
contentInsertionConfiguration:
widget.contentInsertionConfiguration,
), ),
), ),
), ),
@@ -1118,14 +1135,20 @@ class StreamMessageInputState extends State<StreamMessageInput>
if (!_hasQuotedMessage) return const Offstage(); if (!_hasQuotedMessage) return const Offstage();
final containsUrl = _effectiveController.message.quotedMessage!.attachments final containsUrl = _effectiveController.message.quotedMessage!.attachments
.any((element) => element.titleLink != null); .any((element) => element.titleLink != null);
return StreamQuotedMessageWidget(
reverse: true, return widget.quotedMessageBuilder?.call(
showBorder: !containsUrl, context,
message: _effectiveController.message.quotedMessage!, _effectiveController.message.quotedMessage!,
messageTheme: _streamChatTheme.otherMessageTheme, ) ??
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), StreamQuotedMessageWidget(
onQuotedMessageClear: widget.onQuotedMessageCleared, reverse: true,
); showBorder: !containsUrl,
message: _effectiveController.message.quotedMessage!,
messageTheme: _streamChatTheme.otherMessageTheme,
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
onQuotedMessageClear: widget.onQuotedMessageCleared,
attachmentThumbnailBuilders: widget.attachmentThumbnailBuilders,
);
} }
Widget _buildAttachments() { Widget _buildAttachments() {
@@ -120,6 +120,7 @@ class StreamMessageTextField extends StatefulWidget {
this.restorationId, this.restorationId,
this.scribbleEnabled = true, this.scribbleEnabled = true,
this.enableIMEPersonalizedLearning = true, this.enableIMEPersonalizedLearning = true,
this.contentInsertionConfiguration,
}) : assert(obscuringCharacter.length == 1, ''), }) : assert(obscuringCharacter.length == 1, ''),
smartDashesType = smartDashesType ?? smartDashesType = smartDashesType ??
(obscureText ? SmartDashesType.disabled : SmartDashesType.enabled), (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled),
@@ -526,6 +527,9 @@ class StreamMessageTextField extends StatefulWidget {
/// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning} /// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning}
final bool enableIMEPersonalizedLearning; final bool enableIMEPersonalizedLearning;
/// {@macro flutter.widgets.editableText.contentInsertionConfiguration}
final ContentInsertionConfiguration? contentInsertionConfiguration;
@override @override
_StreamMessageTextFieldState createState() => _StreamMessageTextFieldState(); _StreamMessageTextFieldState createState() => _StreamMessageTextFieldState();
@@ -622,6 +626,9 @@ class StreamMessageTextField extends StatefulWidget {
properties.add(DiagnosticsProperty<bool>( properties.add(DiagnosticsProperty<bool>(
'enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, 'enableIMEPersonalizedLearning', enableIMEPersonalizedLearning,
defaultValue: true)); defaultValue: true));
properties.add(DiagnosticsProperty<ContentInsertionConfiguration>(
'contentInsertionConfiguration', contentInsertionConfiguration,
defaultValue: null));
} }
} }
@@ -727,6 +734,7 @@ class _StreamMessageTextFieldState extends State<StreamMessageTextField>
restorationId: widget.restorationId, restorationId: widget.restorationId,
scribbleEnabled: widget.scribbleEnabled, scribbleEnabled: widget.scribbleEnabled,
enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning, enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning,
contentInsertionConfiguration: widget.contentInsertionConfiguration,
); );
@override @override
@@ -852,20 +852,25 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
streamChannel!.channel.markRead(); streamChannel!.channel.markRead();
} }
final index = unreadCount > 0 ? unreadCount + 1 : 0; // If the channel is not up to date, we need to reload it before scrolling
// to the end of the list.
if (!_upToDate) { if (!_upToDate) {
_bottomPaginationActive = false; // Reset the pagination variables.
initialAlignment = 0;
initialIndex = 0; initialIndex = 0;
initialAlignment = 0;
_bottomPaginationActive = false;
// Reload the channel to get the latest messages.
await streamChannel!.reloadChannel(); await streamChannel!.reloadChannel();
WidgetsBinding.instance.addPostFrameCallback((_) { // Wait for the frame to be rendered with the updated channel state.
_scrollController!.jumpTo(index: index); await WidgetsBinding.instance.endOfFrame;
}); }
} else {
// Scroll to the end of the list.
if (_scrollController?.isAttached == true) {
_scrollController!.scrollTo( _scrollController!.scrollTo(
index: index, index: 0,
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
curve: Curves.easeInOut, curve: Curves.easeInOut,
); );
@@ -29,6 +29,7 @@ class MessageCard extends StatefulWidget {
this.borderSide, this.borderSide,
this.borderRadiusGeometry, this.borderRadiusGeometry,
this.textBuilder, this.textBuilder,
this.quotedMessageBuilder,
this.onLinkTap, this.onLinkTap,
this.onMentionTap, this.onMentionTap,
this.onQuotedMessageTap, this.onQuotedMessageTap,
@@ -82,6 +83,9 @@ class MessageCard extends StatefulWidget {
/// {@macro textBuilder} /// {@macro textBuilder}
final Widget Function(BuildContext, Message)? textBuilder; final Widget Function(BuildContext, Message)? textBuilder;
/// {@macro quotedMessageBuilder}
final Widget Function(BuildContext, Message)? quotedMessageBuilder;
/// {@macro onLinkTap} /// {@macro onLinkTap}
final void Function(String)? onLinkTap; final void Function(String)? onLinkTap;
@@ -129,8 +133,12 @@ class _MessageCardState extends State<MessageCard> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final onQuotedMessageTap = widget.onQuotedMessageTap;
final quotedMessageBuilder = widget.quotedMessageBuilder;
return Card( return Card(
elevation: 0, elevation: 0,
clipBehavior: Clip.hardEdge,
margin: EdgeInsets.symmetric( margin: EdgeInsets.symmetric(
horizontal: (widget.isFailedState ? 15.0 : 0.0) + horizontal: (widget.isFailedState ? 15.0 : 0.0) +
(widget.showUserAvatar == DisplayWidget.gone ? 0 : 4.0), (widget.showUserAvatar == DisplayWidget.gone ? 0 : 4.0),
@@ -150,15 +158,27 @@ class _MessageCardState extends State<MessageCard> {
maxWidth: widthLimit ?? double.infinity, maxWidth: widthLimit ?? double.infinity,
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (widget.hasQuotedMessage) if (widget.hasQuotedMessage)
QuotedMessage( MouseRegion(
reverse: widget.reverse, cursor: SystemMouseCursors.click,
message: widget.message, child: InkWell(
hasNonUrlAttachments: widget.hasNonUrlAttachments, onTap: !widget.message.quotedMessage!.isDeleted &&
onQuotedMessageTap: widget.onQuotedMessageTap, onQuotedMessageTap != null
? () => onQuotedMessageTap(widget.message.quotedMessageId)
: null,
child: quotedMessageBuilder?.call(
context,
widget.message.quotedMessage!,
) ??
QuotedMessage(
reverse: widget.reverse,
message: widget.message,
hasNonUrlAttachments: widget.hasNonUrlAttachments,
),
),
), ),
if (widget.hasNonUrlAttachments) if (widget.hasNonUrlAttachments)
ParseAttachments( ParseAttachments(
@@ -61,6 +61,7 @@ class StreamMessageWidget extends StatefulWidget {
this.showInChannelIndicator = false, this.showInChannelIndicator = false,
this.onReplyTap, this.onReplyTap,
this.onThreadTap, this.onThreadTap,
this.onConfirmDeleteTap,
this.showUsername = true, this.showUsername = true,
this.showTimestamp = true, this.showTimestamp = true,
this.showReactions = true, this.showReactions = true,
@@ -78,6 +79,7 @@ class StreamMessageWidget extends StatefulWidget {
this.onMessageActions, this.onMessageActions,
this.onShowMessage, this.onShowMessage,
this.userAvatarBuilder, this.userAvatarBuilder,
this.quotedMessageBuilder,
this.editMessageInputBuilder, this.editMessageInputBuilder,
this.textBuilder, this.textBuilder,
@Deprecated(''' @Deprecated('''
@@ -307,6 +309,11 @@ class StreamMessageWidget extends StatefulWidget {
/// {@endtemplate} /// {@endtemplate}
final void Function(Message)? onReplyTap; final void Function(Message)? onReplyTap;
/// {@template onDeleteTap}
/// The function called when delete confirmation button is tapped.
/// {@endtemplate}
final Future<void> Function(Message)? onConfirmDeleteTap;
/// {@template editMessageInputBuilder} /// {@template editMessageInputBuilder}
/// Widget builder for edit message layout /// Widget builder for edit message layout
/// {@endtemplate} /// {@endtemplate}
@@ -348,6 +355,11 @@ class StreamMessageWidget extends StatefulWidget {
/// {@endtemplate} /// {@endtemplate}
final Widget Function(BuildContext, User)? userAvatarBuilder; final Widget Function(BuildContext, User)? userAvatarBuilder;
/// {@template quotedMessageBuilder}
/// Widget builder for building quoted message
/// {@endtemplate}
final Widget Function(BuildContext, Message)? quotedMessageBuilder;
/// {@template message} /// {@template message}
/// The message to display. /// The message to display.
/// {@endtemplate} /// {@endtemplate}
@@ -568,8 +580,10 @@ class StreamMessageWidget extends StatefulWidget {
void Function(User)? onMentionTap, void Function(User)? onMentionTap,
void Function(Message)? onThreadTap, void Function(Message)? onThreadTap,
void Function(Message)? onReplyTap, void Function(Message)? onReplyTap,
Future<void> Function(Message)? onConfirmDeleteTap,
Widget Function(BuildContext, Message)? editMessageInputBuilder, Widget Function(BuildContext, Message)? editMessageInputBuilder,
Widget Function(BuildContext, Message)? textBuilder, Widget Function(BuildContext, Message)? textBuilder,
Widget Function(BuildContext, Message)? quotedMessageBuilder,
@Deprecated(''' @Deprecated('''
Use [bottomRowBuilderWithDefaultWidget] instead. Use [bottomRowBuilderWithDefaultWidget] instead.
Will be removed in the next major version. Will be removed in the next major version.
@@ -659,9 +673,11 @@ class StreamMessageWidget extends StatefulWidget {
onMentionTap: onMentionTap ?? this.onMentionTap, onMentionTap: onMentionTap ?? this.onMentionTap,
onThreadTap: onThreadTap ?? this.onThreadTap, onThreadTap: onThreadTap ?? this.onThreadTap,
onReplyTap: onReplyTap ?? this.onReplyTap, onReplyTap: onReplyTap ?? this.onReplyTap,
onConfirmDeleteTap: onConfirmDeleteTap ?? this.onConfirmDeleteTap,
editMessageInputBuilder: editMessageInputBuilder:
editMessageInputBuilder ?? this.editMessageInputBuilder, editMessageInputBuilder ?? this.editMessageInputBuilder,
textBuilder: textBuilder ?? this.textBuilder, textBuilder: textBuilder ?? this.textBuilder,
quotedMessageBuilder: quotedMessageBuilder ?? this.quotedMessageBuilder,
bottomRowBuilderWithDefaultWidget: _bottomRowBuilderWithDefaultWidget, bottomRowBuilderWithDefaultWidget: _bottomRowBuilderWithDefaultWidget,
onMessageActions: onMessageActions ?? this.onMessageActions, onMessageActions: onMessageActions ?? this.onMessageActions,
message: message ?? this.message, message: message ?? this.message,
@@ -957,6 +973,7 @@ class _StreamMessageWidgetState extends State<StreamMessageWidget>
borderSide: widget.borderSide, borderSide: widget.borderSide,
borderRadiusGeometry: widget.borderRadiusGeometry, borderRadiusGeometry: widget.borderRadiusGeometry,
textBuilder: widget.textBuilder, textBuilder: widget.textBuilder,
quotedMessageBuilder: widget.quotedMessageBuilder,
onLinkTap: widget.onLinkTap, onLinkTap: widget.onLinkTap,
onMentionTap: widget.onMentionTap, onMentionTap: widget.onMentionTap,
onQuotedMessageTap: widget.onQuotedMessageTap, onQuotedMessageTap: widget.onQuotedMessageTap,
@@ -1098,16 +1115,21 @@ class _StreamMessageWidgetState extends State<StreamMessageWidget>
), ),
onClick: () async { onClick: () async {
Navigator.of(context, rootNavigator: true).pop(); Navigator.of(context, rootNavigator: true).pop();
final deleted = await showDialog( final deleted = await showDialog<bool?>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (_) => const DeleteMessageDialog(), builder: (_) => const DeleteMessageDialog(),
); );
if (deleted) { if (deleted == true) {
try { try {
await StreamChannel.of(context) final onConfirmDeleteTap = widget.onConfirmDeleteTap;
.channel if (onConfirmDeleteTap != null) {
.deleteMessage(widget.message); await onConfirmDeleteTap(widget.message);
} else {
await StreamChannel.of(context)
.channel
.deleteMessage(widget.message);
}
} catch (e) { } catch (e) {
showDialog( showDialog(
context: context, context: context,
@@ -1197,21 +1219,4 @@ class _StreamMessageWidgetState extends State<StreamMessageWidget>
), ),
); );
} }
void retryMessage(BuildContext context) {
final channel = StreamChannel.of(context).channel;
if (widget.message.status == MessageSendingStatus.failed) {
channel.sendMessage(widget.message);
return;
}
if (widget.message.status == MessageSendingStatus.failed_update) {
channel.updateMessage(widget.message);
return;
}
if (widget.message.status == MessageSendingStatus.failed_delete) {
channel.deleteMessage(widget.message);
return;
}
}
} }
@@ -63,6 +63,7 @@ class MessageWidgetContent extends StatelessWidget {
this.onMentionTap, this.onMentionTap,
this.onLinkTap, this.onLinkTap,
this.textBuilder, this.textBuilder,
this.quotedMessageBuilder,
@Deprecated(''' @Deprecated('''
Use [bottomRowBuilderWithDefaultWidget] instead. Use [bottomRowBuilderWithDefaultWidget] instead.
Will be removed in the next major version. Will be removed in the next major version.
@@ -170,6 +171,9 @@ class MessageWidgetContent extends StatelessWidget {
/// {@macro textBuilder} /// {@macro textBuilder}
final Widget Function(BuildContext, Message)? textBuilder; final Widget Function(BuildContext, Message)? textBuilder;
/// {@macro quotedMessageBuilder}
final Widget Function(BuildContext, Message)? quotedMessageBuilder;
/// {@macro showReactionPickerIndicator} /// {@macro showReactionPickerIndicator}
final bool showReactionPickerIndicator; final bool showReactionPickerIndicator;
@@ -351,6 +355,8 @@ class MessageWidgetContent extends StatelessWidget {
onMentionTap: onMentionTap, onMentionTap: onMentionTap,
onLinkTap: onLinkTap, onLinkTap: onLinkTap,
textBuilder: textBuilder, textBuilder: textBuilder,
quotedMessageBuilder:
quotedMessageBuilder,
borderRadiusGeometry: borderRadiusGeometry:
borderRadiusGeometry, borderRadiusGeometry,
borderSide: borderSide, borderSide: borderSide,
@@ -7,63 +7,41 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// ///
/// Used in [QuotedMessageCard]. Should not be used elsewhere. /// Used in [QuotedMessageCard]. Should not be used elsewhere.
/// {@endtemplate} /// {@endtemplate}
class QuotedMessage extends StatefulWidget { class QuotedMessage extends StatelessWidget {
/// {@macro quotedMessage} /// {@macro quotedMessage}
const QuotedMessage({ const QuotedMessage({
super.key, super.key,
required this.message, required this.message,
required this.reverse, required this.reverse,
required this.hasNonUrlAttachments, required this.hasNonUrlAttachments,
this.onQuotedMessageTap,
}); });
/// {@macro message} /// {@macro message}
final Message message; final Message message;
/// {@macro onQuotedMessageTap}
final OnQuotedMessageTap? onQuotedMessageTap;
/// {@macro reverse} /// {@macro reverse}
final bool reverse; final bool reverse;
/// {@macro hasNonUrlAttachments} /// {@macro hasNonUrlAttachments}
final bool hasNonUrlAttachments; final bool hasNonUrlAttachments;
@override
State<QuotedMessage> createState() => _QuotedMessageState();
}
class _QuotedMessageState extends State<QuotedMessage> {
late StreamChatState _streamChat;
late StreamChatThemeData _streamChatTheme;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_streamChatTheme = StreamChatTheme.of(context);
_streamChat = StreamChat.of(context);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isMyMessage = widget.message.user?.id == _streamChat.currentUser?.id; final streamChat = StreamChat.of(context);
final onTap = widget.message.quotedMessage?.isDeleted != true && final chatThemeData = StreamChatTheme.of(context);
widget.onQuotedMessageTap != null
? () => widget.onQuotedMessageTap!(widget.message.quotedMessageId) final isMyMessage = message.user?.id == streamChat.currentUser?.id;
: null;
final chatThemeData = _streamChatTheme;
return StreamQuotedMessageWidget( return StreamQuotedMessageWidget(
onTap: onTap, message: message.quotedMessage!,
message: widget.message.quotedMessage!,
messageTheme: isMyMessage messageTheme: isMyMessage
? chatThemeData.otherMessageTheme ? chatThemeData.otherMessageTheme
: chatThemeData.ownMessageTheme, : chatThemeData.ownMessageTheme,
reverse: widget.reverse, reverse: reverse,
padding: EdgeInsets.only( padding: EdgeInsets.only(
right: 8, right: 8,
left: 8, left: 8,
top: 8, top: 8,
bottom: widget.hasNonUrlAttachments ? 8 : 0, bottom: hasNonUrlAttachments ? 8 : 0,
), ),
); );
} }
@@ -202,9 +202,10 @@ class MediaThumbnailProvider extends ImageProvider<MediaThumbnailProvider> {
} }
@override @override
ImageStreamCompleter loadImage( @Deprecated('Will get replaced by loadImage in the next major version.')
ImageStreamCompleter loadBuffer(
MediaThumbnailProvider key, MediaThumbnailProvider key,
ImageDecoderCallback decode, DecoderBufferCallback decode,
) { ) {
return MultiFrameImageStreamCompleter( return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode), codec: _loadAsync(key, decode),
@@ -219,9 +220,10 @@ class MediaThumbnailProvider extends ImageProvider<MediaThumbnailProvider> {
); );
} }
@Deprecated('Will get replaced by loadImage in the next major version.')
Future<ui.Codec> _loadAsync( Future<ui.Codec> _loadAsync(
MediaThumbnailProvider key, MediaThumbnailProvider key,
ImageDecoderCallback decode, DecoderBufferCallback decode,
) async { ) async {
assert(key == this, '$key is not $this'); assert(key == this, '$key is not $this');
final bytes = await media.thumbnailDataWithSize( final bytes = await media.thumbnailDataWithSize(
@@ -157,6 +157,7 @@ class StreamUserListTile extends StatelessWidget {
trailing: selected ? selectedWidget : null, trailing: selected ? selectedWidget : null,
title: title, title: title,
subtitle: subtitle, subtitle: subtitle,
tileColor: tileColor,
); );
} }
} }
+2 -2
View File
@@ -1,7 +1,7 @@
name: stream_chat_flutter name: stream_chat_flutter
homepage: https://github.com/GetStream/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. description: Stream Chat official Flutter SDK. Build your own chat experience using Dart and Flutter.
version: 6.3.0 version: 6.4.0
repository: https://github.com/GetStream/stream-chat-flutter repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
@@ -38,7 +38,7 @@ dependencies:
rxdart: ^0.27.0 rxdart: ^0.27.0
share_plus: ^6.3.0 share_plus: ^6.3.0
shimmer: ^3.0.0 shimmer: ^3.0.0
stream_chat_flutter_core: ^6.3.0 stream_chat_flutter_core: ^6.4.0
synchronized: ^3.0.0 synchronized: ^3.0.0
thumblr: ^0.0.4 thumblr: ^0.0.4
url_launcher: ^6.1.0 url_launcher: ^6.1.0
@@ -1,3 +1,7 @@
## 6.4.0
- Updated `stream_chat` dependency to [`6.4.0`](https://pub.dev/packages/stream_chat/changelog).
## 6.3.0 ## 6.3.0
- Updated `stream_chat` dependency to [`6.3.0`](https://pub.dev/packages/stream_chat/changelog). - Updated `stream_chat` dependency to [`6.3.0`](https://pub.dev/packages/stream_chat/changelog).
@@ -108,7 +108,7 @@ class StreamChannelListEventHandler {
final channels = [...controller.currentItems]; final channels = [...controller.currentItems];
final channelIndex = channels.indexWhere((it) => it.cid == channelCid); final channelIndex = channels.indexWhere((it) => it.cid == channelCid);
if (channelIndex <= 0) { if (channelIndex < 0) {
// If the channel is not in the list, It might be hidden. // If the channel is not in the list, It might be hidden.
// So, we just refresh the list. // So, we just refresh the list.
await controller.refresh(resetValue: false); await controller.refresh(resetValue: false);
@@ -1,7 +1,7 @@
name: stream_chat_flutter_core name: stream_chat_flutter_core
homepage: https://github.com/GetStream/stream-chat-flutter homepage: https://github.com/GetStream/stream-chat-flutter
description: Stream Chat official Flutter SDK Core. Build your own chat experience using Dart and Flutter. description: Stream Chat official Flutter SDK Core. Build your own chat experience using Dart and Flutter.
version: 6.3.0 version: 6.4.0
repository: https://github.com/GetStream/stream-chat-flutter repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
@@ -17,7 +17,7 @@ dependencies:
freezed_annotation: ^2.0.3 freezed_annotation: ^2.0.3
meta: ^1.8.0 meta: ^1.8.0
rxdart: ^0.27.0 rxdart: ^0.27.0
stream_chat: ^6.3.0 stream_chat: ^6.4.0
dev_dependencies: dev_dependencies:
build_runner: ^2.3.3 build_runner: ^2.3.3
@@ -1,3 +1,7 @@
## 5.4.0
* Updated `stream_chat_flutter` dependency to [`6.4.0`](https://pub.dev/packages/stream_chat_flutter/changelog).
## 5.3.0 ## 5.3.0
* Updated `stream_chat_flutter` dependency to [`6.3.0`](https://pub.dev/packages/stream_chat_flutter/changelog). * Updated `stream_chat_flutter` dependency to [`6.3.0`](https://pub.dev/packages/stream_chat_flutter/changelog).
@@ -1,6 +1,6 @@
name: stream_chat_localizations name: stream_chat_localizations
description: The Official localizations for Stream Chat Flutter, a service for building chat applications description: The Official localizations for Stream Chat Flutter, a service for building chat applications
version: 5.3.0 version: 5.4.0
homepage: https://github.com/GetStream/stream-chat-flutter homepage: https://github.com/GetStream/stream-chat-flutter
repository: https://github.com/GetStream/stream-chat-flutter repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
@@ -14,7 +14,7 @@ dependencies:
sdk: flutter sdk: flutter
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
stream_chat_flutter: ^6.3.0 stream_chat_flutter: ^6.4.0
dev_dependencies: dev_dependencies:
dart_code_metrics: ^5.7.2 dart_code_metrics: ^5.7.2
@@ -1,3 +1,7 @@
## 6.4.0
- Updated `stream_chat` dependency to [`6.4.0`](https://pub.dev/packages/stream_chat/changelog).
## 6.3.0 ## 6.3.0
- Updated `stream_chat` dependency to [`6.3.0`](https://pub.dev/packages/stream_chat/changelog). - Updated `stream_chat` dependency to [`6.3.0`](https://pub.dev/packages/stream_chat/changelog).
@@ -5,7 +5,6 @@ import 'package:stream_chat/stream_chat.dart';
import 'package:stream_chat_persistence/src/db/drift_chat_database.dart'; import 'package:stream_chat_persistence/src/db/drift_chat_database.dart';
import 'package:stream_chat_persistence/src/entity/messages.dart'; import 'package:stream_chat_persistence/src/entity/messages.dart';
import 'package:stream_chat_persistence/src/entity/users.dart'; import 'package:stream_chat_persistence/src/entity/users.dart';
import 'package:stream_chat_persistence/src/mapper/mapper.dart'; import 'package:stream_chat_persistence/src/mapper/mapper.dart';
part 'message_dao.g.dart'; part 'message_dao.g.dart';
@@ -49,7 +49,7 @@ class DriftChatDatabase extends _$DriftChatDatabase {
// you should bump this number whenever you change or add a table definition. // you should bump this number whenever you change or add a table definition.
@override @override
int get schemaVersion => 11; int get schemaVersion => 12;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
File diff suppressed because it is too large Load Diff
@@ -53,14 +53,52 @@ class Messages extends Table {
/// A used command name. /// A used command name.
TextColumn get command => text().nullable()(); TextColumn get command => text().nullable()();
/// The DateTime when the message was created. /// The DateTime on which the message was created.
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); ///
/// Returns the latest between [localCreatedAt] and [remoteCreatedAt].
/// If both are null, returns [currentDateAndTime].
Expression<DateTime> get createdAt {
return coalesce<DateTime>(
[localCreatedAt, remoteCreatedAt, currentDateAndTime],
);
}
/// The DateTime when the message was updated last time. /// The DateTime on which the message was created on the client.
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); DateTimeColumn get localCreatedAt => dateTime().nullable()();
/// The DateTime when the message was deleted. /// The DateTime on which the message was created on the server.
DateTimeColumn get deletedAt => dateTime().nullable()(); DateTimeColumn get remoteCreatedAt => dateTime().nullable()();
/// The DateTime on which the message was updated last time.
///
/// Returns the latest between [localUpdatedAt] and [remoteUpdatedAt].
/// If both are null, returns [createdAt].
Expression<DateTime> get updatedAt {
return coalesce<DateTime>(
[localUpdatedAt, remoteUpdatedAt, createdAt],
);
}
/// The DateTime on which the message was updated on the client.
DateTimeColumn get localUpdatedAt => dateTime().nullable()();
/// The DateTime on which the message was updated on the server.
DateTimeColumn get remoteUpdatedAt => dateTime().nullable()();
/// The DateTime on which the message was deleted.
///
/// Returns the latest between [localDeletedAt] and [remoteDeletedAt].
Expression<DateTime> get deletedAt {
return coalesce<DateTime>(
[localDeletedAt, remoteDeletedAt],
);
}
/// The DateTime on which the message was deleted on the client.
DateTimeColumn get localDeletedAt => dateTime().nullable()();
/// The DateTime on which the message was deleted on the server.
DateTimeColumn get remoteDeletedAt => dateTime().nullable()();
/// Id of the User who sent the message /// Id of the User who sent the message
TextColumn get userId => text().nullable()(); TextColumn get userId => text().nullable()();
@@ -21,9 +21,13 @@ extension MessageEntityX on MessageEntity {
final json = jsonDecode(it); final json = jsonDecode(it);
return Attachment.fromData(json); return Attachment.fromData(json);
}).toList(), }).toList(),
createdAt: createdAt,
extraData: extraData ?? <String, Object>{}, extraData: extraData ?? <String, Object>{},
updatedAt: updatedAt, createdAt: remoteCreatedAt,
localCreatedAt: localCreatedAt,
updatedAt: remoteUpdatedAt,
localUpdatedAt: localUpdatedAt,
deletedAt: remoteDeletedAt,
localDeletedAt: localDeletedAt,
id: id, id: id,
type: type, type: type,
status: status, status: status,
@@ -37,7 +41,6 @@ extension MessageEntityX on MessageEntity {
showInChannel: showInChannel, showInChannel: showInChannel,
text: messageText, text: messageText,
user: user, user: user,
deletedAt: deletedAt,
pinned: pinned, pinned: pinned,
pinnedAt: pinnedAt, pinnedAt: pinnedAt,
pinExpires: pinExpires, pinExpires: pinExpires,
@@ -59,7 +62,8 @@ extension MessageX on Message {
parentId: parentId, parentId: parentId,
quotedMessageId: quotedMessageId, quotedMessageId: quotedMessageId,
command: command, command: command,
createdAt: createdAt, remoteCreatedAt: remoteCreatedAt,
localCreatedAt: localCreatedAt,
shadowed: shadowed, shadowed: shadowed,
showInChannel: showInChannel, showInChannel: showInChannel,
replyCount: replyCount, replyCount: replyCount,
@@ -67,10 +71,12 @@ extension MessageX on Message {
reactionCounts: reactionCounts, reactionCounts: reactionCounts,
mentionedUsers: mentionedUsers.map(jsonEncode).toList(), mentionedUsers: mentionedUsers.map(jsonEncode).toList(),
status: status, status: status,
updatedAt: updatedAt, remoteUpdatedAt: remoteUpdatedAt,
localUpdatedAt: localUpdatedAt,
extraData: extraData, extraData: extraData,
userId: user?.id, userId: user?.id,
deletedAt: deletedAt, remoteDeletedAt: remoteDeletedAt,
localDeletedAt: localDeletedAt,
messageText: text, messageText: text,
pinned: pinned, pinned: pinned,
pinnedAt: pinnedAt, pinnedAt: pinnedAt,
@@ -21,9 +21,13 @@ extension PinnedMessageEntityX on PinnedMessageEntity {
final json = jsonDecode(it); final json = jsonDecode(it);
return Attachment.fromData(json); return Attachment.fromData(json);
}).toList(), }).toList(),
createdAt: createdAt,
extraData: extraData ?? <String, Object>{}, extraData: extraData ?? <String, Object>{},
updatedAt: updatedAt, createdAt: remoteCreatedAt,
localCreatedAt: localCreatedAt,
updatedAt: remoteUpdatedAt,
localUpdatedAt: localUpdatedAt,
deletedAt: remoteDeletedAt,
localDeletedAt: localDeletedAt,
id: id, id: id,
type: type, type: type,
status: status, status: status,
@@ -37,7 +41,6 @@ extension PinnedMessageEntityX on PinnedMessageEntity {
showInChannel: showInChannel, showInChannel: showInChannel,
text: messageText, text: messageText,
user: user, user: user,
deletedAt: deletedAt,
pinned: pinned, pinned: pinned,
pinnedAt: pinnedAt, pinnedAt: pinnedAt,
pinExpires: pinExpires, pinExpires: pinExpires,
@@ -60,7 +63,8 @@ extension PMessageX on Message {
parentId: parentId, parentId: parentId,
quotedMessageId: quotedMessageId, quotedMessageId: quotedMessageId,
command: command, command: command,
createdAt: createdAt, remoteCreatedAt: remoteCreatedAt,
localCreatedAt: localCreatedAt,
shadowed: shadowed, shadowed: shadowed,
showInChannel: showInChannel, showInChannel: showInChannel,
replyCount: replyCount, replyCount: replyCount,
@@ -68,10 +72,12 @@ extension PMessageX on Message {
reactionCounts: reactionCounts, reactionCounts: reactionCounts,
mentionedUsers: mentionedUsers.map(jsonEncode).toList(), mentionedUsers: mentionedUsers.map(jsonEncode).toList(),
status: status, status: status,
updatedAt: updatedAt, remoteUpdatedAt: remoteUpdatedAt,
localUpdatedAt: localUpdatedAt,
extraData: extraData, extraData: extraData,
userId: user?.id, userId: user?.id,
deletedAt: deletedAt, remoteDeletedAt: remoteDeletedAt,
localDeletedAt: localDeletedAt,
messageText: text, messageText: text,
pinned: pinned, pinned: pinned,
pinnedAt: pinnedAt, pinnedAt: pinnedAt,
@@ -1,7 +1,7 @@
name: stream_chat_persistence name: stream_chat_persistence
homepage: https://github.com/GetStream/stream-chat-flutter homepage: https://github.com/GetStream/stream-chat-flutter
description: Official Stream Chat Persistence library. Build your own chat experience using Dart and Flutter. description: Official Stream Chat Persistence library. Build your own chat experience using Dart and Flutter.
version: 6.3.0 version: 6.4.0
repository: https://github.com/GetStream/stream-chat-flutter repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
@@ -18,7 +18,7 @@ dependencies:
path: ^1.8.2 path: ^1.8.2
path_provider: ^2.0.1 path_provider: ^2.0.1
sqlite3_flutter_libs: ^0.5.0 sqlite3_flutter_libs: ^0.5.0
stream_chat: ^6.3.0 stream_chat: ^6.4.0
dev_dependencies: dev_dependencies:
build_runner: ^2.3.3 build_runner: ^2.3.3
@@ -38,7 +38,8 @@ void main() {
parentId: 'testParentId', parentId: 'testParentId',
quotedMessageId: quotedMessage.id, quotedMessageId: quotedMessage.id,
command: 'testCommand', command: 'testCommand',
createdAt: DateTime.now(), localCreatedAt: DateTime.now(),
remoteCreatedAt: DateTime.now().add(const Duration(seconds: 1)),
shadowed: math.Random().nextBool(), shadowed: math.Random().nextBool(),
showInChannel: math.Random().nextBool(), showInChannel: math.Random().nextBool(),
replyCount: 33, replyCount: 33,
@@ -52,10 +53,12 @@ void main() {
jsonEncode(User(id: 'testuser')), jsonEncode(User(id: 'testuser')),
], ],
status: MessageSendingStatus.sent, status: MessageSendingStatus.sent,
updatedAt: DateTime.now(), localUpdatedAt: DateTime.now(),
remoteUpdatedAt: DateTime.now().add(const Duration(seconds: 1)),
extraData: {'extra_test_data': 'extraData'}, extraData: {'extra_test_data': 'extraData'},
userId: user.id, userId: user.id,
deletedAt: DateTime.now(), localDeletedAt: DateTime.now(),
remoteDeletedAt: DateTime.now().add(const Duration(seconds: 1)),
messageText: 'Hello', messageText: 'Hello',
pinned: true, pinned: true,
pinExpires: DateTime.now().toUtc(), pinExpires: DateTime.now().toUtc(),
@@ -81,7 +84,8 @@ void main() {
expect(message.parentId, entity.parentId); expect(message.parentId, entity.parentId);
expect(message.quotedMessageId, entity.quotedMessageId); expect(message.quotedMessageId, entity.quotedMessageId);
expect(message.command, entity.command); expect(message.command, entity.command);
expect(message.createdAt, isSameDateAs(entity.createdAt)); expect(message.localCreatedAt, isSameDateAs(entity.localCreatedAt));
expect(message.remoteCreatedAt, isSameDateAs(entity.remoteCreatedAt));
expect(message.shadowed, entity.shadowed); expect(message.shadowed, entity.shadowed);
expect(message.showInChannel, entity.showInChannel); expect(message.showInChannel, entity.showInChannel);
for (var i = 0; i < message.mentionedUsers.length; i++) { for (var i = 0; i < message.mentionedUsers.length; i++) {
@@ -93,10 +97,12 @@ void main() {
expect(message.reactionScores, entity.reactionScores); expect(message.reactionScores, entity.reactionScores);
expect(message.reactionCounts, entity.reactionCounts); expect(message.reactionCounts, entity.reactionCounts);
expect(message.status, entity.status); expect(message.status, entity.status);
expect(message.updatedAt, isSameDateAs(entity.updatedAt)); expect(message.localUpdatedAt, isSameDateAs(entity.localUpdatedAt));
expect(message.remoteUpdatedAt, isSameDateAs(entity.remoteUpdatedAt));
expect(message.extraData, entity.extraData); expect(message.extraData, entity.extraData);
expect(message.user!.id, entity.userId); expect(message.user!.id, entity.userId);
expect(message.deletedAt, isSameDateAs(entity.deletedAt)); expect(message.localDeletedAt, isSameDateAs(entity.localDeletedAt));
expect(message.remoteDeletedAt, isSameDateAs(entity.remoteDeletedAt));
expect(message.text, entity.messageText); expect(message.text, entity.messageText);
expect(message.pinned, entity.pinned); expect(message.pinned, entity.pinned);
expect(message.pinExpires, isSameDateAs(entity.pinExpires)); expect(message.pinExpires, isSameDateAs(entity.pinExpires));
@@ -144,7 +150,8 @@ void main() {
parentId: 'testParentId', parentId: 'testParentId',
quotedMessageId: quotedMessage.id, quotedMessageId: quotedMessage.id,
command: 'testCommand', command: 'testCommand',
createdAt: DateTime.now(), localCreatedAt: DateTime.now(),
createdAt: DateTime.now().add(const Duration(seconds: 1)),
shadowed: math.Random().nextBool(), shadowed: math.Random().nextBool(),
showInChannel: math.Random().nextBool(), showInChannel: math.Random().nextBool(),
replyCount: 33, replyCount: 33,
@@ -157,10 +164,12 @@ void main() {
(prev, curr) => (prev, curr) =>
prev?..update(curr.type, (value) => value + 1, ifAbsent: () => 1), prev?..update(curr.type, (value) => value + 1, ifAbsent: () => 1),
), ),
updatedAt: DateTime.now(), localUpdatedAt: DateTime.now(),
updatedAt: DateTime.now().add(const Duration(seconds: 1)),
extraData: const {'extra_test_data': 'extraData'}, extraData: const {'extra_test_data': 'extraData'},
user: user, user: user,
deletedAt: DateTime.now(), localDeletedAt: DateTime.now(),
deletedAt: DateTime.now().add(const Duration(seconds: 1)),
text: 'Hello', text: 'Hello',
pinned: true, pinned: true,
pinExpires: DateTime.now(), pinExpires: DateTime.now(),
@@ -179,7 +188,8 @@ void main() {
expect(entity.parentId, message.parentId); expect(entity.parentId, message.parentId);
expect(entity.quotedMessageId, message.quotedMessageId); expect(entity.quotedMessageId, message.quotedMessageId);
expect(entity.command, message.command); expect(entity.command, message.command);
expect(entity.createdAt, isSameDateAs(message.createdAt)); expect(entity.localCreatedAt, isSameDateAs(message.localCreatedAt));
expect(entity.remoteCreatedAt, isSameDateAs(message.remoteCreatedAt));
expect(entity.shadowed, message.shadowed); expect(entity.shadowed, message.shadowed);
expect(entity.showInChannel, message.showInChannel); expect(entity.showInChannel, message.showInChannel);
expect(entity.replyCount, message.replyCount); expect(entity.replyCount, message.replyCount);
@@ -188,10 +198,12 @@ void main() {
expect(entity.reactionScores, message.reactionScores); expect(entity.reactionScores, message.reactionScores);
expect(entity.reactionCounts, message.reactionCounts); expect(entity.reactionCounts, message.reactionCounts);
expect(entity.status, message.status); expect(entity.status, message.status);
expect(entity.updatedAt, isSameDateAs(message.updatedAt)); expect(entity.localUpdatedAt, isSameDateAs(message.localUpdatedAt));
expect(entity.remoteUpdatedAt, isSameDateAs(message.remoteUpdatedAt));
expect(entity.extraData, message.extraData); expect(entity.extraData, message.extraData);
expect(entity.userId, message.user!.id); expect(entity.userId, message.user!.id);
expect(entity.deletedAt, isSameDateAs(message.deletedAt)); expect(entity.localDeletedAt, isSameDateAs(message.localDeletedAt));
expect(entity.remoteDeletedAt, isSameDateAs(message.remoteDeletedAt));
expect(entity.messageText, message.text); expect(entity.messageText, message.text);
expect(entity.pinned, message.pinned); expect(entity.pinned, message.pinned);
expect(entity.pinExpires, isSameDateAs(message.pinExpires)); expect(entity.pinExpires, isSameDateAs(message.pinExpires));
@@ -38,7 +38,8 @@ void main() {
parentId: 'testParentId', parentId: 'testParentId',
quotedMessageId: quotedMessage.id, quotedMessageId: quotedMessage.id,
command: 'testCommand', command: 'testCommand',
createdAt: DateTime.now(), localCreatedAt: DateTime.now(),
remoteCreatedAt: DateTime.now().add(const Duration(seconds: 1)),
shadowed: math.Random().nextBool(), shadowed: math.Random().nextBool(),
showInChannel: math.Random().nextBool(), showInChannel: math.Random().nextBool(),
replyCount: 33, replyCount: 33,
@@ -48,12 +49,16 @@ void main() {
(prev, curr) => (prev, curr) =>
prev?..update(curr.type, (value) => value + 1, ifAbsent: () => 1), prev?..update(curr.type, (value) => value + 1, ifAbsent: () => 1),
), ),
mentionedUsers: [], mentionedUsers: [
jsonEncode(User(id: 'testuser')),
],
status: MessageSendingStatus.sent, status: MessageSendingStatus.sent,
updatedAt: DateTime.now(), localUpdatedAt: DateTime.now(),
remoteUpdatedAt: DateTime.now().add(const Duration(seconds: 1)),
extraData: {'extra_test_data': 'extraData'}, extraData: {'extra_test_data': 'extraData'},
userId: user.id, userId: user.id,
deletedAt: DateTime.now(), localDeletedAt: DateTime.now(),
remoteDeletedAt: DateTime.now().add(const Duration(seconds: 1)),
messageText: 'Hello', messageText: 'Hello',
pinned: true, pinned: true,
pinExpires: DateTime.now().toUtc(), pinExpires: DateTime.now().toUtc(),
@@ -79,17 +84,25 @@ void main() {
expect(message.parentId, entity.parentId); expect(message.parentId, entity.parentId);
expect(message.quotedMessageId, entity.quotedMessageId); expect(message.quotedMessageId, entity.quotedMessageId);
expect(message.command, entity.command); expect(message.command, entity.command);
expect(message.createdAt, isSameDateAs(entity.createdAt)); expect(message.localCreatedAt, isSameDateAs(entity.localCreatedAt));
expect(message.remoteCreatedAt, isSameDateAs(entity.remoteCreatedAt));
expect(message.shadowed, entity.shadowed); expect(message.shadowed, entity.shadowed);
expect(message.showInChannel, entity.showInChannel); expect(message.showInChannel, entity.showInChannel);
for (var i = 0; i < message.mentionedUsers.length; i++) {
final entityMentionedUser =
User.fromJson(jsonDecode(entity.mentionedUsers[i]));
expect(message.mentionedUsers[i].id, entityMentionedUser.id);
}
expect(message.replyCount, entity.replyCount); expect(message.replyCount, entity.replyCount);
expect(message.reactionScores, entity.reactionScores); expect(message.reactionScores, entity.reactionScores);
expect(message.reactionCounts, entity.reactionCounts); expect(message.reactionCounts, entity.reactionCounts);
expect(message.status, entity.status); expect(message.status, entity.status);
expect(message.updatedAt, isSameDateAs(entity.updatedAt)); expect(message.localUpdatedAt, isSameDateAs(entity.localUpdatedAt));
expect(message.remoteUpdatedAt, isSameDateAs(entity.remoteUpdatedAt));
expect(message.extraData, entity.extraData); expect(message.extraData, entity.extraData);
expect(message.user!.id, entity.userId); expect(message.user!.id, entity.userId);
expect(message.deletedAt, isSameDateAs(entity.deletedAt)); expect(message.localDeletedAt, isSameDateAs(entity.localDeletedAt));
expect(message.remoteDeletedAt, isSameDateAs(entity.remoteDeletedAt));
expect(message.text, entity.messageText); expect(message.text, entity.messageText);
expect(message.pinned, entity.pinned); expect(message.pinned, entity.pinned);
expect(message.pinExpires, isSameDateAs(entity.pinExpires)); expect(message.pinExpires, isSameDateAs(entity.pinExpires));
@@ -108,7 +121,7 @@ void main() {
} }
}); });
test('toPinnedEntity should map message into PinnedMessageEntity', () { test('toEntity should map message into MessageEntity', () {
const cid = 'testCid'; const cid = 'testCid';
final user = User(id: 'testUserId'); final user = User(id: 'testUserId');
final quotedMessage = Message(id: 'testQuotedMessageId'); final quotedMessage = Message(id: 'testQuotedMessageId');
@@ -137,20 +150,26 @@ void main() {
parentId: 'testParentId', parentId: 'testParentId',
quotedMessageId: quotedMessage.id, quotedMessageId: quotedMessage.id,
command: 'testCommand', command: 'testCommand',
createdAt: DateTime.now(), localCreatedAt: DateTime.now(),
createdAt: DateTime.now().add(const Duration(seconds: 1)),
shadowed: math.Random().nextBool(), shadowed: math.Random().nextBool(),
showInChannel: math.Random().nextBool(), showInChannel: math.Random().nextBool(),
replyCount: 33, replyCount: 33,
mentionedUsers: [
User(id: 'testuser'),
],
reactionScores: {for (final r in reactions) r.type: r.score}, reactionScores: {for (final r in reactions) r.type: r.score},
reactionCounts: reactions.fold( reactionCounts: reactions.fold(
{}, {},
(prev, curr) => (prev, curr) =>
prev?..update(curr.type, (value) => value + 1, ifAbsent: () => 1), prev?..update(curr.type, (value) => value + 1, ifAbsent: () => 1),
), ),
updatedAt: DateTime.now(), localUpdatedAt: DateTime.now(),
updatedAt: DateTime.now().add(const Duration(seconds: 1)),
extraData: const {'extra_test_data': 'extraData'}, extraData: const {'extra_test_data': 'extraData'},
user: user, user: user,
deletedAt: DateTime.now(), localDeletedAt: DateTime.now(),
deletedAt: DateTime.now().add(const Duration(seconds: 1)),
text: 'Hello', text: 'Hello',
pinned: true, pinned: true,
pinExpires: DateTime.now(), pinExpires: DateTime.now(),
@@ -169,17 +188,22 @@ void main() {
expect(entity.parentId, message.parentId); expect(entity.parentId, message.parentId);
expect(entity.quotedMessageId, message.quotedMessageId); expect(entity.quotedMessageId, message.quotedMessageId);
expect(entity.command, message.command); expect(entity.command, message.command);
expect(entity.createdAt, isSameDateAs(message.createdAt)); expect(entity.localCreatedAt, isSameDateAs(message.localCreatedAt));
expect(entity.remoteCreatedAt, isSameDateAs(message.remoteCreatedAt));
expect(entity.shadowed, message.shadowed); expect(entity.shadowed, message.shadowed);
expect(entity.showInChannel, message.showInChannel); expect(entity.showInChannel, message.showInChannel);
expect(entity.replyCount, message.replyCount); expect(entity.replyCount, message.replyCount);
expect(
entity.mentionedUsers, message.mentionedUsers.map(jsonEncode).toList());
expect(entity.reactionScores, message.reactionScores); expect(entity.reactionScores, message.reactionScores);
expect(entity.reactionCounts, message.reactionCounts); expect(entity.reactionCounts, message.reactionCounts);
expect(entity.status, message.status); expect(entity.status, message.status);
expect(entity.updatedAt, isSameDateAs(message.updatedAt)); expect(entity.localUpdatedAt, isSameDateAs(message.localUpdatedAt));
expect(entity.remoteUpdatedAt, isSameDateAs(message.remoteUpdatedAt));
expect(entity.extraData, message.extraData); expect(entity.extraData, message.extraData);
expect(entity.userId, message.user!.id); expect(entity.userId, message.user!.id);
expect(entity.deletedAt, isSameDateAs(message.deletedAt)); expect(entity.localDeletedAt, isSameDateAs(message.localDeletedAt));
expect(entity.remoteDeletedAt, isSameDateAs(message.remoteDeletedAt));
expect(entity.messageText, message.text); expect(entity.messageText, message.text);
expect(entity.pinned, message.pinned); expect(entity.pinned, message.pinned);
expect(entity.pinExpires, isSameDateAs(message.pinExpires)); expect(entity.pinExpires, isSameDateAs(message.pinExpires));