Merge pull request #1625 from GetStream/release/6.4.0

This commit is contained in:
Sahil Kumar
2023-06-19 16:54:12 +05:30
committed by GitHub
51 changed files with 1841 additions and 559 deletions
+9
View File
@@ -1,3 +1,12 @@
## 6.4.0
🐞 Fixed
- [[#1293]](https://github.com/GetStream/stream-chat-flutter/issues/1293) Fixed wrong message order when sending
messages quickly.
- [[#1612]](https://github.com/GetStream/stream-chat-flutter/issues/1612) Fixed `Channel.isMutedStream` does not emit
when channel mute expires.
## 6.3.0
🐞 Fixed
+104 -45
View File
@@ -7,6 +7,7 @@ import 'package:rxdart/rxdart.dart';
import 'package:stream_chat/src/client/retry_queue.dart';
import 'package:stream_chat/src/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,
+1 -1
View File
@@ -3,4 +3,4 @@ import 'package:stream_chat/src/client/client.dart';
/// Current package version
/// Used in [StreamChatClient] to build the `x-stream-client` header
// ignore: constant_identifier_names
const PACKAGE_VERSION = '6.3.0';
const PACKAGE_VERSION = '6.4.0';
+1 -1
View File
@@ -1,7 +1,7 @@
name: stream_chat
homepage: https://getstream.io/
description: The official Dart client for Stream Chat, a service for building chat applications.
version: 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),
+97
View File
@@ -1,3 +1,100 @@
## 6.4.0
🐞 Fixed
- [[#1600]](https://github.com/GetStream/stream-chat-flutter/issues/1600) Fixed type `ImageDecoderCallback` not found
error on pre-Flutter 3.10.0 versions.
- [[#1605]](https://github.com/GetStream/stream-chat-flutter/issues/1605) Fixed Null exception is thrown on message list
for unread messages when `ScrollToBottomButton` is pressed.
- [[#1615]](https://github.com/GetStream/stream-chat-flutter/issues/1615) Fixed `StreamAttachmentPickerBottomSheet` not
able to find the `StreamChatTheme` when used in nested MaterialApp.
✅ Added
- Added support for `StreamMessageInput.allowedAttachmentPickerTypes` to specify the allowed attachment picker types.
[#1601](https://github.com/GetStream/stream-chat-flutter/issues/1376)
```dart
StreamMessageInput(
...,
allowedAttachmentPickerTypes: const [
AttachmentPickerType.files,
AttachmentPickerType.images,
],
)
```
- Added support for `StreamMessageWidget.onConfirmDeleteTap` to override the default action on delete confirmation.
[#1604](https://github.com/GetStream/stream-chat-flutter/issues/1604)
```dart
StreamMessageWidget(
...,
onConfirmDeleteTap: (message) async {
final channel = StreamChannel.of(context).channel;
await channel.deleteMessage(message, hard: false);
},
)
```
- Added support for `StreamMessageWidget.quotedMessageBuilder` and `StreamMessageInput.quotedMessageBuilder` to override
the default quoted message widget. [#1547](https://github.com/GetStream/stream-chat-flutter/issues/1547)
```dart
StreamMessageWidget(
...,
quotedMessageBuilder: (context, message) {
return Container(
color: Colors.red,
child: Text('Quoted Message'),
);
},
)
```
- Added support for `StreamChannelAvatar.ownSpaceAvatarBuilder`, `StreamChannelAvatar.oneToOneAvatarBuilder` and
`StreamChannelAvatar.groupAvatarBuilder` to override the default avatar
widget.[#1614](https://github.com/GetStream/stream-chat-flutter/issues/1614)
```dart
StreamChannelAvatar(
...,
ownSpaceAvatarBuilder: (context, channel) {
return Container(
color: Colors.red,
child: Text('Own Space Avatar'),
);
},
oneToOneAvatarBuilder: (context, channel) {
return Container(
color: Colors.red,
child: Text('One to One Avatar'),
);
},
groupAvatarBuilder: (context, channel) {
return Container(
color: Colors.red,
child: Text('Group Avatar'),
);
},
)
```
- Added support for `StreamMessageInput.contentInsertionConfiguration` to specify the content insertion configuration.
[#1613](https://github.com/GetStream/stream-chat-flutter/issues/1613)
```dart
StreamMessageInput(
...,
contentInsertionConfiguration: ContentInsertionConfiguration(
onContentInserted: (content) {
// Do something with the content.
controller.addAttachment(...);
},
),
)
```
## 6.3.0
🐞 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,
@@ -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();
}
@@ -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(
@@ -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,
),
);
}
@@ -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(
@@ -157,6 +157,7 @@ class StreamUserListTile extends StatelessWidget {
trailing: selected ? selectedWidget : null,
title: title,
subtitle: subtitle,
tileColor: tileColor,
);
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
name: stream_chat_flutter
homepage: https://github.com/GetStream/stream-chat-flutter
description: Stream Chat official Flutter SDK. Build your own chat experience using Dart and Flutter.
version: 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));