Merge pull request #1625 from GetStream/release/6.4.0
This commit is contained in:
@@ -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
|
||||
|
||||
🐞 Fixed
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:rxdart/rxdart.dart';
|
||||
import 'package:stream_chat/src/client/retry_queue.dart';
|
||||
import 'package:stream_chat/src/core/util/utils.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
|
||||
/// [Event.typingStop] event is emitted automatically.
|
||||
@@ -563,6 +564,8 @@ class Channel {
|
||||
});
|
||||
}
|
||||
|
||||
final _sendMessageLock = Lock();
|
||||
|
||||
/// Send a [message] to this channel.
|
||||
///
|
||||
/// If [skipPush] is true the message will not send a push notification.
|
||||
@@ -586,7 +589,7 @@ class Channel {
|
||||
);
|
||||
// ignore: parameter_assignments
|
||||
message = message.copyWith(
|
||||
createdAt: message.createdAt,
|
||||
localCreatedAt: DateTime.now(),
|
||||
user: _client.state.currentUser,
|
||||
quotedMessage: quotedMessage,
|
||||
status: MessageSendingStatus.sending,
|
||||
@@ -615,14 +618,21 @@ class Channel {
|
||||
message = await attachmentsUploadCompleter.future;
|
||||
}
|
||||
|
||||
final response = await _client.sendMessage(
|
||||
message,
|
||||
id!,
|
||||
type,
|
||||
skipPush: skipPush,
|
||||
skipEnrichUrl: skipEnrichUrl,
|
||||
// Wait for the previous sendMessage call to finish. Otherwise, the order
|
||||
// of messages will not be maintained.
|
||||
final response = await _sendMessageLock.synchronized(
|
||||
() => _client.sendMessage(
|
||||
message,
|
||||
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();
|
||||
return response;
|
||||
} catch (e) {
|
||||
@@ -633,6 +643,8 @@ class Channel {
|
||||
}
|
||||
}
|
||||
|
||||
final _updateMessageLock = Lock();
|
||||
|
||||
/// Updates the [message] in this channel.
|
||||
///
|
||||
/// Waits for a [_messageAttachmentsUploadCompleter] to complete
|
||||
@@ -652,7 +664,7 @@ class Channel {
|
||||
// ignore: parameter_assignments
|
||||
message = message.copyWith(
|
||||
status: MessageSendingStatus.updating,
|
||||
updatedAt: message.updatedAt,
|
||||
localUpdatedAt: DateTime.now(),
|
||||
attachments: message.attachments.map(
|
||||
(it) {
|
||||
if (it.uploadState.isSuccess) return it;
|
||||
@@ -678,16 +690,20 @@ class Channel {
|
||||
message = await attachmentsUploadCompleter.future;
|
||||
}
|
||||
|
||||
final response = await _client.updateMessage(
|
||||
message,
|
||||
skipEnrichUrl: skipEnrichUrl,
|
||||
// Wait for the previous update call to finish. Otherwise, the order of
|
||||
// messages will not be maintained.
|
||||
final response = await _updateMessageLock.synchronized(
|
||||
() => _client.updateMessage(
|
||||
message,
|
||||
skipEnrichUrl: skipEnrichUrl,
|
||||
),
|
||||
);
|
||||
|
||||
final m = response.message.copyWith(
|
||||
ownReactions: message.ownReactions,
|
||||
);
|
||||
final updatedMessage = response.message
|
||||
.syncWith(message)
|
||||
.copyWith(ownReactions: message.ownReactions);
|
||||
|
||||
state?.updateMessage(m);
|
||||
state?.updateMessage(updatedMessage);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
@@ -714,16 +730,20 @@ class Channel {
|
||||
bool skipEnrichUrl = false,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _client.partialUpdateMessage(
|
||||
message.id,
|
||||
set: set,
|
||||
unset: unset,
|
||||
skipEnrichUrl: skipEnrichUrl,
|
||||
// Wait for the previous update call to finish. Otherwise, the order of
|
||||
// messages will not be maintained.
|
||||
final response = await _updateMessageLock.synchronized(
|
||||
() => _client.partialUpdateMessage(
|
||||
message.id,
|
||||
set: set,
|
||||
unset: unset,
|
||||
skipEnrichUrl: skipEnrichUrl,
|
||||
),
|
||||
);
|
||||
|
||||
final updatedMessage = response.message.copyWith(
|
||||
ownReactions: message.ownReactions,
|
||||
);
|
||||
final updatedMessage = response.message
|
||||
.syncWith(message)
|
||||
.copyWith(ownReactions: message.ownReactions);
|
||||
|
||||
state?.updateMessage(updatedMessage);
|
||||
|
||||
@@ -736,6 +756,8 @@ class Channel {
|
||||
}
|
||||
}
|
||||
|
||||
final _deleteMessageLock = Lock();
|
||||
|
||||
/// Deletes the [message] from the channel.
|
||||
Future<EmptyResponse> deleteMessage(Message message, {bool? hard}) async {
|
||||
final hardDelete = hard ?? false;
|
||||
@@ -746,7 +768,7 @@ class Channel {
|
||||
state!.deleteMessage(
|
||||
message.copyWith(
|
||||
type: 'deleted',
|
||||
deletedAt: message.deletedAt ?? DateTime.now(),
|
||||
localDeletedAt: DateTime.now(),
|
||||
status: MessageSendingStatus.sent,
|
||||
),
|
||||
hardDelete: hardDelete,
|
||||
@@ -770,13 +792,18 @@ class Channel {
|
||||
|
||||
state?.deleteMessage(message, hardDelete: hardDelete);
|
||||
|
||||
final response = await _client.deleteMessage(message.id, hard: hard);
|
||||
|
||||
state?.deleteMessage(
|
||||
message.copyWith(status: MessageSendingStatus.sent),
|
||||
hardDelete: hardDelete,
|
||||
// Wait for the previous delete call to finish. Otherwise, the order of
|
||||
// messages will not be maintained.
|
||||
final response = await _deleteMessageLock.synchronized(
|
||||
() => _client.deleteMessage(message.id, hard: hard),
|
||||
);
|
||||
|
||||
final deletedMessage = message.copyWith(
|
||||
status: MessageSendingStatus.sent,
|
||||
);
|
||||
|
||||
state?.deleteMessage(deletedMessage, hardDelete: hardDelete);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
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.
|
||||
Future<EmptyResponse> mute({Duration? expiration}) {
|
||||
_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);
|
||||
}
|
||||
|
||||
/// Unmute the channel.
|
||||
Future<EmptyResponse> unmute() {
|
||||
_checkInitialized();
|
||||
|
||||
// Cancel the mute expiration timer if it is set.
|
||||
_muteExpirationTimer?.cancel();
|
||||
_muteExpirationTimer = null;
|
||||
|
||||
return _client.unmuteChannel(cid!);
|
||||
}
|
||||
|
||||
@@ -1558,6 +1602,7 @@ class Channel {
|
||||
void dispose() {
|
||||
client.state.removeChannel('$cid');
|
||||
state?.dispose();
|
||||
_muteExpirationTimer?.cancel();
|
||||
_keyStrokeHandler.cancel();
|
||||
}
|
||||
|
||||
@@ -1942,8 +1987,9 @@ class ChannelClientState {
|
||||
)
|
||||
.listen((event) {
|
||||
final message = event.message!;
|
||||
if (isUpToDate ||
|
||||
(message.parentId != null && message.showInChannel != true)) {
|
||||
final showInChannel =
|
||||
message.parentId != null && message.showInChannel != true;
|
||||
if (isUpToDate || showInChannel) {
|
||||
updateMessage(message);
|
||||
}
|
||||
|
||||
@@ -1960,10 +2006,10 @@ class ChannelClientState {
|
||||
var newMessages = [...messages];
|
||||
final oldIndex = newMessages.indexWhere((m) => m.id == message.id);
|
||||
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.
|
||||
if (message.quotedMessageId != null && message.quotedMessage == null) {
|
||||
final oldMessage = newMessages[oldIndex];
|
||||
updatedMessage = updatedMessage.copyWith(
|
||||
quotedMessage: oldMessage.quotedMessage,
|
||||
);
|
||||
@@ -1980,7 +2026,7 @@ class ChannelClientState {
|
||||
return it.copyWith(
|
||||
quotedMessage: updatedMessage.copyWith(
|
||||
type: 'deleted',
|
||||
deletedAt: updatedMessage.deletedAt ?? DateTime.now(),
|
||||
deletedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
@@ -2004,7 +2050,7 @@ class ChannelClientState {
|
||||
}
|
||||
|
||||
_channelState = _channelState.copyWith(
|
||||
messages: newMessages..sort(_sortByCreatedAt),
|
||||
messages: newMessages.sorted(_sortByCreatedAt),
|
||||
pinnedMessages: newPinnedMessages,
|
||||
channel: _channelState.channel?.copyWith(
|
||||
lastMessageAt: message.createdAt,
|
||||
@@ -2226,7 +2272,7 @@ class ChannelClientState {
|
||||
...newThreads[parentId]!.where(
|
||||
(newMessage) => !messages.any((m) => m.id == newMessage.id),
|
||||
),
|
||||
]..sort(_sortByCreatedAt);
|
||||
].sorted(_sortByCreatedAt);
|
||||
} else {
|
||||
newThreads[parentId] = messages;
|
||||
}
|
||||
@@ -2245,15 +2291,10 @@ class ChannelClientState {
|
||||
|
||||
/// Update channelState with updated information.
|
||||
void updateChannelState(ChannelState updatedState) {
|
||||
final _existingStateMessages = _channelState.messages ?? [];
|
||||
final _updatedStateMessages = updatedState.messages ?? [];
|
||||
final _existingStateMessages = [...messages];
|
||||
final newMessages = <Message>[
|
||||
..._updatedStateMessages,
|
||||
..._existingStateMessages
|
||||
.where((m) =>
|
||||
!_updatedStateMessages.any((newMessage) => newMessage.id == m.id))
|
||||
.toList(),
|
||||
]..sort(_sortByCreatedAt);
|
||||
..._existingStateMessages.merge(updatedState.messages),
|
||||
].sorted(_sortByCreatedAt);
|
||||
|
||||
final _existingStateWatchers = _channelState.watchers ?? [];
|
||||
final _updatedStateWatchers = updatedState.watchers ?? [];
|
||||
@@ -2471,3 +2512,21 @@ bool _pinIsValid(Message message) {
|
||||
final now = DateTime.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/websocket.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
|
||||
/// [LogRecord] as the only parameter.
|
||||
@@ -526,10 +526,13 @@ class StreamChatClient {
|
||||
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
|
||||
/// Will automatically fetch [cids] and [lastSyncedAt] if [persistenceEnabled]
|
||||
Future<void> sync({List<String>? cids, DateTime? lastSyncAt}) {
|
||||
return synchronized(() async {
|
||||
return _syncLock.synchronized(() async {
|
||||
final channels = cids ?? await chatPersistenceClient?.getChannelCids();
|
||||
if (channels == null || channels.isEmpty) {
|
||||
return;
|
||||
|
||||
@@ -64,8 +64,11 @@ class Message extends Equatable {
|
||||
this.showInChannel,
|
||||
this.command,
|
||||
DateTime? createdAt,
|
||||
this.localCreatedAt,
|
||||
DateTime? updatedAt,
|
||||
this.deletedAt,
|
||||
this.localUpdatedAt,
|
||||
DateTime? deletedAt,
|
||||
this.localDeletedAt,
|
||||
this.user,
|
||||
this.pinned = false,
|
||||
this.pinnedAt,
|
||||
@@ -76,8 +79,9 @@ class Message extends Equatable {
|
||||
this.i18n,
|
||||
}) : id = id ?? const Uuid().v4(),
|
||||
pinExpires = pinExpires?.toUtc(),
|
||||
_createdAt = createdAt,
|
||||
_updatedAt = updatedAt,
|
||||
remoteCreatedAt = createdAt,
|
||||
remoteUpdatedAt = updatedAt,
|
||||
remoteDeletedAt = deletedAt,
|
||||
_quotedMessageId = quotedMessageId;
|
||||
|
||||
/// Create a new instance from JSON.
|
||||
@@ -161,21 +165,49 @@ class Message extends Equatable {
|
||||
@JsonKey(includeToJson: false)
|
||||
final String? command;
|
||||
|
||||
final DateTime? _createdAt;
|
||||
|
||||
/// Reserved field indicating when the message was deleted.
|
||||
/// Indicates when the message was created.
|
||||
///
|
||||
/// Returns the latest between [localCreatedAt] and [remoteCreatedAt].
|
||||
/// If both are null, returns [DateTime.now].
|
||||
@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)
|
||||
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)
|
||||
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.
|
||||
@JsonKey(includeToJson: false)
|
||||
@@ -273,8 +305,11 @@ class Message extends Equatable {
|
||||
bool? showInChannel,
|
||||
String? command,
|
||||
DateTime? createdAt,
|
||||
DateTime? localCreatedAt,
|
||||
DateTime? updatedAt,
|
||||
DateTime? localUpdatedAt,
|
||||
DateTime? deletedAt,
|
||||
DateTime? localDeletedAt,
|
||||
User? user,
|
||||
bool? pinned,
|
||||
DateTime? pinnedAt,
|
||||
@@ -338,9 +373,12 @@ class Message extends Equatable {
|
||||
threadParticipants: threadParticipants ?? this.threadParticipants,
|
||||
showInChannel: showInChannel ?? this.showInChannel,
|
||||
command: command ?? this.command,
|
||||
createdAt: createdAt ?? _createdAt,
|
||||
updatedAt: updatedAt ?? _updatedAt,
|
||||
deletedAt: deletedAt ?? this.deletedAt,
|
||||
createdAt: createdAt ?? remoteCreatedAt,
|
||||
localCreatedAt: localCreatedAt ?? this.localCreatedAt,
|
||||
updatedAt: updatedAt ?? remoteUpdatedAt,
|
||||
localUpdatedAt: localUpdatedAt ?? this.localUpdatedAt,
|
||||
deletedAt: deletedAt ?? remoteDeletedAt,
|
||||
localDeletedAt: localDeletedAt ?? this.localDeletedAt,
|
||||
user: user ?? this.user,
|
||||
pinned: pinned ?? this.pinned,
|
||||
pinnedAt: pinnedAt ?? this.pinnedAt,
|
||||
@@ -374,9 +412,12 @@ class Message extends Equatable {
|
||||
threadParticipants: other.threadParticipants,
|
||||
showInChannel: other.showInChannel,
|
||||
command: other.command,
|
||||
createdAt: other.createdAt,
|
||||
updatedAt: other.updatedAt,
|
||||
deletedAt: other.deletedAt,
|
||||
createdAt: other.remoteCreatedAt,
|
||||
localCreatedAt: other.localCreatedAt,
|
||||
updatedAt: other.remoteUpdatedAt,
|
||||
localUpdatedAt: other.localUpdatedAt,
|
||||
deletedAt: other.remoteDeletedAt,
|
||||
localDeletedAt: other.localDeletedAt,
|
||||
user: other.user,
|
||||
pinned: other.pinned,
|
||||
pinnedAt: other.pinnedAt,
|
||||
@@ -387,6 +428,28 @@ class Message extends Equatable {
|
||||
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
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
@@ -407,9 +470,12 @@ class Message extends Equatable {
|
||||
shadowed,
|
||||
silent,
|
||||
command,
|
||||
_createdAt,
|
||||
_updatedAt,
|
||||
deletedAt,
|
||||
localCreatedAt,
|
||||
remoteCreatedAt,
|
||||
localUpdatedAt,
|
||||
remoteUpdatedAt,
|
||||
localDeletedAt,
|
||||
remoteDeletedAt,
|
||||
user,
|
||||
pinned,
|
||||
pinnedAt,
|
||||
|
||||
@@ -3,4 +3,4 @@ import 'package:stream_chat/src/client/client.dart';
|
||||
/// Current package version
|
||||
/// Used in [StreamChatClient] to build the `x-stream-client` header
|
||||
// ignore: constant_identifier_names
|
||||
const PACKAGE_VERSION = '6.3.0';
|
||||
const PACKAGE_VERSION = '6.4.0';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: stream_chat
|
||||
homepage: https://getstream.io/
|
||||
description: The official Dart client for Stream Chat, a service for building chat applications.
|
||||
version: 6.3.0
|
||||
version: 6.4.0
|
||||
repository: https://github.com/GetStream/stream-chat-flutter
|
||||
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
|
||||
|
||||
|
||||
@@ -2481,6 +2481,31 @@ void main() {
|
||||
)).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 {
|
||||
when(
|
||||
() => client.unmuteChannel(channelCid),
|
||||
|
||||
@@ -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
|
||||
|
||||
🐞 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
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:responsive_builder/responsive_builder.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';
|
||||
|
||||
Future<void> main() async {
|
||||
@@ -255,6 +258,19 @@ class _ChannelPageState extends State<ChannelPage> {
|
||||
widget.onBackPressed!(context);
|
||||
}
|
||||
: null,
|
||||
onImageTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) {
|
||||
return StreamChannel(
|
||||
channel: StreamChannel.of(context).channel,
|
||||
child: const DebugChannelPage(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
showBackButton: widget.showBackButton,
|
||||
),
|
||||
body: Column(
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import 'package:flutter/material.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}
|
||||
/// Widget for constructing a group of images
|
||||
/// {@endtemplate}
|
||||
|
||||
@@ -2,6 +2,14 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.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}
|
||||
/// Displays a user's avatar.
|
||||
/// {@endtemplate}
|
||||
|
||||
@@ -54,6 +54,9 @@ class StreamChannelAvatar extends StatelessWidget {
|
||||
this.selected = false,
|
||||
this.selectionColor,
|
||||
this.selectionThickness = 4,
|
||||
this.ownSpaceAvatarBuilder,
|
||||
this.oneToOneAvatarBuilder,
|
||||
this.groupAvatarBuilder,
|
||||
}) : assert(
|
||||
channel.state != null,
|
||||
'Channel ${channel.id} is not initialized',
|
||||
@@ -80,6 +83,21 @@ class StreamChannelAvatar extends StatelessWidget {
|
||||
/// Thickness of selection image
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final client = channel.client.state;
|
||||
@@ -146,15 +164,22 @@ class StreamChannelAvatar extends StatelessWidget {
|
||||
return BetterStreamBuilder<User>(
|
||||
stream: client.currentUserStream.map((it) => it!),
|
||||
initialData: currentUser,
|
||||
builder: (context, user) => StreamUserAvatar(
|
||||
borderRadius: borderRadius ?? previewTheme?.borderRadius,
|
||||
user: user,
|
||||
constraints: constraints ?? previewTheme?.constraints,
|
||||
onTap: onTap != null ? (_) => onTap!() : null,
|
||||
selected: selected,
|
||||
selectionColor: selectionColor ?? colorTheme.accentPrimary,
|
||||
selectionThickness: selectionThickness,
|
||||
),
|
||||
builder: (context, user) {
|
||||
final ownSpaceBuilder = ownSpaceAvatarBuilder;
|
||||
if (ownSpaceBuilder != null) {
|
||||
return ownSpaceBuilder(context, user, selected);
|
||||
}
|
||||
|
||||
return StreamUserAvatar(
|
||||
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,
|
||||
builder: (context, member) => StreamUserAvatar(
|
||||
borderRadius: borderRadius ?? previewTheme?.borderRadius,
|
||||
user: member.user!,
|
||||
constraints: constraints ?? previewTheme?.constraints,
|
||||
onTap: onTap != null ? (_) => onTap!() : null,
|
||||
selected: selected,
|
||||
selectionColor: selectionColor ?? colorTheme.accentPrimary,
|
||||
selectionThickness: selectionThickness,
|
||||
),
|
||||
builder: (context, member) {
|
||||
final oneToOneBuilder = oneToOneAvatarBuilder;
|
||||
if (oneToOneBuilder != null) {
|
||||
return oneToOneBuilder(context, member.user!, selected);
|
||||
}
|
||||
|
||||
return StreamUserAvatar(
|
||||
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
|
||||
return StreamGroupAvatar(
|
||||
channel: channel,
|
||||
|
||||
+10
-1
@@ -19,6 +19,7 @@ class MessageActionsModal extends StatefulWidget {
|
||||
this.showDeleteMessage = true,
|
||||
this.showEditMessage = true,
|
||||
this.onReplyTap,
|
||||
this.onConfirmDeleteTap,
|
||||
this.onThreadReplyTap,
|
||||
this.showCopyMessage = true,
|
||||
this.showReplyMessage = true,
|
||||
@@ -44,6 +45,9 @@ class MessageActionsModal extends StatefulWidget {
|
||||
/// The action to perform when "reply" is tapped
|
||||
final OnMessageTap? onReplyTap;
|
||||
|
||||
/// The action to perform when delete confirmation button is tapped.
|
||||
final Future<void> Function(Message)? onConfirmDeleteTap;
|
||||
|
||||
/// Message in focus for actions
|
||||
final Message message;
|
||||
|
||||
@@ -363,7 +367,12 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
|
||||
if (answer == true) {
|
||||
try {
|
||||
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) {
|
||||
_showErrorAlertBottomSheet();
|
||||
}
|
||||
|
||||
+93
-87
@@ -693,6 +693,7 @@ Widget mobileAttachmentPickerBuilder({
|
||||
required BuildContext context,
|
||||
required StreamAttachmentPickerController controller,
|
||||
Iterable<AttachmentPickerOption>? customOptions,
|
||||
List<AttachmentPickerType> allowedTypes = AttachmentPickerType.values,
|
||||
ThumbnailSize attachmentThumbnailSize = const ThumbnailSize(400, 400),
|
||||
ThumbnailFormat attachmentThumbnailFormat = ThumbnailFormat.jpeg,
|
||||
int attachmentThumbnailQuality = 100,
|
||||
@@ -702,74 +703,76 @@ Widget mobileAttachmentPickerBuilder({
|
||||
controller: controller,
|
||||
onSendAttachments: Navigator.of(context).pop,
|
||||
options: {
|
||||
if (customOptions != null) ...customOptions,
|
||||
AttachmentPickerOption(
|
||||
key: 'gallery-picker',
|
||||
icon: StreamSvgIcon.pictures(size: 36).toIconThemeSvgIcon(),
|
||||
supportedTypes: [
|
||||
AttachmentPickerType.images,
|
||||
AttachmentPickerType.videos,
|
||||
],
|
||||
optionViewBuilder: (context, controller) {
|
||||
final selectedIds = controller.value.map((it) => it.id);
|
||||
return StreamGalleryPicker(
|
||||
selectedMediaItems: selectedIds,
|
||||
mediaThumbnailSize: attachmentThumbnailSize,
|
||||
mediaThumbnailFormat: attachmentThumbnailFormat,
|
||||
mediaThumbnailQuality: attachmentThumbnailQuality,
|
||||
mediaThumbnailScale: attachmentThumbnailScale,
|
||||
onMediaItemSelected: (media) async {
|
||||
if (selectedIds.contains(media.id)) {
|
||||
return controller.removeAssetAttachment(media);
|
||||
}
|
||||
return controller.addAssetAttachment(media);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
AttachmentPickerOption(
|
||||
key: 'file-picker',
|
||||
icon: StreamSvgIcon.files(size: 36).toIconThemeSvgIcon(),
|
||||
supportedTypes: [AttachmentPickerType.files],
|
||||
optionViewBuilder: (context, controller) {
|
||||
return StreamFilePicker(
|
||||
onFilePicked: (file) async {
|
||||
if (file != null) await controller.addAttachment(file);
|
||||
return Navigator.pop(context, controller.value);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
AttachmentPickerOption(
|
||||
key: 'image-picker',
|
||||
icon: StreamSvgIcon.camera(size: 36).toIconThemeSvgIcon(),
|
||||
supportedTypes: [AttachmentPickerType.images],
|
||||
optionViewBuilder: (context, controller) {
|
||||
return StreamImagePicker(
|
||||
onImagePicked: (image) async {
|
||||
if (image != null) {
|
||||
await controller.addAttachment(image);
|
||||
}
|
||||
return Navigator.pop(context, controller.value);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
AttachmentPickerOption(
|
||||
key: 'video-picker',
|
||||
icon: StreamSvgIcon.record(size: 36).toIconThemeSvgIcon(),
|
||||
supportedTypes: [AttachmentPickerType.videos],
|
||||
optionViewBuilder: (context, controller) {
|
||||
return StreamVideoPicker(
|
||||
onVideoPicked: (video) async {
|
||||
if (video != null) {
|
||||
await controller.addAttachment(video);
|
||||
}
|
||||
return Navigator.pop(context, controller.value);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
...{
|
||||
if (customOptions != null) ...customOptions,
|
||||
AttachmentPickerOption(
|
||||
key: 'gallery-picker',
|
||||
icon: StreamSvgIcon.pictures(size: 36).toIconThemeSvgIcon(),
|
||||
supportedTypes: [
|
||||
AttachmentPickerType.images,
|
||||
AttachmentPickerType.videos,
|
||||
],
|
||||
optionViewBuilder: (context, controller) {
|
||||
final selectedIds = controller.value.map((it) => it.id);
|
||||
return StreamGalleryPicker(
|
||||
selectedMediaItems: selectedIds,
|
||||
mediaThumbnailSize: attachmentThumbnailSize,
|
||||
mediaThumbnailFormat: attachmentThumbnailFormat,
|
||||
mediaThumbnailQuality: attachmentThumbnailQuality,
|
||||
mediaThumbnailScale: attachmentThumbnailScale,
|
||||
onMediaItemSelected: (media) async {
|
||||
if (selectedIds.contains(media.id)) {
|
||||
return controller.removeAssetAttachment(media);
|
||||
}
|
||||
return controller.addAssetAttachment(media);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
AttachmentPickerOption(
|
||||
key: 'file-picker',
|
||||
icon: StreamSvgIcon.files(size: 36).toIconThemeSvgIcon(),
|
||||
supportedTypes: [AttachmentPickerType.files],
|
||||
optionViewBuilder: (context, controller) {
|
||||
return StreamFilePicker(
|
||||
onFilePicked: (file) async {
|
||||
if (file != null) await controller.addAttachment(file);
|
||||
return Navigator.pop(context, controller.value);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
AttachmentPickerOption(
|
||||
key: 'image-picker',
|
||||
icon: StreamSvgIcon.camera(size: 36).toIconThemeSvgIcon(),
|
||||
supportedTypes: [AttachmentPickerType.images],
|
||||
optionViewBuilder: (context, controller) {
|
||||
return StreamImagePicker(
|
||||
onImagePicked: (image) async {
|
||||
if (image != null) {
|
||||
await controller.addAttachment(image);
|
||||
}
|
||||
return Navigator.pop(context, controller.value);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
AttachmentPickerOption(
|
||||
key: 'video-picker',
|
||||
icon: StreamSvgIcon.record(size: 36).toIconThemeSvgIcon(),
|
||||
supportedTypes: [AttachmentPickerType.videos],
|
||||
optionViewBuilder: (context, controller) {
|
||||
return StreamVideoPicker(
|
||||
onVideoPicked: (video) async {
|
||||
if (video != null) {
|
||||
await controller.addAttachment(video);
|
||||
}
|
||||
return Navigator.pop(context, controller.value);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
}..where((option) => option.supportedTypes.every(allowedTypes.contains)),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -779,6 +782,7 @@ Widget webOrDesktopAttachmentPickerBuilder({
|
||||
required BuildContext context,
|
||||
required StreamAttachmentPickerController controller,
|
||||
Iterable<WebOrDesktopAttachmentPickerOption>? customOptions,
|
||||
List<AttachmentPickerType> allowedTypes = AttachmentPickerType.values,
|
||||
ThumbnailSize attachmentThumbnailSize = const ThumbnailSize(400, 400),
|
||||
ThumbnailFormat attachmentThumbnailFormat = ThumbnailFormat.jpeg,
|
||||
int attachmentThumbnailQuality = 100,
|
||||
@@ -787,25 +791,27 @@ Widget webOrDesktopAttachmentPickerBuilder({
|
||||
return StreamWebOrDesktopAttachmentPickerBottomSheet(
|
||||
controller: controller,
|
||||
options: {
|
||||
if (customOptions != null) ...customOptions,
|
||||
WebOrDesktopAttachmentPickerOption(
|
||||
key: 'image-picker',
|
||||
type: AttachmentPickerType.images,
|
||||
icon: StreamSvgIcon.pictures(size: 36).toIconThemeSvgIcon(),
|
||||
title: context.translations.uploadAPhotoLabel,
|
||||
),
|
||||
WebOrDesktopAttachmentPickerOption(
|
||||
key: 'video-picker',
|
||||
type: AttachmentPickerType.videos,
|
||||
icon: StreamSvgIcon.record(size: 36).toIconThemeSvgIcon(),
|
||||
title: context.translations.uploadAVideoLabel,
|
||||
),
|
||||
WebOrDesktopAttachmentPickerOption(
|
||||
key: 'file-picker',
|
||||
type: AttachmentPickerType.files,
|
||||
icon: StreamSvgIcon.files(size: 36).toIconThemeSvgIcon(),
|
||||
title: context.translations.uploadAFileLabel,
|
||||
),
|
||||
...{
|
||||
if (customOptions != null) ...customOptions,
|
||||
WebOrDesktopAttachmentPickerOption(
|
||||
key: 'image-picker',
|
||||
type: AttachmentPickerType.images,
|
||||
icon: StreamSvgIcon.pictures(size: 36).toIconThemeSvgIcon(),
|
||||
title: context.translations.uploadAPhotoLabel,
|
||||
),
|
||||
WebOrDesktopAttachmentPickerOption(
|
||||
key: 'video-picker',
|
||||
type: AttachmentPickerType.videos,
|
||||
icon: StreamSvgIcon.record(size: 36).toIconThemeSvgIcon(),
|
||||
title: context.translations.uploadAVideoLabel,
|
||||
),
|
||||
WebOrDesktopAttachmentPickerOption(
|
||||
key: 'file-picker',
|
||||
type: AttachmentPickerType.files,
|
||||
icon: StreamSvgIcon.files(size: 36).toIconThemeSvgIcon(),
|
||||
title: context.translations.uploadAFileLabel,
|
||||
),
|
||||
}.where((option) => option.supportedTypes.every(allowedTypes.contains)),
|
||||
},
|
||||
onOptionTap: (context, controller, option) async {
|
||||
final attachment = await StreamAttachmentHandler.instance.pickFile(
|
||||
|
||||
+3
@@ -66,6 +66,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
Future<T?> showStreamAttachmentPickerModalBottomSheet<T>({
|
||||
required BuildContext context,
|
||||
Iterable<AttachmentPickerOption>? customOptions,
|
||||
List<AttachmentPickerType> allowedTypes = AttachmentPickerType.values,
|
||||
List<Attachment>? initialAttachments,
|
||||
StreamAttachmentPickerController? controller,
|
||||
Color? backgroundColor,
|
||||
@@ -117,6 +118,7 @@ Future<T?> showStreamAttachmentPickerModalBottomSheet<T>({
|
||||
return webOrDesktopAttachmentPickerBuilder.call(
|
||||
context: context,
|
||||
controller: controller,
|
||||
allowedTypes: allowedTypes,
|
||||
customOptions: customOptions?.map(
|
||||
WebOrDesktopAttachmentPickerOption.fromAttachmentPickerOption,
|
||||
),
|
||||
@@ -130,6 +132,7 @@ Future<T?> showStreamAttachmentPickerModalBottomSheet<T>({
|
||||
return mobileAttachmentPickerBuilder.call(
|
||||
context: context,
|
||||
controller: controller,
|
||||
allowedTypes: allowedTypes,
|
||||
customOptions: customOptions,
|
||||
attachmentThumbnailSize: attachmentThumbnailSize,
|
||||
attachmentThumbnailFormat: attachmentThumbnailFormat,
|
||||
|
||||
@@ -20,7 +20,6 @@ class StreamQuotedMessageWidget extends StatelessWidget {
|
||||
this.textLimit = 170,
|
||||
this.attachmentThumbnailBuilders,
|
||||
this.padding = const EdgeInsets.all(8),
|
||||
this.onTap,
|
||||
this.onQuotedMessageClear,
|
||||
});
|
||||
|
||||
@@ -46,9 +45,6 @@ class StreamQuotedMessageWidget extends StatelessWidget {
|
||||
/// Padding around the widget
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
/// Callback for tap on widget
|
||||
final GestureTapCallback? onTap;
|
||||
|
||||
/// Callback for clearing quoted messages.
|
||||
final VoidCallback? onQuotedMessageClear;
|
||||
|
||||
@@ -77,19 +73,12 @@ class StreamQuotedMessageWidget extends StatelessWidget {
|
||||
showOnlineStatus: false,
|
||||
),
|
||||
];
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: reverse ? children.reversed.toList() : children,
|
||||
),
|
||||
),
|
||||
return Padding(
|
||||
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 = AbsorbPointer(child: child);
|
||||
|
||||
final isImageFile = attachment.title?.mimeType?.type == 'image';
|
||||
final isVideoFile = attachment.title?.mimeType?.type == 'video';
|
||||
|
||||
return Material(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
type: MaterialType.transparency,
|
||||
shape: attachment.type == 'file'
|
||||
shape: attachment.type == 'file' && (!isImageFile && !isVideoFile)
|
||||
? null
|
||||
: RoundedRectangleBorder(
|
||||
side: const BorderSide(width: 0, color: Colors.transparent),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: child,
|
||||
child: AbsorbPointer(child: child),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, QuotedMessageAttachmentThumbnailBuilder>
|
||||
get _defaultAttachmentBuilder {
|
||||
return {
|
||||
final builders = <String, QuotedMessageAttachmentThumbnailBuilder>{
|
||||
'image': (_, attachment) {
|
||||
return StreamImageAttachment(
|
||||
attachment: attachment,
|
||||
@@ -315,16 +307,33 @@ class _ParseAttachments extends StatelessWidget {
|
||||
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.onError,
|
||||
this.attachmentLimit = 10,
|
||||
this.allowedAttachmentPickerTypes = AttachmentPickerType.values,
|
||||
this.onAttachmentLimitExceed,
|
||||
this.attachmentButtonBuilder,
|
||||
this.commandButtonBuilder,
|
||||
this.customAutocompleteTriggers = const [],
|
||||
this.mentionAllAppUsers = false,
|
||||
this.sendButtonBuilder,
|
||||
this.quotedMessageBuilder,
|
||||
this.shouldKeepFocusAfterMessage,
|
||||
this.validator = _defaultValidator,
|
||||
this.restorationId,
|
||||
@@ -143,6 +145,7 @@ class StreamMessageInput extends StatefulWidget {
|
||||
_defaultClearQuotedMessageKeyPredicate,
|
||||
this.ogPreviewFilter = _defaultOgPreviewFilter,
|
||||
this.hintGetter = _defaultHintGetter,
|
||||
this.contentInsertionConfiguration,
|
||||
});
|
||||
|
||||
/// 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.
|
||||
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.
|
||||
///
|
||||
/// This will override the default error alert behaviour.
|
||||
@@ -258,6 +267,9 @@ class StreamMessageInput extends StatefulWidget {
|
||||
/// Builder for creating send button
|
||||
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.
|
||||
/// The default behaviour keeps focus until a command is enabled.
|
||||
final bool? shouldKeepFocusAfterMessage;
|
||||
@@ -295,6 +307,9 @@ class StreamMessageInput extends StatefulWidget {
|
||||
/// Returns the hint text for the message input.
|
||||
final HintGetter hintGetter;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.contentInsertionConfiguration}
|
||||
final ContentInsertionConfiguration? contentInsertionConfiguration;
|
||||
|
||||
static String? _defaultHintGetter(
|
||||
BuildContext context,
|
||||
HintType type,
|
||||
@@ -771,8 +786,8 @@ class StreamMessageInputState extends State<StreamMessageInput>
|
||||
Future<void> _onAttachmentButtonPressed() async {
|
||||
final attachments = await showStreamAttachmentPickerModalBottomSheet(
|
||||
context: context,
|
||||
allowedTypes: widget.allowedAttachmentPickerTypes,
|
||||
initialAttachments: _effectiveController.attachments,
|
||||
useRootNavigator: true,
|
||||
);
|
||||
|
||||
if (attachments != null) {
|
||||
@@ -860,6 +875,8 @@ class StreamMessageInputState extends State<StreamMessageInput>
|
||||
decoration: _getInputDecoration(context),
|
||||
textCapitalization: widget.textCapitalization,
|
||||
autocorrect: widget.autoCorrect,
|
||||
contentInsertionConfiguration:
|
||||
widget.contentInsertionConfiguration,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1118,14 +1135,20 @@ class StreamMessageInputState extends State<StreamMessageInput>
|
||||
if (!_hasQuotedMessage) return const Offstage();
|
||||
final containsUrl = _effectiveController.message.quotedMessage!.attachments
|
||||
.any((element) => element.titleLink != null);
|
||||
return StreamQuotedMessageWidget(
|
||||
reverse: true,
|
||||
showBorder: !containsUrl,
|
||||
message: _effectiveController.message.quotedMessage!,
|
||||
messageTheme: _streamChatTheme.otherMessageTheme,
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
|
||||
onQuotedMessageClear: widget.onQuotedMessageCleared,
|
||||
);
|
||||
|
||||
return widget.quotedMessageBuilder?.call(
|
||||
context,
|
||||
_effectiveController.message.quotedMessage!,
|
||||
) ??
|
||||
StreamQuotedMessageWidget(
|
||||
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() {
|
||||
|
||||
@@ -120,6 +120,7 @@ class StreamMessageTextField extends StatefulWidget {
|
||||
this.restorationId,
|
||||
this.scribbleEnabled = true,
|
||||
this.enableIMEPersonalizedLearning = true,
|
||||
this.contentInsertionConfiguration,
|
||||
}) : assert(obscuringCharacter.length == 1, ''),
|
||||
smartDashesType = smartDashesType ??
|
||||
(obscureText ? SmartDashesType.disabled : SmartDashesType.enabled),
|
||||
@@ -526,6 +527,9 @@ class StreamMessageTextField extends StatefulWidget {
|
||||
/// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning}
|
||||
final bool enableIMEPersonalizedLearning;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.contentInsertionConfiguration}
|
||||
final ContentInsertionConfiguration? contentInsertionConfiguration;
|
||||
|
||||
@override
|
||||
_StreamMessageTextFieldState createState() => _StreamMessageTextFieldState();
|
||||
|
||||
@@ -622,6 +626,9 @@ class StreamMessageTextField extends StatefulWidget {
|
||||
properties.add(DiagnosticsProperty<bool>(
|
||||
'enableIMEPersonalizedLearning', enableIMEPersonalizedLearning,
|
||||
defaultValue: true));
|
||||
properties.add(DiagnosticsProperty<ContentInsertionConfiguration>(
|
||||
'contentInsertionConfiguration', contentInsertionConfiguration,
|
||||
defaultValue: null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,6 +734,7 @@ class _StreamMessageTextFieldState extends State<StreamMessageTextField>
|
||||
restorationId: widget.restorationId,
|
||||
scribbleEnabled: widget.scribbleEnabled,
|
||||
enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning,
|
||||
contentInsertionConfiguration: widget.contentInsertionConfiguration,
|
||||
);
|
||||
|
||||
@override
|
||||
|
||||
@@ -852,20 +852,25 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
|
||||
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) {
|
||||
_bottomPaginationActive = false;
|
||||
initialAlignment = 0;
|
||||
// Reset the pagination variables.
|
||||
initialIndex = 0;
|
||||
initialAlignment = 0;
|
||||
_bottomPaginationActive = false;
|
||||
|
||||
// Reload the channel to get the latest messages.
|
||||
await streamChannel!.reloadChannel();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollController!.jumpTo(index: index);
|
||||
});
|
||||
} else {
|
||||
// Wait for the frame to be rendered with the updated channel state.
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
}
|
||||
|
||||
// Scroll to the end of the list.
|
||||
if (_scrollController?.isAttached == true) {
|
||||
_scrollController!.scrollTo(
|
||||
index: index,
|
||||
index: 0,
|
||||
duration: const Duration(seconds: 1),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
|
||||
@@ -29,6 +29,7 @@ class MessageCard extends StatefulWidget {
|
||||
this.borderSide,
|
||||
this.borderRadiusGeometry,
|
||||
this.textBuilder,
|
||||
this.quotedMessageBuilder,
|
||||
this.onLinkTap,
|
||||
this.onMentionTap,
|
||||
this.onQuotedMessageTap,
|
||||
@@ -82,6 +83,9 @@ class MessageCard extends StatefulWidget {
|
||||
/// {@macro textBuilder}
|
||||
final Widget Function(BuildContext, Message)? textBuilder;
|
||||
|
||||
/// {@macro quotedMessageBuilder}
|
||||
final Widget Function(BuildContext, Message)? quotedMessageBuilder;
|
||||
|
||||
/// {@macro onLinkTap}
|
||||
final void Function(String)? onLinkTap;
|
||||
|
||||
@@ -129,8 +133,12 @@ class _MessageCardState extends State<MessageCard> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final onQuotedMessageTap = widget.onQuotedMessageTap;
|
||||
final quotedMessageBuilder = widget.quotedMessageBuilder;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
margin: EdgeInsets.symmetric(
|
||||
horizontal: (widget.isFailedState ? 15.0 : 0.0) +
|
||||
(widget.showUserAvatar == DisplayWidget.gone ? 0 : 4.0),
|
||||
@@ -150,15 +158,27 @@ class _MessageCardState extends State<MessageCard> {
|
||||
maxWidth: widthLimit ?? double.infinity,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.hasQuotedMessage)
|
||||
QuotedMessage(
|
||||
reverse: widget.reverse,
|
||||
message: widget.message,
|
||||
hasNonUrlAttachments: widget.hasNonUrlAttachments,
|
||||
onQuotedMessageTap: widget.onQuotedMessageTap,
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: InkWell(
|
||||
onTap: !widget.message.quotedMessage!.isDeleted &&
|
||||
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)
|
||||
ParseAttachments(
|
||||
|
||||
@@ -61,6 +61,7 @@ class StreamMessageWidget extends StatefulWidget {
|
||||
this.showInChannelIndicator = false,
|
||||
this.onReplyTap,
|
||||
this.onThreadTap,
|
||||
this.onConfirmDeleteTap,
|
||||
this.showUsername = true,
|
||||
this.showTimestamp = true,
|
||||
this.showReactions = true,
|
||||
@@ -78,6 +79,7 @@ class StreamMessageWidget extends StatefulWidget {
|
||||
this.onMessageActions,
|
||||
this.onShowMessage,
|
||||
this.userAvatarBuilder,
|
||||
this.quotedMessageBuilder,
|
||||
this.editMessageInputBuilder,
|
||||
this.textBuilder,
|
||||
@Deprecated('''
|
||||
@@ -307,6 +309,11 @@ class StreamMessageWidget extends StatefulWidget {
|
||||
/// {@endtemplate}
|
||||
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}
|
||||
/// Widget builder for edit message layout
|
||||
/// {@endtemplate}
|
||||
@@ -348,6 +355,11 @@ class StreamMessageWidget extends StatefulWidget {
|
||||
/// {@endtemplate}
|
||||
final Widget Function(BuildContext, User)? userAvatarBuilder;
|
||||
|
||||
/// {@template quotedMessageBuilder}
|
||||
/// Widget builder for building quoted message
|
||||
/// {@endtemplate}
|
||||
final Widget Function(BuildContext, Message)? quotedMessageBuilder;
|
||||
|
||||
/// {@template message}
|
||||
/// The message to display.
|
||||
/// {@endtemplate}
|
||||
@@ -568,8 +580,10 @@ class StreamMessageWidget extends StatefulWidget {
|
||||
void Function(User)? onMentionTap,
|
||||
void Function(Message)? onThreadTap,
|
||||
void Function(Message)? onReplyTap,
|
||||
Future<void> Function(Message)? onConfirmDeleteTap,
|
||||
Widget Function(BuildContext, Message)? editMessageInputBuilder,
|
||||
Widget Function(BuildContext, Message)? textBuilder,
|
||||
Widget Function(BuildContext, Message)? quotedMessageBuilder,
|
||||
@Deprecated('''
|
||||
Use [bottomRowBuilderWithDefaultWidget] instead.
|
||||
Will be removed in the next major version.
|
||||
@@ -659,9 +673,11 @@ class StreamMessageWidget extends StatefulWidget {
|
||||
onMentionTap: onMentionTap ?? this.onMentionTap,
|
||||
onThreadTap: onThreadTap ?? this.onThreadTap,
|
||||
onReplyTap: onReplyTap ?? this.onReplyTap,
|
||||
onConfirmDeleteTap: onConfirmDeleteTap ?? this.onConfirmDeleteTap,
|
||||
editMessageInputBuilder:
|
||||
editMessageInputBuilder ?? this.editMessageInputBuilder,
|
||||
textBuilder: textBuilder ?? this.textBuilder,
|
||||
quotedMessageBuilder: quotedMessageBuilder ?? this.quotedMessageBuilder,
|
||||
bottomRowBuilderWithDefaultWidget: _bottomRowBuilderWithDefaultWidget,
|
||||
onMessageActions: onMessageActions ?? this.onMessageActions,
|
||||
message: message ?? this.message,
|
||||
@@ -957,6 +973,7 @@ class _StreamMessageWidgetState extends State<StreamMessageWidget>
|
||||
borderSide: widget.borderSide,
|
||||
borderRadiusGeometry: widget.borderRadiusGeometry,
|
||||
textBuilder: widget.textBuilder,
|
||||
quotedMessageBuilder: widget.quotedMessageBuilder,
|
||||
onLinkTap: widget.onLinkTap,
|
||||
onMentionTap: widget.onMentionTap,
|
||||
onQuotedMessageTap: widget.onQuotedMessageTap,
|
||||
@@ -1098,16 +1115,21 @@ class _StreamMessageWidgetState extends State<StreamMessageWidget>
|
||||
),
|
||||
onClick: () async {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
final deleted = await showDialog(
|
||||
final deleted = await showDialog<bool?>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const DeleteMessageDialog(),
|
||||
);
|
||||
if (deleted) {
|
||||
if (deleted == true) {
|
||||
try {
|
||||
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 (e) {
|
||||
showDialog(
|
||||
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.onLinkTap,
|
||||
this.textBuilder,
|
||||
this.quotedMessageBuilder,
|
||||
@Deprecated('''
|
||||
Use [bottomRowBuilderWithDefaultWidget] instead.
|
||||
Will be removed in the next major version.
|
||||
@@ -170,6 +171,9 @@ class MessageWidgetContent extends StatelessWidget {
|
||||
/// {@macro textBuilder}
|
||||
final Widget Function(BuildContext, Message)? textBuilder;
|
||||
|
||||
/// {@macro quotedMessageBuilder}
|
||||
final Widget Function(BuildContext, Message)? quotedMessageBuilder;
|
||||
|
||||
/// {@macro showReactionPickerIndicator}
|
||||
final bool showReactionPickerIndicator;
|
||||
|
||||
@@ -351,6 +355,8 @@ class MessageWidgetContent extends StatelessWidget {
|
||||
onMentionTap: onMentionTap,
|
||||
onLinkTap: onLinkTap,
|
||||
textBuilder: textBuilder,
|
||||
quotedMessageBuilder:
|
||||
quotedMessageBuilder,
|
||||
borderRadiusGeometry:
|
||||
borderRadiusGeometry,
|
||||
borderSide: borderSide,
|
||||
|
||||
@@ -7,63 +7,41 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
///
|
||||
/// Used in [QuotedMessageCard]. Should not be used elsewhere.
|
||||
/// {@endtemplate}
|
||||
class QuotedMessage extends StatefulWidget {
|
||||
class QuotedMessage extends StatelessWidget {
|
||||
/// {@macro quotedMessage}
|
||||
const QuotedMessage({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.reverse,
|
||||
required this.hasNonUrlAttachments,
|
||||
this.onQuotedMessageTap,
|
||||
});
|
||||
|
||||
/// {@macro message}
|
||||
final Message message;
|
||||
|
||||
/// {@macro onQuotedMessageTap}
|
||||
final OnQuotedMessageTap? onQuotedMessageTap;
|
||||
|
||||
/// {@macro reverse}
|
||||
final bool reverse;
|
||||
|
||||
/// {@macro 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
|
||||
Widget build(BuildContext context) {
|
||||
final isMyMessage = widget.message.user?.id == _streamChat.currentUser?.id;
|
||||
final onTap = widget.message.quotedMessage?.isDeleted != true &&
|
||||
widget.onQuotedMessageTap != null
|
||||
? () => widget.onQuotedMessageTap!(widget.message.quotedMessageId)
|
||||
: null;
|
||||
final chatThemeData = _streamChatTheme;
|
||||
final streamChat = StreamChat.of(context);
|
||||
final chatThemeData = StreamChatTheme.of(context);
|
||||
|
||||
final isMyMessage = message.user?.id == streamChat.currentUser?.id;
|
||||
return StreamQuotedMessageWidget(
|
||||
onTap: onTap,
|
||||
message: widget.message.quotedMessage!,
|
||||
message: message.quotedMessage!,
|
||||
messageTheme: isMyMessage
|
||||
? chatThemeData.otherMessageTheme
|
||||
: chatThemeData.ownMessageTheme,
|
||||
reverse: widget.reverse,
|
||||
reverse: reverse,
|
||||
padding: EdgeInsets.only(
|
||||
right: 8,
|
||||
left: 8,
|
||||
top: 8,
|
||||
bottom: widget.hasNonUrlAttachments ? 8 : 0,
|
||||
bottom: hasNonUrlAttachments ? 8 : 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+5
-3
@@ -202,9 +202,10 @@ class MediaThumbnailProvider extends ImageProvider<MediaThumbnailProvider> {
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
@Deprecated('Will get replaced by loadImage in the next major version.')
|
||||
ImageStreamCompleter loadBuffer(
|
||||
MediaThumbnailProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
DecoderBufferCallback decode,
|
||||
) {
|
||||
return MultiFrameImageStreamCompleter(
|
||||
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(
|
||||
MediaThumbnailProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
DecoderBufferCallback decode,
|
||||
) async {
|
||||
assert(key == this, '$key is not $this');
|
||||
final bytes = await media.thumbnailDataWithSize(
|
||||
|
||||
+1
@@ -157,6 +157,7 @@ class StreamUserListTile extends StatelessWidget {
|
||||
trailing: selected ? selectedWidget : null,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
tileColor: tileColor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: stream_chat_flutter
|
||||
homepage: https://github.com/GetStream/stream-chat-flutter
|
||||
description: Stream Chat official Flutter SDK. Build your own chat experience using Dart and Flutter.
|
||||
version: 6.3.0
|
||||
version: 6.4.0
|
||||
repository: https://github.com/GetStream/stream-chat-flutter
|
||||
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
|
||||
|
||||
@@ -38,7 +38,7 @@ dependencies:
|
||||
rxdart: ^0.27.0
|
||||
share_plus: ^6.3.0
|
||||
shimmer: ^3.0.0
|
||||
stream_chat_flutter_core: ^6.3.0
|
||||
stream_chat_flutter_core: ^6.4.0
|
||||
synchronized: ^3.0.0
|
||||
thumblr: ^0.0.4
|
||||
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
|
||||
|
||||
- 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 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.
|
||||
// So, we just refresh the list.
|
||||
await controller.refresh(resetValue: false);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: stream_chat_flutter_core
|
||||
homepage: https://github.com/GetStream/stream-chat-flutter
|
||||
description: Stream Chat official Flutter SDK Core. Build your own chat experience using Dart and Flutter.
|
||||
version: 6.3.0
|
||||
version: 6.4.0
|
||||
repository: https://github.com/GetStream/stream-chat-flutter
|
||||
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
|
||||
|
||||
@@ -17,7 +17,7 @@ dependencies:
|
||||
freezed_annotation: ^2.0.3
|
||||
meta: ^1.8.0
|
||||
rxdart: ^0.27.0
|
||||
stream_chat: ^6.3.0
|
||||
stream_chat: ^6.4.0
|
||||
|
||||
dev_dependencies:
|
||||
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
|
||||
|
||||
* 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
|
||||
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
|
||||
repository: https://github.com/GetStream/stream-chat-flutter
|
||||
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
|
||||
@@ -14,7 +14,7 @@ dependencies:
|
||||
sdk: flutter
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
stream_chat_flutter: ^6.3.0
|
||||
stream_chat_flutter: ^6.4.0
|
||||
|
||||
dev_dependencies:
|
||||
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
|
||||
|
||||
- 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/entity/messages.dart';
|
||||
import 'package:stream_chat_persistence/src/entity/users.dart';
|
||||
|
||||
import 'package:stream_chat_persistence/src/mapper/mapper.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.
|
||||
@override
|
||||
int get schemaVersion => 11;
|
||||
int get schemaVersion => 12;
|
||||
|
||||
@override
|
||||
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.
|
||||
TextColumn get command => text().nullable()();
|
||||
|
||||
/// The DateTime when the message was created.
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
/// The DateTime on which the message was created.
|
||||
///
|
||||
/// 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.
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
/// The DateTime on which the message was created on the client.
|
||||
DateTimeColumn get localCreatedAt => dateTime().nullable()();
|
||||
|
||||
/// The DateTime when the message was deleted.
|
||||
DateTimeColumn get deletedAt => dateTime().nullable()();
|
||||
/// The DateTime on which the message was created on the server.
|
||||
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
|
||||
TextColumn get userId => text().nullable()();
|
||||
|
||||
@@ -21,9 +21,13 @@ extension MessageEntityX on MessageEntity {
|
||||
final json = jsonDecode(it);
|
||||
return Attachment.fromData(json);
|
||||
}).toList(),
|
||||
createdAt: createdAt,
|
||||
extraData: extraData ?? <String, Object>{},
|
||||
updatedAt: updatedAt,
|
||||
createdAt: remoteCreatedAt,
|
||||
localCreatedAt: localCreatedAt,
|
||||
updatedAt: remoteUpdatedAt,
|
||||
localUpdatedAt: localUpdatedAt,
|
||||
deletedAt: remoteDeletedAt,
|
||||
localDeletedAt: localDeletedAt,
|
||||
id: id,
|
||||
type: type,
|
||||
status: status,
|
||||
@@ -37,7 +41,6 @@ extension MessageEntityX on MessageEntity {
|
||||
showInChannel: showInChannel,
|
||||
text: messageText,
|
||||
user: user,
|
||||
deletedAt: deletedAt,
|
||||
pinned: pinned,
|
||||
pinnedAt: pinnedAt,
|
||||
pinExpires: pinExpires,
|
||||
@@ -59,7 +62,8 @@ extension MessageX on Message {
|
||||
parentId: parentId,
|
||||
quotedMessageId: quotedMessageId,
|
||||
command: command,
|
||||
createdAt: createdAt,
|
||||
remoteCreatedAt: remoteCreatedAt,
|
||||
localCreatedAt: localCreatedAt,
|
||||
shadowed: shadowed,
|
||||
showInChannel: showInChannel,
|
||||
replyCount: replyCount,
|
||||
@@ -67,10 +71,12 @@ extension MessageX on Message {
|
||||
reactionCounts: reactionCounts,
|
||||
mentionedUsers: mentionedUsers.map(jsonEncode).toList(),
|
||||
status: status,
|
||||
updatedAt: updatedAt,
|
||||
remoteUpdatedAt: remoteUpdatedAt,
|
||||
localUpdatedAt: localUpdatedAt,
|
||||
extraData: extraData,
|
||||
userId: user?.id,
|
||||
deletedAt: deletedAt,
|
||||
remoteDeletedAt: remoteDeletedAt,
|
||||
localDeletedAt: localDeletedAt,
|
||||
messageText: text,
|
||||
pinned: pinned,
|
||||
pinnedAt: pinnedAt,
|
||||
|
||||
@@ -21,9 +21,13 @@ extension PinnedMessageEntityX on PinnedMessageEntity {
|
||||
final json = jsonDecode(it);
|
||||
return Attachment.fromData(json);
|
||||
}).toList(),
|
||||
createdAt: createdAt,
|
||||
extraData: extraData ?? <String, Object>{},
|
||||
updatedAt: updatedAt,
|
||||
createdAt: remoteCreatedAt,
|
||||
localCreatedAt: localCreatedAt,
|
||||
updatedAt: remoteUpdatedAt,
|
||||
localUpdatedAt: localUpdatedAt,
|
||||
deletedAt: remoteDeletedAt,
|
||||
localDeletedAt: localDeletedAt,
|
||||
id: id,
|
||||
type: type,
|
||||
status: status,
|
||||
@@ -37,7 +41,6 @@ extension PinnedMessageEntityX on PinnedMessageEntity {
|
||||
showInChannel: showInChannel,
|
||||
text: messageText,
|
||||
user: user,
|
||||
deletedAt: deletedAt,
|
||||
pinned: pinned,
|
||||
pinnedAt: pinnedAt,
|
||||
pinExpires: pinExpires,
|
||||
@@ -60,7 +63,8 @@ extension PMessageX on Message {
|
||||
parentId: parentId,
|
||||
quotedMessageId: quotedMessageId,
|
||||
command: command,
|
||||
createdAt: createdAt,
|
||||
remoteCreatedAt: remoteCreatedAt,
|
||||
localCreatedAt: localCreatedAt,
|
||||
shadowed: shadowed,
|
||||
showInChannel: showInChannel,
|
||||
replyCount: replyCount,
|
||||
@@ -68,10 +72,12 @@ extension PMessageX on Message {
|
||||
reactionCounts: reactionCounts,
|
||||
mentionedUsers: mentionedUsers.map(jsonEncode).toList(),
|
||||
status: status,
|
||||
updatedAt: updatedAt,
|
||||
remoteUpdatedAt: remoteUpdatedAt,
|
||||
localUpdatedAt: localUpdatedAt,
|
||||
extraData: extraData,
|
||||
userId: user?.id,
|
||||
deletedAt: deletedAt,
|
||||
remoteDeletedAt: remoteDeletedAt,
|
||||
localDeletedAt: localDeletedAt,
|
||||
messageText: text,
|
||||
pinned: pinned,
|
||||
pinnedAt: pinnedAt,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: stream_chat_persistence
|
||||
homepage: https://github.com/GetStream/stream-chat-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
|
||||
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
|
||||
|
||||
@@ -18,7 +18,7 @@ dependencies:
|
||||
path: ^1.8.2
|
||||
path_provider: ^2.0.1
|
||||
sqlite3_flutter_libs: ^0.5.0
|
||||
stream_chat: ^6.3.0
|
||||
stream_chat: ^6.4.0
|
||||
|
||||
dev_dependencies:
|
||||
build_runner: ^2.3.3
|
||||
|
||||
@@ -38,7 +38,8 @@ void main() {
|
||||
parentId: 'testParentId',
|
||||
quotedMessageId: quotedMessage.id,
|
||||
command: 'testCommand',
|
||||
createdAt: DateTime.now(),
|
||||
localCreatedAt: DateTime.now(),
|
||||
remoteCreatedAt: DateTime.now().add(const Duration(seconds: 1)),
|
||||
shadowed: math.Random().nextBool(),
|
||||
showInChannel: math.Random().nextBool(),
|
||||
replyCount: 33,
|
||||
@@ -52,10 +53,12 @@ void main() {
|
||||
jsonEncode(User(id: 'testuser')),
|
||||
],
|
||||
status: MessageSendingStatus.sent,
|
||||
updatedAt: DateTime.now(),
|
||||
localUpdatedAt: DateTime.now(),
|
||||
remoteUpdatedAt: DateTime.now().add(const Duration(seconds: 1)),
|
||||
extraData: {'extra_test_data': 'extraData'},
|
||||
userId: user.id,
|
||||
deletedAt: DateTime.now(),
|
||||
localDeletedAt: DateTime.now(),
|
||||
remoteDeletedAt: DateTime.now().add(const Duration(seconds: 1)),
|
||||
messageText: 'Hello',
|
||||
pinned: true,
|
||||
pinExpires: DateTime.now().toUtc(),
|
||||
@@ -81,7 +84,8 @@ void main() {
|
||||
expect(message.parentId, entity.parentId);
|
||||
expect(message.quotedMessageId, entity.quotedMessageId);
|
||||
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.showInChannel, entity.showInChannel);
|
||||
for (var i = 0; i < message.mentionedUsers.length; i++) {
|
||||
@@ -93,10 +97,12 @@ void main() {
|
||||
expect(message.reactionScores, entity.reactionScores);
|
||||
expect(message.reactionCounts, entity.reactionCounts);
|
||||
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.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.pinned, entity.pinned);
|
||||
expect(message.pinExpires, isSameDateAs(entity.pinExpires));
|
||||
@@ -144,7 +150,8 @@ void main() {
|
||||
parentId: 'testParentId',
|
||||
quotedMessageId: quotedMessage.id,
|
||||
command: 'testCommand',
|
||||
createdAt: DateTime.now(),
|
||||
localCreatedAt: DateTime.now(),
|
||||
createdAt: DateTime.now().add(const Duration(seconds: 1)),
|
||||
shadowed: math.Random().nextBool(),
|
||||
showInChannel: math.Random().nextBool(),
|
||||
replyCount: 33,
|
||||
@@ -157,10 +164,12 @@ void main() {
|
||||
(prev, curr) =>
|
||||
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'},
|
||||
user: user,
|
||||
deletedAt: DateTime.now(),
|
||||
localDeletedAt: DateTime.now(),
|
||||
deletedAt: DateTime.now().add(const Duration(seconds: 1)),
|
||||
text: 'Hello',
|
||||
pinned: true,
|
||||
pinExpires: DateTime.now(),
|
||||
@@ -179,7 +188,8 @@ void main() {
|
||||
expect(entity.parentId, message.parentId);
|
||||
expect(entity.quotedMessageId, message.quotedMessageId);
|
||||
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.showInChannel, message.showInChannel);
|
||||
expect(entity.replyCount, message.replyCount);
|
||||
@@ -188,10 +198,12 @@ void main() {
|
||||
expect(entity.reactionScores, message.reactionScores);
|
||||
expect(entity.reactionCounts, message.reactionCounts);
|
||||
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.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.pinned, message.pinned);
|
||||
expect(entity.pinExpires, isSameDateAs(message.pinExpires));
|
||||
|
||||
@@ -38,7 +38,8 @@ void main() {
|
||||
parentId: 'testParentId',
|
||||
quotedMessageId: quotedMessage.id,
|
||||
command: 'testCommand',
|
||||
createdAt: DateTime.now(),
|
||||
localCreatedAt: DateTime.now(),
|
||||
remoteCreatedAt: DateTime.now().add(const Duration(seconds: 1)),
|
||||
shadowed: math.Random().nextBool(),
|
||||
showInChannel: math.Random().nextBool(),
|
||||
replyCount: 33,
|
||||
@@ -48,12 +49,16 @@ void main() {
|
||||
(prev, curr) =>
|
||||
prev?..update(curr.type, (value) => value + 1, ifAbsent: () => 1),
|
||||
),
|
||||
mentionedUsers: [],
|
||||
mentionedUsers: [
|
||||
jsonEncode(User(id: 'testuser')),
|
||||
],
|
||||
status: MessageSendingStatus.sent,
|
||||
updatedAt: DateTime.now(),
|
||||
localUpdatedAt: DateTime.now(),
|
||||
remoteUpdatedAt: DateTime.now().add(const Duration(seconds: 1)),
|
||||
extraData: {'extra_test_data': 'extraData'},
|
||||
userId: user.id,
|
||||
deletedAt: DateTime.now(),
|
||||
localDeletedAt: DateTime.now(),
|
||||
remoteDeletedAt: DateTime.now().add(const Duration(seconds: 1)),
|
||||
messageText: 'Hello',
|
||||
pinned: true,
|
||||
pinExpires: DateTime.now().toUtc(),
|
||||
@@ -79,17 +84,25 @@ void main() {
|
||||
expect(message.parentId, entity.parentId);
|
||||
expect(message.quotedMessageId, entity.quotedMessageId);
|
||||
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.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.reactionScores, entity.reactionScores);
|
||||
expect(message.reactionCounts, entity.reactionCounts);
|
||||
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.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.pinned, entity.pinned);
|
||||
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';
|
||||
final user = User(id: 'testUserId');
|
||||
final quotedMessage = Message(id: 'testQuotedMessageId');
|
||||
@@ -137,20 +150,26 @@ void main() {
|
||||
parentId: 'testParentId',
|
||||
quotedMessageId: quotedMessage.id,
|
||||
command: 'testCommand',
|
||||
createdAt: DateTime.now(),
|
||||
localCreatedAt: DateTime.now(),
|
||||
createdAt: DateTime.now().add(const Duration(seconds: 1)),
|
||||
shadowed: math.Random().nextBool(),
|
||||
showInChannel: math.Random().nextBool(),
|
||||
replyCount: 33,
|
||||
mentionedUsers: [
|
||||
User(id: 'testuser'),
|
||||
],
|
||||
reactionScores: {for (final r in reactions) r.type: r.score},
|
||||
reactionCounts: reactions.fold(
|
||||
{},
|
||||
(prev, curr) =>
|
||||
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'},
|
||||
user: user,
|
||||
deletedAt: DateTime.now(),
|
||||
localDeletedAt: DateTime.now(),
|
||||
deletedAt: DateTime.now().add(const Duration(seconds: 1)),
|
||||
text: 'Hello',
|
||||
pinned: true,
|
||||
pinExpires: DateTime.now(),
|
||||
@@ -169,17 +188,22 @@ void main() {
|
||||
expect(entity.parentId, message.parentId);
|
||||
expect(entity.quotedMessageId, message.quotedMessageId);
|
||||
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.showInChannel, message.showInChannel);
|
||||
expect(entity.replyCount, message.replyCount);
|
||||
expect(
|
||||
entity.mentionedUsers, message.mentionedUsers.map(jsonEncode).toList());
|
||||
expect(entity.reactionScores, message.reactionScores);
|
||||
expect(entity.reactionCounts, message.reactionCounts);
|
||||
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.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.pinned, message.pinned);
|
||||
expect(entity.pinExpires, isSameDateAs(message.pinExpires));
|
||||
|
||||
Reference in New Issue
Block a user