diff --git a/migrations/v10-migration.md b/migrations/v10-migration.md index 951fd6c344..fbce8c2d25 100644 --- a/migrations/v10-migration.md +++ b/migrations/v10-migration.md @@ -8,6 +8,10 @@ This guide includes breaking changes grouped by release phase: +### ๐Ÿšง v10.0.0-beta.7 + +- [MessageState](#-messagestate) + ### ๐Ÿšง v10.0.0-beta.4 - [SendReaction](#-sendreaction) @@ -28,6 +32,77 @@ This guide includes breaking changes grouped by release phase: --- +## ๐Ÿงช Migration for v10.0.0-beta.7 + +### ๐Ÿ›  MessageState + +#### Key Changes: + +- `MessageState` factory constructors now accept `MessageDeleteScope` instead of `bool hard` parameter +- Pattern matching callbacks in state classes now receive `MessageDeleteScope scope` instead of `bool hard` +- New delete-for-me functionality with dedicated states and methods + +#### Migration Steps: + +**Before:** +```dart +// Factory constructors with bool hard +final deletingState = MessageState.deleting(hard: true); +final deletedState = MessageState.deleted(hard: false); +final failedState = MessageState.deletingFailed(hard: true); + +// Pattern matching with bool hard +message.state.whenOrNull( + deleting: (hard) => handleDeleting(hard), + deleted: (hard) => handleDeleted(hard), + deletingFailed: (hard) => handleDeletingFailed(hard), +); +``` + +**After:** +```dart +// Factory constructors with MessageDeleteScope +final deletingState = MessageState.deleting( + scope: MessageDeleteScope.hardDeleteForAll, +); +final deletedState = MessageState.deleted( + scope: MessageDeleteScope.softDeleteForAll, +); +final failedState = MessageState.deletingFailed( + scope: MessageDeleteScope.deleteForMe(), +); + +// Pattern matching with MessageDeleteScope +message.state.whenOrNull( + deleting: (scope) => handleDeleting(scope.hard), + deleted: (scope) => handleDeleted(scope.hard), + deletingFailed: (scope) => handleDeletingFailed(scope.hard), +); + +// New delete-for-me functionality +channel.deleteMessageForMe(message); // Delete only for current user +client.deleteMessageForMe(messageId); // Delete only for current user + +// Check delete-for-me states +if (message.state.isDeletingForMe) { + // Handle deleting for me state +} +if (message.state.isDeletedForMe) { + // Handle deleted for me state +} +if (message.state.isDeletingForMeFailed) { + // Handle delete for me failed state +} +``` + +> โš ๏ธ **Important:** +> - All `MessageState` factory constructors now require `MessageDeleteScope` parameter +> - Pattern matching callbacks receive `MessageDeleteScope` instead of `bool hard` +> - Use `scope.hard` to access the hard delete boolean value +> - New delete-for-me methods are available on both `Channel` and `StreamChatClient` + +--- + ## ๐Ÿงช Migration for v10.0.0-beta.4 ### ๐Ÿ›  SendReaction @@ -429,6 +504,15 @@ StreamMessageWidget( ## ๐ŸŽ‰ You're Ready to Migrate! +### For v10.0.0-beta.7: +- โœ… Update `MessageState` factory constructors to use `MessageDeleteScope` parameter +- โœ… Update pattern matching callbacks to handle `MessageDeleteScope` instead of `bool hard` +- โœ… Leverage new delete-for-me functionality with `deleteMessageForMe` methods +- โœ… Use new state checking methods for delete-for-me operations + +### For v10.0.0-beta.4: +- โœ… Update `sendReaction` method calls to use `Reaction` object instead of individual parameters + ### For v10.0.0-beta.3: - โœ… Update attachment picker options to use `SystemAttachmentPickerOption` or `TabbedAttachmentPickerOption` - โœ… Handle new `StreamAttachmentPickerResult` return type from attachment picker diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index c1402692f8..c4d7aadebb 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -1,3 +1,22 @@ +## Upcoming Beta + +๐Ÿ›‘๏ธ Breaking + +- **Changed `MessageState` factory constructors**: The `deleting`, `deleted`, and `deletingFailed` + factory constructors now accept a `MessageDeleteScope` parameter instead of `bool hard`. + Pattern matching callbacks also receive `MessageDeleteScope scope` instead of `bool hard`. + +โœ… Added + +- Added support for deleting messages only for the current user: + - `Channel.deleteMessageForMe()` - Delete a message only for the current user + - `StreamChatClient.deleteMessageForMe()` - Delete a message only for the current user via client + - `MessageDeleteScope` - New sealed class to represent deletion scope + - `MessageState.deletingForMe`, `MessageState.deletedForMe`, `MessageState.deletingForMeFailed` states + - `Message.deletedOnlyForMe`, `Event.deletedForMe`, `Member.deletedMessages` model fields + +For more details, please refer to the [migration guide](../../migrations/v10-migration.md). + ## 10.0.0-beta.6 - Included the changes from version [`9.17.0`](https://pub.dev/packages/stream_chat/changelog). diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index d71761fd82..91e3ca6b62 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -920,31 +920,49 @@ class Channel { final _deleteMessageLock = Lock(); - /// Deletes the [message] from the channel. - Future deleteMessage( + /// Deletes the [message] for everyone. + /// + /// If [hard] is true, the message is permanently deleted from the server + /// and cannot be recovered. In this case, any attachments associated with the + /// message are also deleted from the server. + Future deleteMessage(Message message, {bool hard = false}) { + final deletionScope = MessageDeleteScope.deleteForAll(hard: hard); + + return _deleteMessage(message, scope: deletionScope); + } + + /// Deletes the [message] only for the current user. + /// + /// Note: This does not delete the message for other channel members and + /// they can still see the message. + Future deleteMessageForMe(Message message) { + const deletionScope = MessageDeleteScope.deleteForMe(); + + return _deleteMessage(message, scope: deletionScope); + } + + // Deletes the [message] from the channel. + // + // The [scope] defines whether to delete the message for everyone or just + // for the current user. + // + // If the message is a local message (not yet sent to the server) or a bounced + // error message, it is deleted locally without making an API call. + // + // If the message is deleted for everyone and [scope.hard] is true, the + // message is permanently deleted from the server and cannot be recovered. + // In this case, any attachments associated with the message are also deleted + // from the server. + Future _deleteMessage( Message message, { - bool hard = false, + required MessageDeleteScope scope, }) async { _checkInitialized(); // Directly deleting the local messages and bounced error messages as they // are not available on the server. if (message.remoteCreatedAt == null || message.isBouncedWithError) { - state?.deleteMessage( - message.copyWith( - type: MessageType.deleted, - localDeletedAt: DateTime.now(), - state: MessageState.deleted(hard: hard), - ), - hardDelete: hard, - ); - - // Removing the attachments upload completer to stop the `sendMessage` - // waiting for attachments to complete. - _messageAttachmentsUploadCompleter - .remove(message.id) - ?.completeError(const StreamChatError('Message deleted')); - + _deleteLocalMessage(message); // Returning empty response to mark the api call as success. return EmptyResponse(); } @@ -953,44 +971,39 @@ class Channel { message = message.copyWith( type: MessageType.deleted, deletedAt: DateTime.now(), - state: MessageState.deleting(hard: hard), + deletedForMe: scope is DeleteForMe, + state: MessageState.deleting(scope: scope), ); - state?.deleteMessage(message, hardDelete: hard); + state?.deleteMessage(message, hardDelete: scope.hard); try { // 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), + () => switch (scope) { + DeleteForMe() => _client.deleteMessageForMe(message.id), + DeleteForAll() => _client.deleteMessage(message.id, hard: scope.hard), + }, ); final deletedMessage = message.copyWith( - state: MessageState.deleted(hard: hard), + deletedForMe: scope is DeleteForMe, + state: MessageState.deleted(scope: scope), ); - state?.deleteMessage(deletedMessage, hardDelete: hard); - - if (hard) { - deletedMessage.attachments.forEach((attachment) { - if (attachment.uploadState.isSuccess) { - if (attachment.type == AttachmentType.image) { - deleteImage(attachment.imageUrl!); - } else if (attachment.type == AttachmentType.file) { - deleteFile(attachment.assetUrl!); - } - } - }); - } + state?.deleteMessage(deletedMessage, hardDelete: scope.hard); + // If hard delete, also delete the attachments from the server. + if (scope.hard) _deleteMessageAttachments(deletedMessage); return response; } catch (e) { final failedMessage = message.copyWith( // Update the message state to failed. - state: MessageState.deletingFailed(hard: hard), + state: MessageState.deletingFailed(scope: scope), ); - state?.deleteMessage(failedMessage, hardDelete: hard); + state?.deleteMessage(failedMessage, hardDelete: scope.hard); // If the error is retriable, add it to the retry queue. if (e is StreamChatNetworkError && e.isRetriable) { state?._retryQueue.add([failedMessage]); @@ -1000,6 +1013,43 @@ class Channel { } } + // Deletes a local [message] that is not yet sent to the server. + // + // This is typically called when a user wants to delete a message that they + // have composed but not yet sent, or if a message failed to send and the user + // wants to remove it from their local view. + void _deleteLocalMessage(Message message) { + state?.deleteMessage( + hardDelete: true, // Local messages are always hard deleted. + message.copyWith( + type: MessageType.deleted, + localDeletedAt: DateTime.now(), + state: MessageState.hardDeleted, + ), + ); + + // Removing the attachments upload completer to stop the `sendMessage` + // waiting for attachments to complete. + final completer = _messageAttachmentsUploadCompleter.remove(message.id); + completer?.completeError(const StreamChatError('Message deleted')); + } + + // Deletes all the attachments associated with the given [message] + // from the server. This is typically called when a message is hard deleted. + Future _deleteMessageAttachments(Message message) async { + final attachments = message.attachments; + final deleteFutures = attachments.map((it) async { + if (it.imageUrl case final url?) return deleteImage(url); + if (it.assetUrl case final url?) return deleteFile(url); + }); + + try { + await Future.wait(deleteFutures); + } catch (e, stk) { + _client.logger.warning('Error deleting message attachments', e, stk); + } + } + /// Retries operations on a message based on its failed state. /// /// This method examines the message's state and performs the appropriate @@ -1009,9 +1059,12 @@ class Channel { /// - For [MessageState.partialUpdatingFailed], it attempts to partially /// update the message with the same 'set' and 'unset' parameters that were /// used in the original request. - /// - For [MessageState.deletingFailed], it attempts to delete the message. - /// with the same 'hard' parameter that was used in the original request + /// - For [MessageState.deletingFailed], it attempts to delete the message + /// again, using the same scope (for me or for all) as the original request. /// - For messages with [isBouncedWithError], it attempts to send the message. + /// + /// Throws a [StateError] if the message is not in a failed state or + /// bounced with an error. Future retryMessage(Message message) async { assert( message.state.isFailed || message.isBouncedWithError, @@ -1038,7 +1091,10 @@ class Channel { skipEnrichUrl: skipEnrichUrl, ); }, - deletingFailed: (hard) => deleteMessage(message, hard: hard), + deletingFailed: (scope) => switch (scope) { + DeleteForMe() => deleteMessageForMe(message), + DeleteForAll(hard: final hard) => deleteMessage(message, hard: hard), + }, ), orElse: () { // Check if the message is bounced with error. diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 4a03630c8e..0c8472eaad 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -1744,21 +1744,29 @@ class StreamChatClient { skipEnrichUrl: skipEnrichUrl, ); - /// Deletes the given message + /// Deletes the given message. + /// + /// If [hard] is true, the message is permanently deleted. Future deleteMessage( String messageId, { bool hard = false, - }) async { - final response = await _chatApi.message.deleteMessage( + }) { + return _chatApi.message.deleteMessage( messageId, hard: hard, ); + } - if (hard) { - await chatPersistenceClient?.deleteMessageById(messageId); - } - - return response; + /// Deletes the given message for the current user only. + /// + /// Note: This does not delete the message for other users in the channel. + Future deleteMessageForMe( + String messageId, + ) { + return _chatApi.message.deleteMessage( + messageId, + deleteForMe: true, + ); } /// Get a message by [messageId] diff --git a/packages/stream_chat/lib/src/core/api/message_api.dart b/packages/stream_chat/lib/src/core/api/message_api.dart index 19a363bb3b..3dc132ea1c 100644 --- a/packages/stream_chat/lib/src/core/api/message_api.dart +++ b/packages/stream_chat/lib/src/core/api/message_api.dart @@ -175,14 +175,20 @@ class MessageApi { Future deleteMessage( String messageId, { bool? hard, + bool? deleteForMe, }) async { + if (hard == true && deleteForMe == true) { + throw ArgumentError( + 'Both hard and deleteForMe cannot be set at the same time.', + ); + } + final response = await _client.delete( '/messages/$messageId', - queryParameters: hard != null - ? { - 'hard': hard, - } - : null, + queryParameters: { + if (hard != null) 'hard': hard, + if (deleteForMe != null) 'delete_for_me': deleteForMe, + }, ); return EmptyResponse.fromJson(response.data); } diff --git a/packages/stream_chat/lib/src/core/models/event.dart b/packages/stream_chat/lib/src/core/models/event.dart index 75e0211b45..8c96bebfe8 100644 --- a/packages/stream_chat/lib/src/core/models/event.dart +++ b/packages/stream_chat/lib/src/core/models/event.dart @@ -30,6 +30,7 @@ class Event { this.channelLastMessageAt, this.parentId, this.hardDelete, + this.deletedForMe, this.aiState, this.aiMessage, this.messageId, @@ -122,6 +123,9 @@ class Event { /// This is true if the message has been hard deleted final bool? hardDelete; + /// Whether the message was deleted only for the current user. + final bool? deletedForMe; + /// The current state of the AI assistant. @JsonKey(unknownEnumValue: AITypingState.idle) final AITypingState? aiState; @@ -189,6 +193,7 @@ class Event { 'channel_last_message_at', 'parent_id', 'hard_delete', + 'deleted_for_me', 'is_local', 'ai_state', 'ai_message', @@ -233,6 +238,7 @@ class Event { bool? online, String? parentId, bool? hardDelete, + bool? deletedForMe, AITypingState? aiState, String? aiMessage, String? messageId, @@ -270,6 +276,7 @@ class Event { channelLastMessageAt: channelLastMessageAt ?? this.channelLastMessageAt, parentId: parentId ?? this.parentId, hardDelete: hardDelete ?? this.hardDelete, + deletedForMe: deletedForMe ?? this.deletedForMe, aiState: aiState ?? this.aiState, aiMessage: aiMessage ?? this.aiMessage, messageId: messageId ?? this.messageId, diff --git a/packages/stream_chat/lib/src/core/models/event.g.dart b/packages/stream_chat/lib/src/core/models/event.g.dart index 1623dc1438..dc9681a9c2 100644 --- a/packages/stream_chat/lib/src/core/models/event.g.dart +++ b/packages/stream_chat/lib/src/core/models/event.g.dart @@ -48,6 +48,7 @@ Event _$EventFromJson(Map json) => Event( : DateTime.parse(json['channel_last_message_at'] as String), parentId: json['parent_id'] as String?, hardDelete: json['hard_delete'] as bool?, + deletedForMe: json['deleted_for_me'] as bool?, aiState: $enumDecodeNullable(_$AITypingStateEnumMap, json['ai_state'], unknownValue: AITypingState.idle), aiMessage: json['ai_message'] as String?, @@ -105,6 +106,7 @@ Map _$EventToJson(Event instance) => { if (instance.parentId case final value?) 'parent_id': value, 'is_local': instance.isLocal, if (instance.hardDelete case final value?) 'hard_delete': value, + if (instance.deletedForMe case final value?) 'deleted_for_me': value, if (_$AITypingStateEnumMap[instance.aiState] case final value?) 'ai_state': value, if (instance.aiMessage case final value?) 'ai_message': value, diff --git a/packages/stream_chat/lib/src/core/models/member.dart b/packages/stream_chat/lib/src/core/models/member.dart index 66b51a287a..452412f7c1 100644 --- a/packages/stream_chat/lib/src/core/models/member.dart +++ b/packages/stream_chat/lib/src/core/models/member.dart @@ -26,6 +26,7 @@ class Member extends Equatable implements ComparableFieldProvider { this.shadowBanned = false, this.pinnedAt, this.archivedAt, + this.deletedMessages = const [], this.extraData = const {}, }) : userId = userId ?? user?.id, createdAt = createdAt ?? DateTime.now(), @@ -53,7 +54,8 @@ class Member extends Equatable implements ComparableFieldProvider { 'created_at', 'updated_at', 'pinned_at', - 'archived_at' + 'archived_at', + 'deleted_messages', ]; /// The interested user @@ -98,6 +100,12 @@ class Member extends Equatable implements ComparableFieldProvider { /// The last date of update final DateTime updatedAt; + /// List of message ids deleted by this member only for himself. + /// + /// These messages are not visible to this member anymore, but are still + /// visible to other channel members. + final List deletedMessages; + /// Map of custom member extraData. final Map extraData; @@ -118,6 +126,7 @@ class Member extends Equatable implements ComparableFieldProvider { bool? banned, DateTime? banExpires, bool? shadowBanned, + List? deletedMessages, Map? extraData, }) => Member( @@ -135,6 +144,7 @@ class Member extends Equatable implements ComparableFieldProvider { archivedAt: archivedAt ?? this.archivedAt, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, + deletedMessages: deletedMessages ?? this.deletedMessages, extraData: extraData ?? this.extraData, ); @@ -159,6 +169,7 @@ class Member extends Equatable implements ComparableFieldProvider { archivedAt, createdAt, updatedAt, + deletedMessages, extraData, ]; diff --git a/packages/stream_chat/lib/src/core/models/member.g.dart b/packages/stream_chat/lib/src/core/models/member.g.dart index 0cd677e72a..abdaf29d65 100644 --- a/packages/stream_chat/lib/src/core/models/member.g.dart +++ b/packages/stream_chat/lib/src/core/models/member.g.dart @@ -37,6 +37,10 @@ Member _$MemberFromJson(Map json) => Member( archivedAt: json['archived_at'] == null ? null : DateTime.parse(json['archived_at'] as String), + deletedMessages: (json['deleted_messages'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], extraData: json['extra_data'] as Map? ?? const {}, ); @@ -55,5 +59,6 @@ Map _$MemberToJson(Member instance) => { 'archived_at': instance.archivedAt?.toIso8601String(), 'created_at': instance.createdAt.toIso8601String(), 'updated_at': instance.updatedAt.toIso8601String(), + 'deleted_messages': instance.deletedMessages, 'extra_data': instance.extraData, }; diff --git a/packages/stream_chat/lib/src/core/models/message.dart b/packages/stream_chat/lib/src/core/models/message.dart index 150224031c..34037d22df 100644 --- a/packages/stream_chat/lib/src/core/models/message.dart +++ b/packages/stream_chat/lib/src/core/models/message.dart @@ -51,6 +51,7 @@ class Message extends Equatable implements ComparableFieldProvider { this.localUpdatedAt, DateTime? deletedAt, this.localDeletedAt, + this.deletedForMe, this.messageTextUpdatedAt, this.user, this.pinned = false, @@ -83,7 +84,9 @@ class Message extends Equatable implements ComparableFieldProvider { ); var state = MessageState.sent; - if (message.deletedAt != null) { + if (message.deletedForMe ?? false) { + state = MessageState.deletedForMe; + } else if (message.deletedAt != null) { state = MessageState.softDeleted; } else if (message.updatedAt.isAfter(message.createdAt)) { state = MessageState.updated; @@ -311,6 +314,10 @@ class Message extends Equatable implements ComparableFieldProvider { @JsonKey(includeIfNull: false) final Location? sharedLocation; + /// Whether the message was deleted only for the current user. + @JsonKey(includeToJson: false) + final bool? deletedForMe; + /// Message custom extraData. final Map extraData; @@ -360,6 +367,7 @@ class Message extends Equatable implements ComparableFieldProvider { 'draft', 'reminder', 'shared_location', + 'deleted_for_me', ]; /// Serialize to json. @@ -419,6 +427,7 @@ class Message extends Equatable implements ComparableFieldProvider { Object? draft = _nullConst, Object? reminder = _nullConst, Location? sharedLocation, + bool? deletedForMe, }) { assert(() { if (pinExpires is! DateTime && @@ -497,6 +506,7 @@ class Message extends Equatable implements ComparableFieldProvider { reminder: reminder == _nullConst ? this.reminder : reminder as MessageReminder?, sharedLocation: sharedLocation ?? this.sharedLocation, + deletedForMe: deletedForMe ?? this.deletedForMe, ); } @@ -543,6 +553,7 @@ class Message extends Equatable implements ComparableFieldProvider { draft: other.draft, reminder: other.reminder, sharedLocation: other.sharedLocation, + deletedForMe: other.deletedForMe, ); } @@ -609,6 +620,7 @@ class Message extends Equatable implements ComparableFieldProvider { draft, reminder, sharedLocation, + deletedForMe, ]; @override diff --git a/packages/stream_chat/lib/src/core/models/message.g.dart b/packages/stream_chat/lib/src/core/models/message.g.dart index 3add1df41b..52a82f6ab3 100644 --- a/packages/stream_chat/lib/src/core/models/message.g.dart +++ b/packages/stream_chat/lib/src/core/models/message.g.dart @@ -54,6 +54,7 @@ Message _$MessageFromJson(Map json) => Message( deletedAt: json['deleted_at'] == null ? null : DateTime.parse(json['deleted_at'] as String), + deletedForMe: json['deleted_for_me'] as bool?, messageTextUpdatedAt: json['message_text_updated_at'] == null ? null : DateTime.parse(json['message_text_updated_at'] as String), diff --git a/packages/stream_chat/lib/src/core/models/message_delete_scope.dart b/packages/stream_chat/lib/src/core/models/message_delete_scope.dart new file mode 100644 index 0000000000..c08f1ea01c --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/message_delete_scope.dart @@ -0,0 +1,61 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'message_delete_scope.freezed.dart'; +part 'message_delete_scope.g.dart'; + +/// Represents the scope of deletion for a message. +/// +/// - [deleteForMe]: The message is deleted only for the current user. +/// - [deleteForAll]: The message is deleted for all users. The [hard] +/// parameter indicates whether the deletion is permanent (hard) or soft. +@freezed +sealed class MessageDeleteScope with _$MessageDeleteScope { + /// The message is deleted only for the current user. + /// + /// Note: This does not permanently delete the message, it will remain + /// visible to other channel members. + const factory MessageDeleteScope.deleteForMe() = DeleteForMe; + + /// The message is deleted for all users. + /// + /// If [hard] is true, the message is permanently deleted and cannot be + /// recovered. If false, the message is soft deleted and may be recoverable + /// by channel members with the appropriate permissions. + /// + /// Defaults to soft deletion (hard = false). + const factory MessageDeleteScope.deleteForAll({ + @Default(false) bool hard, + }) = DeleteForAll; + + /// Creates a instance of [MessageDeleteScope] from a JSON map. + factory MessageDeleteScope.fromJson(Map json) => + _$MessageDeleteScopeFromJson(json); + + // region Predefined Scopes + + /// The message is soft deleted for all users. + /// + /// This is equivalent to `MessageDeleteScope.deleteForAll(hard: false)`. + static const softDeleteForAll = MessageDeleteScope.deleteForAll(); + + /// The message is permanently (hard) deleted for all users. + /// + /// This is equivalent to `MessageDeleteScope.deleteForAll(hard: true)`. + static const hardDeleteForAll = MessageDeleteScope.deleteForAll(hard: true); + + // endregion +} + +/// Extension methods for [MessageDeleteScope] to provide additional +/// functionality. +extension MessageDeleteScopeX on MessageDeleteScope { + /// Indicates whether the deletion is permanent (hard) or soft. + /// + /// For [DeleteForMe], this is always false. + bool get hard { + return switch (this) { + DeleteForMe() => false, + DeleteForAll(hard: final hard) => hard, + }; + } +} diff --git a/packages/stream_chat/lib/src/core/models/message_delete_scope.freezed.dart b/packages/stream_chat/lib/src/core/models/message_delete_scope.freezed.dart new file mode 100644 index 0000000000..84da7fb10b --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/message_delete_scope.freezed.dart @@ -0,0 +1,166 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'message_delete_scope.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +MessageDeleteScope _$MessageDeleteScopeFromJson(Map json) { + switch (json['runtimeType']) { + case 'deleteForMe': + return DeleteForMe.fromJson(json); + case 'deleteForAll': + return DeleteForAll.fromJson(json); + + default: + throw CheckedFromJsonException(json, 'runtimeType', 'MessageDeleteScope', + 'Invalid union type "${json['runtimeType']}"!'); + } +} + +/// @nodoc +mixin _$MessageDeleteScope { + /// Serializes this MessageDeleteScope to a JSON map. + Map toJson(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is MessageDeleteScope); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() { + return 'MessageDeleteScope()'; + } +} + +/// @nodoc +class $MessageDeleteScopeCopyWith<$Res> { + $MessageDeleteScopeCopyWith( + MessageDeleteScope _, $Res Function(MessageDeleteScope) __); +} + +/// @nodoc +@JsonSerializable() +class DeleteForMe implements MessageDeleteScope { + const DeleteForMe({final String? $type}) : $type = $type ?? 'deleteForMe'; + factory DeleteForMe.fromJson(Map json) => + _$DeleteForMeFromJson(json); + + @JsonKey(name: 'runtimeType') + final String $type; + + @override + Map toJson() { + return _$DeleteForMeToJson( + this, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is DeleteForMe); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() { + return 'MessageDeleteScope.deleteForMe()'; + } +} + +/// @nodoc +@JsonSerializable() +class DeleteForAll implements MessageDeleteScope { + const DeleteForAll({this.hard = false, final String? $type}) + : $type = $type ?? 'deleteForAll'; + factory DeleteForAll.fromJson(Map json) => + _$DeleteForAllFromJson(json); + + @JsonKey() + final bool hard; + + @JsonKey(name: 'runtimeType') + final String $type; + + /// Create a copy of MessageDeleteScope + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $DeleteForAllCopyWith get copyWith => + _$DeleteForAllCopyWithImpl(this, _$identity); + + @override + Map toJson() { + return _$DeleteForAllToJson( + this, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is DeleteForAll && + (identical(other.hard, hard) || other.hard == hard)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, hard); + + @override + String toString() { + return 'MessageDeleteScope.deleteForAll(hard: $hard)'; + } +} + +/// @nodoc +abstract mixin class $DeleteForAllCopyWith<$Res> + implements $MessageDeleteScopeCopyWith<$Res> { + factory $DeleteForAllCopyWith( + DeleteForAll value, $Res Function(DeleteForAll) _then) = + _$DeleteForAllCopyWithImpl; + @useResult + $Res call({bool hard}); +} + +/// @nodoc +class _$DeleteForAllCopyWithImpl<$Res> implements $DeleteForAllCopyWith<$Res> { + _$DeleteForAllCopyWithImpl(this._self, this._then); + + final DeleteForAll _self; + final $Res Function(DeleteForAll) _then; + + /// Create a copy of MessageDeleteScope + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + $Res call({ + Object? hard = null, + }) { + return _then(DeleteForAll( + hard: null == hard + ? _self.hard + : hard // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +// dart format on diff --git a/packages/stream_chat/lib/src/core/models/message_delete_scope.g.dart b/packages/stream_chat/lib/src/core/models/message_delete_scope.g.dart new file mode 100644 index 0000000000..0998631d2a --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/message_delete_scope.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'message_delete_scope.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +DeleteForMe _$DeleteForMeFromJson(Map json) => DeleteForMe( + $type: json['runtimeType'] as String?, + ); + +Map _$DeleteForMeToJson(DeleteForMe instance) => + { + 'runtimeType': instance.$type, + }; + +DeleteForAll _$DeleteForAllFromJson(Map json) => DeleteForAll( + hard: json['hard'] as bool? ?? false, + $type: json['runtimeType'] as String?, + ); + +Map _$DeleteForAllToJson(DeleteForAll instance) => + { + 'hard': instance.hard, + 'runtimeType': instance.$type, + }; diff --git a/packages/stream_chat/lib/src/core/models/message_state.dart b/packages/stream_chat/lib/src/core/models/message_state.dart index cf98096843..141bdfd1d8 100644 --- a/packages/stream_chat/lib/src/core/models/message_state.dart +++ b/packages/stream_chat/lib/src/core/models/message_state.dart @@ -1,9 +1,9 @@ // ignore_for_file: avoid_positional_boolean_parameters import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:stream_chat/src/core/models/message_delete_scope.dart'; part 'message_state.freezed.dart'; - part 'message_state.g.dart'; /// Helper extension for [MessageState]. @@ -33,7 +33,7 @@ extension MessageStateX on MessageState { } /// Returns true if the message is in outgoing deleting state. - bool get isDeleting => isSoftDeleting || isHardDeleting; + bool get isDeleting => isSoftDeleting || isHardDeleting || isDeletingForMe; /// Returns true if the message is in outgoing soft deleting state. bool get isSoftDeleting { @@ -43,7 +43,10 @@ extension MessageStateX on MessageState { final outgoingState = messageState.state; if (outgoingState is! Deleting) return false; - return !outgoingState.hard; + final deletingScope = outgoingState.scope; + if (deletingScope is! DeleteForAll) return false; + + return !deletingScope.hard; } /// Returns true if the message is in outgoing hard deleting state. @@ -54,7 +57,22 @@ extension MessageStateX on MessageState { final outgoingState = messageState.state; if (outgoingState is! Deleting) return false; - return outgoingState.hard; + final deletingScope = outgoingState.scope; + if (deletingScope is! DeleteForAll) return false; + + return deletingScope.hard; + } + + /// Returns true if the message is in outgoing deleting for me state. + bool get isDeletingForMe { + final messageState = this; + if (messageState is! MessageOutgoing) return false; + + final outgoingState = messageState.state; + if (outgoingState is! Deleting) return false; + + final deletingScope = outgoingState.scope; + return deletingScope is DeleteForMe; } /// Returns true if the message is in completed sent state. @@ -70,7 +88,7 @@ extension MessageStateX on MessageState { } /// Returns true if the message is in completed deleted state. - bool get isDeleted => isSoftDeleted || isHardDeleted; + bool get isDeleted => isSoftDeleted || isHardDeleted || isDeletedForMe; /// Returns true if the message is in completed soft deleted state. bool get isSoftDeleted { @@ -80,7 +98,10 @@ extension MessageStateX on MessageState { final completedState = messageState.state; if (completedState is! Deleted) return false; - return !completedState.hard; + final deletingScope = completedState.scope; + if (deletingScope is! DeleteForAll) return false; + + return !deletingScope.hard; } /// Returns true if the message is in completed hard deleted state. @@ -91,7 +112,22 @@ extension MessageStateX on MessageState { final completedState = messageState.state; if (completedState is! Deleted) return false; - return completedState.hard; + final deletingScope = completedState.scope; + if (deletingScope is! DeleteForAll) return false; + + return deletingScope.hard; + } + + /// Returns true if the message is in completed deleted for me state. + bool get isDeletedForMe { + final messageState = this; + if (messageState is! MessageCompleted) return false; + + final completedState = messageState.state; + if (completedState is! Deleted) return false; + + final deletingScope = completedState.scope; + return deletingScope is DeleteForMe; } /// Returns true if the message is in failed sending state. @@ -111,7 +147,8 @@ extension MessageStateX on MessageState { } /// Returns true if the message is in failed deleting state. - bool get isDeletingFailed => isSoftDeletingFailed || isHardDeletingFailed; + bool get isDeletingFailed => + isSoftDeletingFailed || isHardDeletingFailed || isDeletingForMeFailed; /// Returns true if the message is in failed soft deleting state. bool get isSoftDeletingFailed { @@ -121,7 +158,10 @@ extension MessageStateX on MessageState { final failedState = messageState.state; if (failedState is! DeletingFailed) return false; - return !failedState.hard; + final deletingScope = failedState.scope; + if (deletingScope is! DeleteForAll) return false; + + return !deletingScope.hard; } /// Returns true if the message is in failed hard deleting state. @@ -132,7 +172,22 @@ extension MessageStateX on MessageState { final failedState = messageState.state; if (failedState is! DeletingFailed) return false; - return failedState.hard; + final deletingScope = failedState.scope; + if (deletingScope is! DeleteForAll) return false; + + return deletingScope.hard; + } + + /// Returns true if the message is in failed deleting for me state. + bool get isDeletingForMeFailed { + final messageState = this; + if (messageState is! MessageFailed) return false; + + final failedState = messageState.state; + if (failedState is! DeletingFailed) return false; + + final deletingScope = failedState.scope; + return deletingScope is DeleteForMe; } } @@ -163,24 +218,32 @@ sealed class MessageState with _$MessageState { factory MessageState.fromJson(Map json) => _$MessageStateFromJson(json); + // region Factory Constructors for Common States + /// Deleting state when the message is being deleted. - factory MessageState.deleting({required bool hard}) { + factory MessageState.deleting({ + required MessageDeleteScope scope, + }) { return MessageState.outgoing( - state: OutgoingState.deleting(hard: hard), + state: OutgoingState.deleting(scope: scope), ); } /// Deleting state when the message has been successfully deleted. - factory MessageState.deleted({required bool hard}) { + factory MessageState.deleted({ + required MessageDeleteScope scope, + }) { return MessageState.completed( - state: CompletedState.deleted(hard: hard), + state: CompletedState.deleted(scope: scope), ); } /// Deleting failed state when the message fails to be deleted. - factory MessageState.deletingFailed({required bool hard}) { + factory MessageState.deletingFailed({ + required MessageDeleteScope scope, + }) { return MessageState.failed( - state: FailedState.deletingFailed(hard: hard), + state: FailedState.deletingFailed(scope: scope), ); } @@ -224,6 +287,10 @@ sealed class MessageState with _$MessageState { ); } + // endregion + + // region Common Static Instances + /// Sending state when the message is being sent. static const sending = MessageState.outgoing( state: OutgoingState.sending(), @@ -241,7 +308,16 @@ sealed class MessageState with _$MessageState { /// Hard deleting state when the message is being hard deleted. static const hardDeleting = MessageState.outgoing( - state: OutgoingState.deleting(hard: true), + state: OutgoingState.deleting( + scope: MessageDeleteScope.hardDeleteForAll, + ), + ); + + /// Deleting for me state when the message is being deleted only for me. + static const deletingForMe = MessageState.outgoing( + state: OutgoingState.deleting( + scope: MessageDeleteScope.deleteForMe(), + ), ); /// Sent state when the message has been successfully sent. @@ -261,7 +337,17 @@ sealed class MessageState with _$MessageState { /// Hard deleted state when the message has been successfully hard deleted. static const hardDeleted = MessageState.completed( - state: CompletedState.deleted(hard: true), + state: CompletedState.deleted( + scope: MessageDeleteScope.hardDeleteForAll, + ), + ); + + /// Deleted for me state when the message has been successfully deleted only + /// for me. + static const deletedForMe = MessageState.completed( + state: CompletedState.deleted( + scope: MessageDeleteScope.deleteForMe(), + ), ); /// Deleting failed state when the message fails to be soft deleted. @@ -271,8 +357,19 @@ sealed class MessageState with _$MessageState { /// Hard deleting failed state when the message fails to be hard deleted. static const hardDeletingFailed = MessageState.failed( - state: FailedState.deletingFailed(hard: true), + state: FailedState.deletingFailed( + scope: MessageDeleteScope.hardDeleteForAll, + ), ); + + /// Deleting for me failed state when the message fails to be deleted only + static const deletingForMeFailed = MessageState.failed( + state: FailedState.deletingFailed( + scope: MessageDeleteScope.deleteForMe(), + ), + ); + + // endregion } /// Represents the state of an outgoing message. @@ -286,7 +383,7 @@ sealed class OutgoingState with _$OutgoingState { /// Deleting state when the message is being deleted. const factory OutgoingState.deleting({ - @Default(false) bool hard, + @Default(MessageDeleteScope.softDeleteForAll) MessageDeleteScope scope, }) = Deleting; /// Creates a new instance from a json @@ -305,7 +402,7 @@ sealed class CompletedState with _$CompletedState { /// Deleted state when the message has been successfully deleted. const factory CompletedState.deleted({ - @Default(false) bool hard, + @Default(MessageDeleteScope.softDeleteForAll) MessageDeleteScope scope, }) = Deleted; /// Creates a new instance from a json @@ -336,7 +433,7 @@ sealed class FailedState with _$FailedState { /// Deleting failed state when the message fails to be deleted. const factory FailedState.deletingFailed({ - @Default(false) bool hard, + @Default(MessageDeleteScope.softDeleteForAll) MessageDeleteScope scope, }) = DeletingFailed; /// Creates a new instance from a json @@ -464,13 +561,13 @@ extension OutgoingStatePatternMatching on OutgoingState { TResult when({ required TResult Function() sending, required TResult Function() updating, - required TResult Function(bool hard) deleting, + required TResult Function(MessageDeleteScope scope) deleting, }) { final outgoingState = this; return switch (outgoingState) { Sending() => sending(), Updating() => updating(), - Deleting() => deleting(outgoingState.hard), + Deleting() => deleting(outgoingState.scope), }; } @@ -479,13 +576,13 @@ extension OutgoingStatePatternMatching on OutgoingState { TResult? whenOrNull({ TResult? Function()? sending, TResult? Function()? updating, - TResult? Function(bool hard)? deleting, + TResult? Function(MessageDeleteScope scope)? deleting, }) { final outgoingState = this; return switch (outgoingState) { Sending() => sending?.call(), Updating() => updating?.call(), - Deleting() => deleting?.call(outgoingState.hard), + Deleting() => deleting?.call(outgoingState.scope), }; } @@ -494,14 +591,14 @@ extension OutgoingStatePatternMatching on OutgoingState { TResult maybeWhen({ TResult Function()? sending, TResult Function()? updating, - TResult Function(bool hard)? deleting, + TResult Function(MessageDeleteScope scope)? deleting, required TResult orElse(), }) { final outgoingState = this; final result = switch (outgoingState) { Sending() => sending?.call(), Updating() => updating?.call(), - Deleting() => deleting?.call(outgoingState.hard), + Deleting() => deleting?.call(outgoingState.scope), }; return result ?? orElse(); @@ -563,13 +660,13 @@ extension CompletedStatePatternMatching on CompletedState { TResult when({ required TResult Function() sent, required TResult Function() updated, - required TResult Function(bool hard) deleted, + required TResult Function(MessageDeleteScope scope) deleted, }) { final completedState = this; return switch (completedState) { Sent() => sent(), Updated() => updated(), - Deleted() => deleted(completedState.hard), + Deleted() => deleted(completedState.scope), }; } @@ -578,13 +675,13 @@ extension CompletedStatePatternMatching on CompletedState { TResult? whenOrNull({ TResult? Function()? sent, TResult? Function()? updated, - TResult? Function(bool hard)? deleted, + TResult? Function(MessageDeleteScope scope)? deleted, }) { final completedState = this; return switch (completedState) { Sent() => sent?.call(), Updated() => updated?.call(), - Deleted() => deleted?.call(completedState.hard), + Deleted() => deleted?.call(completedState.scope), }; } @@ -593,14 +690,14 @@ extension CompletedStatePatternMatching on CompletedState { TResult maybeWhen({ TResult Function()? sent, TResult Function()? updated, - TResult Function(bool hard)? deleted, + TResult Function(MessageDeleteScope scope)? deleted, required TResult orElse(), }) { final completedState = this; final result = switch (completedState) { Sent() => sent?.call(), Updated() => updated?.call(), - Deleted() => deleted?.call(completedState.hard), + Deleted() => deleted?.call(completedState.scope), }; return result ?? orElse(); @@ -665,7 +762,7 @@ extension FailedStatePatternMatching on FailedState { required TResult Function( Map? set, List? unset, bool skipEnrichUrl) partialUpdatingFailed, - required TResult Function(bool hard) deletingFailed, + required TResult Function(MessageDeleteScope scope) deletingFailed, }) { final failedState = this; return switch (failedState) { @@ -675,7 +772,7 @@ extension FailedStatePatternMatching on FailedState { updatingFailed(failedState.skipPush, failedState.skipEnrichUrl), PartialUpdatingFailed() => partialUpdatingFailed( failedState.set, failedState.unset, failedState.skipEnrichUrl), - DeletingFailed() => deletingFailed(failedState.hard), + DeletingFailed() => deletingFailed(failedState.scope), }; } @@ -687,7 +784,7 @@ extension FailedStatePatternMatching on FailedState { required TResult Function( Map? set, List? unset, bool skipEnrichUrl) partialUpdatingFailed, - TResult? Function(bool hard)? deletingFailed, + TResult? Function(MessageDeleteScope scope)? deletingFailed, }) { final failedState = this; return switch (failedState) { @@ -697,7 +794,7 @@ extension FailedStatePatternMatching on FailedState { updatingFailed?.call(failedState.skipPush, failedState.skipEnrichUrl), PartialUpdatingFailed() => partialUpdatingFailed( failedState.set, failedState.unset, failedState.skipEnrichUrl), - DeletingFailed() => deletingFailed?.call(failedState.hard), + DeletingFailed() => deletingFailed?.call(failedState.scope), }; } @@ -709,7 +806,7 @@ extension FailedStatePatternMatching on FailedState { required TResult Function( Map? set, List? unset, bool skipEnrichUrl) partialUpdatingFailed, - TResult Function(bool hard)? deletingFailed, + TResult Function(MessageDeleteScope scope)? deletingFailed, required TResult orElse(), }) { final failedState = this; @@ -720,7 +817,7 @@ extension FailedStatePatternMatching on FailedState { updatingFailed?.call(failedState.skipPush, failedState.skipEnrichUrl), PartialUpdatingFailed() => partialUpdatingFailed( failedState.set, failedState.unset, failedState.skipEnrichUrl), - DeletingFailed() => deletingFailed?.call(failedState.hard), + DeletingFailed() => deletingFailed?.call(failedState.scope), }; return result ?? orElse(); diff --git a/packages/stream_chat/lib/src/core/models/message_state.freezed.dart b/packages/stream_chat/lib/src/core/models/message_state.freezed.dart index 06cb77449e..f896483f61 100644 --- a/packages/stream_chat/lib/src/core/models/message_state.freezed.dart +++ b/packages/stream_chat/lib/src/core/models/message_state.freezed.dart @@ -473,13 +473,14 @@ class Updating implements OutgoingState { /// @nodoc @JsonSerializable() class Deleting implements OutgoingState { - const Deleting({this.hard = false, final String? $type}) + const Deleting( + {this.scope = MessageDeleteScope.softDeleteForAll, final String? $type}) : $type = $type ?? 'deleting'; factory Deleting.fromJson(Map json) => _$DeletingFromJson(json); @JsonKey() - final bool hard; + final MessageDeleteScope scope; @JsonKey(name: 'runtimeType') final String $type; @@ -503,16 +504,16 @@ class Deleting implements OutgoingState { return identical(this, other) || (other.runtimeType == runtimeType && other is Deleting && - (identical(other.hard, hard) || other.hard == hard)); + (identical(other.scope, scope) || other.scope == scope)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => Object.hash(runtimeType, hard); + int get hashCode => Object.hash(runtimeType, scope); @override String toString() { - return 'OutgoingState.deleting(hard: $hard)'; + return 'OutgoingState.deleting(scope: $scope)'; } } @@ -522,7 +523,9 @@ abstract mixin class $DeletingCopyWith<$Res> factory $DeletingCopyWith(Deleting value, $Res Function(Deleting) _then) = _$DeletingCopyWithImpl; @useResult - $Res call({bool hard}); + $Res call({MessageDeleteScope scope}); + + $MessageDeleteScopeCopyWith<$Res> get scope; } /// @nodoc @@ -536,15 +539,25 @@ class _$DeletingCopyWithImpl<$Res> implements $DeletingCopyWith<$Res> { /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') $Res call({ - Object? hard = null, + Object? scope = null, }) { return _then(Deleting( - hard: null == hard - ? _self.hard - : hard // ignore: cast_nullable_to_non_nullable - as bool, + scope: null == scope + ? _self.scope + : scope // ignore: cast_nullable_to_non_nullable + as MessageDeleteScope, )); } + + /// Create a copy of OutgoingState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $MessageDeleteScopeCopyWith<$Res> get scope { + return $MessageDeleteScopeCopyWith<$Res>(_self.scope, (value) { + return _then(_self.copyWith(scope: value)); + }); + } } CompletedState _$CompletedStateFromJson(Map json) { @@ -656,13 +669,14 @@ class Updated implements CompletedState { /// @nodoc @JsonSerializable() class Deleted implements CompletedState { - const Deleted({this.hard = false, final String? $type}) + const Deleted( + {this.scope = MessageDeleteScope.softDeleteForAll, final String? $type}) : $type = $type ?? 'deleted'; factory Deleted.fromJson(Map json) => _$DeletedFromJson(json); @JsonKey() - final bool hard; + final MessageDeleteScope scope; @JsonKey(name: 'runtimeType') final String $type; @@ -686,16 +700,16 @@ class Deleted implements CompletedState { return identical(this, other) || (other.runtimeType == runtimeType && other is Deleted && - (identical(other.hard, hard) || other.hard == hard)); + (identical(other.scope, scope) || other.scope == scope)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => Object.hash(runtimeType, hard); + int get hashCode => Object.hash(runtimeType, scope); @override String toString() { - return 'CompletedState.deleted(hard: $hard)'; + return 'CompletedState.deleted(scope: $scope)'; } } @@ -705,7 +719,9 @@ abstract mixin class $DeletedCopyWith<$Res> factory $DeletedCopyWith(Deleted value, $Res Function(Deleted) _then) = _$DeletedCopyWithImpl; @useResult - $Res call({bool hard}); + $Res call({MessageDeleteScope scope}); + + $MessageDeleteScopeCopyWith<$Res> get scope; } /// @nodoc @@ -719,15 +735,25 @@ class _$DeletedCopyWithImpl<$Res> implements $DeletedCopyWith<$Res> { /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') $Res call({ - Object? hard = null, + Object? scope = null, }) { return _then(Deleted( - hard: null == hard - ? _self.hard - : hard // ignore: cast_nullable_to_non_nullable - as bool, + scope: null == scope + ? _self.scope + : scope // ignore: cast_nullable_to_non_nullable + as MessageDeleteScope, )); } + + /// Create a copy of CompletedState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $MessageDeleteScopeCopyWith<$Res> get scope { + return $MessageDeleteScopeCopyWith<$Res>(_self.scope, (value) { + return _then(_self.copyWith(scope: value)); + }); + } } FailedState _$FailedStateFromJson(Map json) { @@ -1078,13 +1104,14 @@ class _$PartialUpdatingFailedCopyWithImpl<$Res> /// @nodoc @JsonSerializable() class DeletingFailed implements FailedState { - const DeletingFailed({this.hard = false, final String? $type}) + const DeletingFailed( + {this.scope = MessageDeleteScope.softDeleteForAll, final String? $type}) : $type = $type ?? 'deletingFailed'; factory DeletingFailed.fromJson(Map json) => _$DeletingFailedFromJson(json); @JsonKey() - final bool hard; + final MessageDeleteScope scope; @JsonKey(name: 'runtimeType') final String $type; @@ -1108,16 +1135,16 @@ class DeletingFailed implements FailedState { return identical(this, other) || (other.runtimeType == runtimeType && other is DeletingFailed && - (identical(other.hard, hard) || other.hard == hard)); + (identical(other.scope, scope) || other.scope == scope)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => Object.hash(runtimeType, hard); + int get hashCode => Object.hash(runtimeType, scope); @override String toString() { - return 'FailedState.deletingFailed(hard: $hard)'; + return 'FailedState.deletingFailed(scope: $scope)'; } } @@ -1128,7 +1155,9 @@ abstract mixin class $DeletingFailedCopyWith<$Res> DeletingFailed value, $Res Function(DeletingFailed) _then) = _$DeletingFailedCopyWithImpl; @useResult - $Res call({bool hard}); + $Res call({MessageDeleteScope scope}); + + $MessageDeleteScopeCopyWith<$Res> get scope; } /// @nodoc @@ -1143,15 +1172,25 @@ class _$DeletingFailedCopyWithImpl<$Res> /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') $Res call({ - Object? hard = null, + Object? scope = null, }) { return _then(DeletingFailed( - hard: null == hard - ? _self.hard - : hard // ignore: cast_nullable_to_non_nullable - as bool, + scope: null == scope + ? _self.scope + : scope // ignore: cast_nullable_to_non_nullable + as MessageDeleteScope, )); } + + /// Create a copy of FailedState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $MessageDeleteScopeCopyWith<$Res> get scope { + return $MessageDeleteScopeCopyWith<$Res>(_self.scope, (value) { + return _then(_self.copyWith(scope: value)); + }); + } } // dart format on diff --git a/packages/stream_chat/lib/src/core/models/message_state.g.dart b/packages/stream_chat/lib/src/core/models/message_state.g.dart index 3161a4f8c2..693a642e17 100644 --- a/packages/stream_chat/lib/src/core/models/message_state.g.dart +++ b/packages/stream_chat/lib/src/core/models/message_state.g.dart @@ -71,12 +71,14 @@ Map _$UpdatingToJson(Updating instance) => { }; Deleting _$DeletingFromJson(Map json) => Deleting( - hard: json['hard'] as bool? ?? false, + scope: json['scope'] == null + ? MessageDeleteScope.softDeleteForAll + : MessageDeleteScope.fromJson(json['scope'] as Map), $type: json['runtimeType'] as String?, ); Map _$DeletingToJson(Deleting instance) => { - 'hard': instance.hard, + 'scope': instance.scope.toJson(), 'runtimeType': instance.$type, }; @@ -97,12 +99,14 @@ Map _$UpdatedToJson(Updated instance) => { }; Deleted _$DeletedFromJson(Map json) => Deleted( - hard: json['hard'] as bool? ?? false, + scope: json['scope'] == null + ? MessageDeleteScope.softDeleteForAll + : MessageDeleteScope.fromJson(json['scope'] as Map), $type: json['runtimeType'] as String?, ); Map _$DeletedToJson(Deleted instance) => { - 'hard': instance.hard, + 'scope': instance.scope.toJson(), 'runtimeType': instance.$type, }; @@ -155,12 +159,14 @@ Map _$PartialUpdatingFailedToJson( DeletingFailed _$DeletingFailedFromJson(Map json) => DeletingFailed( - hard: json['hard'] as bool? ?? false, + scope: json['scope'] == null + ? MessageDeleteScope.softDeleteForAll + : MessageDeleteScope.fromJson(json['scope'] as Map), $type: json['runtimeType'] as String?, ); Map _$DeletingFailedToJson(DeletingFailed instance) => { - 'hard': instance.hard, + 'scope': instance.scope.toJson(), 'runtimeType': instance.$type, }; diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index 01b114e5e0..5070a56d9b 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -47,6 +47,7 @@ export 'src/core/models/location.dart'; export 'src/core/models/location_coordinates.dart'; export 'src/core/models/member.dart'; export 'src/core/models/message.dart'; +export 'src/core/models/message_delete_scope.dart'; export 'src/core/models/message_reminder.dart'; export 'src/core/models/message_state.dart'; export 'src/core/models/moderation.dart'; diff --git a/packages/stream_chat/test/fixtures/member.json b/packages/stream_chat/test/fixtures/member.json index 6d95d7ad02..63b7ddd175 100644 --- a/packages/stream_chat/test/fixtures/member.json +++ b/packages/stream_chat/test/fixtures/member.json @@ -12,5 +12,10 @@ "channel_role": "channel_member", "created_at": "2020-01-28T22:17:30.95443Z", "updated_at": "2020-01-28T22:17:30.95443Z", + "deleted_messages": [ + "msg-1", + "msg-2", + "msg-3" + ], "some_custom_field": "with_custom_data" } \ No newline at end of file diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index 9c69178ac8..80430c1642 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -2239,41 +2239,47 @@ void main() { uploadState: const UploadState.success(), ), ); + const messageId = 'test-message-id'; final message = Message( - attachments: attachments, id: messageId, + attachments: attachments, createdAt: DateTime.now(), state: MessageState.sent, ); - when(() => client.deleteMessage(messageId, hard: true)) - .thenAnswer((_) async => EmptyResponse()); + when( + () => client.deleteMessage(messageId, hard: true), + ).thenAnswer((_) async => EmptyResponse()); - when(() => client.deleteImage(any(), channelId, channelType)) - .thenAnswer((_) async => EmptyResponse()); + when( + () => client.deleteImage(any(), channelId, channelType), + ).thenAnswer((_) async => EmptyResponse()); - when(() => client.deleteFile(any(), channelId, channelType)) - .thenAnswer((_) async => EmptyResponse()); + when( + () => client.deleteFile(any(), channelId, channelType), + ).thenAnswer((_) async => EmptyResponse()); final res = await channel.deleteMessage(message, hard: true); - expect(res, isNotNull); verify(() => client.deleteMessage(messageId, hard: true)).called(1); - verify(() => client.deleteImage( - any(), - channelId, - channelType, - )).called(2); + + verify(() => client.deleteImage(any(), channelId, channelType)) + .called(2); + + verify(() => client.deleteFile(any(), channelId, channelType)) + .called(1); }); test( - '''should directly update the state with message as deleted if the state is sending or failed''', + 'should hard delete the message if the state is sending or failed', () async { const messageId = 'test-message-id'; final message = Message( id: messageId, + text: 'Hello World!', + state: MessageState.sending, ); expectLater( @@ -2282,13 +2288,17 @@ void main() { emitsInOrder([ [ isSameMessageAs( - message.copyWith(state: MessageState.softDeleted), + message.copyWith(state: MessageState.sending), matchMessageState: true, ), ], + const [], // message is hard deleted from state ]), ); + // Add message to channel state first + channel.state?.addNewMessage(message); + final res = await channel.deleteMessage(message); expect(res, isNotNull); @@ -2297,6 +2307,79 @@ void main() { ); }); + group('`.deleteMessageForMe`', () { + test('should work fine', () async { + const messageId = 'test-message-id'; + final message = Message( + id: messageId, + createdAt: DateTime.now(), + state: MessageState.sent, + ); + + when(() => client.deleteMessageForMe(messageId)) + .thenAnswer((_) async => EmptyResponse()); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(state: MessageState.deletingForMe), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(state: MessageState.deletedForMe), + matchMessageState: true, + ), + ], + ]), + ); + + final res = await channel.deleteMessageForMe(message); + + expect(res, isNotNull); + + verify(() => client.deleteMessageForMe(messageId)).called(1); + }); + + test( + 'should hard delete the message if the state is sending or failed', + () async { + const messageId = 'test-message-id'; + final message = Message( + id: messageId, + text: 'Hello World!', + state: MessageState.sending, + ); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(state: MessageState.sending), + matchMessageState: true, + ), + ], + const [], // message is hard deleted from state + ]), + ); + + // Add message to channel state first + channel.state?.addNewMessage(message); + + final res = await channel.deleteMessageForMe(message); + + expect(res, isNotNull); + verifyNever(() => client.deleteMessageForMe(messageId)); + }, + ); + }); + group('`.pinMessage`', () { test('should work fine without passing timeoutOrExpirationDate', () async { @@ -7199,7 +7282,7 @@ void main() { final message = Message( id: 'test-message-id', createdAt: DateTime.now(), - state: MessageState.deletingFailed(hard: true), + state: MessageState.hardDeletingFailed, ); when(() => client.deleteMessage( @@ -7223,7 +7306,7 @@ void main() { final message = Message( id: 'test-message-id', createdAt: DateTime.now(), - state: MessageState.deletingFailed(hard: false), + state: MessageState.softDeletingFailed, ); when(() => client.deleteMessage( @@ -7240,6 +7323,25 @@ void main() { )).called(1); }); + test('should call deleteMessageForMe for deletingForMeFailed state', + () async { + final message = Message( + id: 'test-message-id', + createdAt: DateTime.now(), + state: MessageState.deletingForMeFailed, + ); + + when(() => client.deleteMessageForMe(message.id)) + .thenAnswer((_) async => EmptyResponse()); + + final result = await channel.retryMessage(message); + + expect(result, isNotNull); + expect(result, isA()); + + verify(() => client.deleteMessageForMe(message.id)).called(1); + }); + test('should throw AssertionError when message state is not failed', () async { final message = Message( diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 3a9f3bb66d..de03ea97f7 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -3536,6 +3536,20 @@ void main() { verifyNoMoreInteractions(api.message); }); + test('`.deleteMessageForMe`', () async { + const messageId = 'test-message-id'; + + when(() => api.message.deleteMessage(messageId, deleteForMe: true)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.deleteMessageForMe(messageId); + expect(res, isNotNull); + + verify(() => api.message.deleteMessage(messageId, deleteForMe: true)) + .called(1); + verifyNoMoreInteractions(api.message); + }); + test('`.getMessage`', () async { const messageId = 'test-message-id'; final message = Message(id: messageId); diff --git a/packages/stream_chat/test/src/core/api/message_api_test.dart b/packages/stream_chat/test/src/core/api/message_api_test.dart index e0572cb911..bfe9bd7815 100644 --- a/packages/stream_chat/test/src/core/api/message_api_test.dart +++ b/packages/stream_chat/test/src/core/api/message_api_test.dart @@ -205,16 +205,17 @@ void main() { const messageId = 'test-message-id'; const path = '/messages/$messageId'; + const params = {'delete_for_me': true}; - when(() => client.delete(path)).thenAnswer( + when(() => client.delete(path, queryParameters: params)).thenAnswer( (_) async => successResponse(path, data: {}), ); - final res = await messageApi.deleteMessage(messageId); + final res = await messageApi.deleteMessage(messageId, deleteForMe: true); expect(res, isNotNull); - verify(() => client.delete(path)).called(1); + verify(() => client.delete(path, queryParameters: params)).called(1); verifyNoMoreInteractions(client); }); diff --git a/packages/stream_chat/test/src/core/models/member_test.dart b/packages/stream_chat/test/src/core/models/member_test.dart index 752ff46b60..86693a6504 100644 --- a/packages/stream_chat/test/src/core/models/member_test.dart +++ b/packages/stream_chat/test/src/core/models/member_test.dart @@ -12,6 +12,7 @@ void main() { expect(member.channelRole, 'channel_member'); expect(member.createdAt, DateTime.parse('2020-01-28T22:17:30.95443Z')); expect(member.updatedAt, DateTime.parse('2020-01-28T22:17:30.95443Z')); + expect(member.deletedMessages, ['msg-1', 'msg-2', 'msg-3']); expect(member.extraData['some_custom_field'], 'with_custom_data'); }); diff --git a/packages/stream_chat/test/src/core/models/message_state_test.dart b/packages/stream_chat/test/src/core/models/message_state_test.dart index 4d5ac13a31..f97291f06a 100644 --- a/packages/stream_chat/test/src/core/models/message_state_test.dart +++ b/packages/stream_chat/test/src/core/models/message_state_test.dart @@ -1,5 +1,6 @@ -// ignore_for_file: use_named_constants, lines_longer_than_80_chars +// ignore_for_file: use_named_constants, lines_longer_than_80_chars, avoid_redundant_argument_values +import 'package:stream_chat/src/core/models/message_delete_scope.dart'; import 'package:stream_chat/src/core/models/message_state.dart'; import 'package:test/test.dart'; @@ -74,25 +75,41 @@ void main() { ); test( - 'isSoftDeleting should return true if the message state is MessageOutgoing with Deleting state and not hard deleting', + 'isSoftDeleting should return true if the message state is MessageOutgoing with Deleting state and scope is softDeleteForAll', () { const messageState = MessageState.outgoing( - state: OutgoingState.deleting(), + state: OutgoingState.deleting( + scope: MessageDeleteScope.softDeleteForAll, + ), ); expect(messageState.isSoftDeleting, true); }, ); test( - 'isHardDeleting should return true if the message state is MessageOutgoing with Deleting state and hard deleting', + 'isHardDeleting should return true if the message state is MessageOutgoing with Deleting state and scope is HardDeleteForAll', () { const messageState = MessageState.outgoing( - state: OutgoingState.deleting(hard: true), + state: OutgoingState.deleting( + scope: MessageDeleteScope.hardDeleteForAll, + ), ); expect(messageState.isHardDeleting, true); }, ); + test( + 'isDeletingForMe should return true if the message state is MessageOutgoing with Deleting state and scope is DeleteForMe', + () { + const messageState = MessageState.outgoing( + state: OutgoingState.deleting( + scope: MessageDeleteScope.deleteForMe(), + ), + ); + expect(messageState.isDeletingForMe, true); + }, + ); + test( 'isSent should return true if the message state is MessageCompleted with Sent state', () { @@ -121,25 +138,41 @@ void main() { ); test( - 'isSoftDeleted should return true if the message state is MessageCompleted with Deleted state and not hard deleting', + 'isSoftDeleted should return true if the message state is MessageCompleted with Deleted state and scope is softDeleteForAll', () { const messageState = MessageState.completed( - state: CompletedState.deleted(), + state: CompletedState.deleted( + scope: MessageDeleteScope.softDeleteForAll, + ), ); expect(messageState.isSoftDeleted, true); }, ); test( - 'isHardDeleted should return true if the message state is MessageCompleted with Deleted state and hard deleting', + 'isHardDeleted should return true if the message state is MessageCompleted with Deleted state and scope is hardDeleteForAll', () { const messageState = MessageState.completed( - state: CompletedState.deleted(hard: true), + state: CompletedState.deleted( + scope: MessageDeleteScope.hardDeleteForAll, + ), ); expect(messageState.isHardDeleted, true); }, ); + test( + 'isDeletedForMe should return true if the message state is MessageCompleted with Deleted state and scope is DeleteForMe', + () { + const messageState = MessageState.completed( + state: CompletedState.deleted( + scope: MessageDeleteScope.deleteForMe(), + ), + ); + expect(messageState.isDeletedForMe, true); + }, + ); + test( 'isSendingFailed should return true if the message state is MessageFailed with SendingFailed state', () { @@ -169,24 +202,40 @@ void main() { ); test( - 'isSoftDeletingFailed should return true if the message state is MessageFailed with DeletingFailed state and not hard deleting', + 'isSoftDeletingFailed should return true if the message state is MessageFailed with DeletingFailed state and scope is softDeleteForAll', () { const messageState = MessageState.failed( - state: FailedState.deletingFailed(), + state: FailedState.deletingFailed( + scope: MessageDeleteScope.softDeleteForAll, + ), ); expect(messageState.isSoftDeletingFailed, true); }, ); test( - 'isHardDeletingFailed should return true if the message state is MessageFailed with DeletingFailed state and hard deleting', + 'isHardDeletingFailed should return true if the message state is MessageFailed with DeletingFailed state and scope is hardDeleteForAll', () { const messageState = MessageState.failed( - state: FailedState.deletingFailed(hard: true), + state: FailedState.deletingFailed( + scope: MessageDeleteScope.hardDeleteForAll, + ), ); expect(messageState.isHardDeletingFailed, true); }, ); + + test( + 'isDeletingForMeFailed should return true if the message state is MessageFailed with DeletingFailed state and scope is DeleteForMe', + () { + const messageState = MessageState.failed( + state: FailedState.deletingFailed( + scope: MessageDeleteScope.deleteForMe(), + ), + ); + expect(messageState.isDeletingForMeFailed, true); + }, + ); }, ); @@ -210,22 +259,41 @@ void main() { ); test( - 'MessageState.softDeleting should create a MessageOutgoing instance with Deleting state and not hard deleting', + 'MessageState.softDeleting should create a MessageOutgoing instance with Deleting state and softDeleteForAll scope', () { const messageState = MessageState.softDeleting; expect(messageState, isA()); expect((messageState as MessageOutgoing).state, isA()); - expect((messageState.state as Deleting).hard, false); + + final deletingState = messageState.state as Deleting; + expect(deletingState.scope, isA()); + expect((deletingState.scope as DeleteForAll).hard, false); }, ); test( - 'MessageState.hardDeleting should create a MessageOutgoing instance with Deleting state and hard deleting', + 'MessageState.hardDeleting should create a MessageOutgoing instance with Deleting state and hardDeleteForAll scope', () { const messageState = MessageState.hardDeleting; expect(messageState, isA()); expect((messageState as MessageOutgoing).state, isA()); - expect((messageState.state as Deleting).hard, true); + + final deletingState = messageState.state as Deleting; + expect(deletingState.scope, isA()); + expect((deletingState.scope as DeleteForAll).hard, true); + }, + ); + + test( + 'MessageState.deletingForMe should create a MessageOutgoing instance with Deleting state and DeleteForMe scope', + () { + const messageState = MessageState.deletingForMe; + expect(messageState, isA()); + expect((messageState as MessageOutgoing).state, isA()); + + final deletingState = messageState.state as Deleting; + expect(deletingState.scope, isA()); + expect((deletingState.scope as DeleteForMe).hard, false); }, ); @@ -248,22 +316,41 @@ void main() { ); test( - 'MessageState.softDeleted should create a MessageCompleted instance with Deleted state and not hard deleting', + 'MessageState.softDeleted should create a MessageCompleted instance with Deleted state and softDeleteForAll scope', () { const messageState = MessageState.softDeleted; expect(messageState, isA()); expect((messageState as MessageCompleted).state, isA()); - expect((messageState.state as Deleted).hard, false); + + final deletedState = messageState.state as Deleted; + expect(deletedState.scope, isA()); + expect((deletedState.scope as DeleteForAll).hard, false); }, ); test( - 'MessageState.hardDeleted should create a MessageCompleted instance with Deleted state and hard deleting', + 'MessageState.hardDeleted should create a MessageCompleted instance with Deleted state and hardDeleteForAll scope', () { const messageState = MessageState.hardDeleted; expect(messageState, isA()); expect((messageState as MessageCompleted).state, isA()); - expect((messageState.state as Deleted).hard, true); + + final deletedState = messageState.state as Deleted; + expect(deletedState.scope, isA()); + expect((deletedState.scope as DeleteForAll).hard, true); + }, + ); + + test( + 'MessageState.deletedForMe should create a MessageCompleted instance with Deleted state and DeleteForMe scope', + () { + const messageState = MessageState.deletedForMe; + expect(messageState, isA()); + expect((messageState as MessageCompleted).state, isA()); + + final deletedState = messageState.state as Deleted; + expect(deletedState.scope, isA()); + expect((deletedState.scope as DeleteForMe).hard, false); }, ); @@ -306,22 +393,41 @@ void main() { ); test( - 'MessageState.softDeletingFailed should create a MessageFailed instance with DeletingFailed state and not hard deleting', + 'MessageState.softDeletingFailed should create a MessageFailed instance with DeletingFailed state and softDeleteForAll scope', () { const messageState = MessageState.softDeletingFailed; expect(messageState, isA()); expect((messageState as MessageFailed).state, isA()); - expect((messageState.state as DeletingFailed).hard, false); + + final failedState = messageState.state as DeletingFailed; + expect(failedState.scope, isA()); + expect((failedState.scope as DeleteForAll).hard, false); }, ); test( - 'MessageState.hardDeletingFailed should create a MessageFailed instance with DeletingFailed state and hard deleting', + 'MessageState.hardDeletingFailed should create a MessageFailed instance with DeletingFailed state and hardDeleteForAll scope', () { const messageState = MessageState.hardDeletingFailed; expect(messageState, isA()); expect((messageState as MessageFailed).state, isA()); - expect((messageState.state as DeletingFailed).hard, true); + + final failedState = messageState.state as DeletingFailed; + expect(failedState.scope, isA()); + expect((failedState.scope as DeleteForAll).hard, true); + }, + ); + + test( + 'MessageState.deletingForMeFailed should create a MessageFailed instance with DeletingFailed state and DeleteForMe scope', + () { + const messageState = MessageState.deletingForMeFailed; + expect(messageState, isA()); + expect((messageState as MessageFailed).state, isA()); + + final failedState = messageState.state as DeletingFailed; + expect(failedState.scope, isA()); + expect((failedState.scope as DeleteForMe).hard, false); }, ); }); diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 2f5c67ac9c..6e42b4caa6 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,3 +1,9 @@ +## Upcoming Beta + +โœ… Added + +- Added support for `StreamMessageWidget.deletedMessageBuilder` to customize the deleted message UI. + ## 10.0.0-beta.6 ๐Ÿž Fixed diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/url_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/url_attachment_builder.dart index ab464d9ed0..58c85fe15b 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/url_attachment_builder.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/url_attachment_builder.dart @@ -65,9 +65,9 @@ class UrlAttachmentBuilder extends StreamAttachmentWidgetBuilder { final host = Uri.parse(urlPreview.titleLink!).host; final splitList = host.split('.'); final hostName = splitList.length == 3 ? splitList[1] : splitList[0]; - final hostDisplayName = urlPreview.authorName?.capitalize() ?? + final hostDisplayName = urlPreview.authorName?.sentenceCase ?? getWebsiteName(hostName.toLowerCase()) ?? - hostName.capitalize(); + hostName.sentenceCase; return InkWell( onTap: onTap, diff --git a/packages/stream_chat_flutter/lib/src/attachment_actions_modal/attachment_actions_modal.dart b/packages/stream_chat_flutter/lib/src/attachment_actions_modal/attachment_actions_modal.dart index f11e4e127e..c36963a7aa 100644 --- a/packages/stream_chat_flutter/lib/src/attachment_actions_modal/attachment_actions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/attachment_actions_modal/attachment_actions_modal.dart @@ -195,7 +195,7 @@ class AttachmentActionsModal extends StatelessWidget { showDelete) _buildButton( context, - context.translations.deleteLabel.capitalize(), + context.translations.deleteLabel.sentenceCase, StreamSvgIcon( size: 24, icon: StreamSvgIcons.delete, diff --git a/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart b/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart index fddd4abc72..d2556eb55a 100644 --- a/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart +++ b/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart @@ -65,7 +65,7 @@ class StreamCommandAutocompleteOptions extends StatelessWidget { title: Row( children: [ Text( - command.name.capitalize(), + command.name.sentenceCase, style: textTheme.bodyBold.copyWith( color: colorTheme.textHighEmphasis, ), diff --git a/packages/stream_chat_flutter/lib/src/message_action/message_actions_builder.dart b/packages/stream_chat_flutter/lib/src/message_action/message_actions_builder.dart index f9dbae07b4..527df91b09 100644 --- a/packages/stream_chat_flutter/lib/src/message_action/message_actions_builder.dart +++ b/packages/stream_chat_flutter/lib/src/message_action/message_actions_builder.dart @@ -79,6 +79,17 @@ class StreamMessageActionsBuilder { ), ), ), + if (messageState.isSendingFailed) + StreamMessageAction( + isDestructive: true, + action: HardDeleteMessage(message: message), + leading: const StreamSvgIcon(icon: StreamSvgIcons.delete), + title: Text( + context.translations.toggleDeleteRetryDeleteMessageText( + isDeleteFailed: false, + ), + ), + ), ], if (message.state.isDeletingFailed) StreamMessageAction( diff --git a/packages/stream_chat_flutter/lib/src/message_widget/giphy_ephemeral_message.dart b/packages/stream_chat_flutter/lib/src/message_widget/giphy_ephemeral_message.dart index dea2cc8e2c..b47013df1b 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/giphy_ephemeral_message.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/giphy_ephemeral_message.dart @@ -159,7 +159,7 @@ class GiphyActions extends StatelessWidget { textStyle: textTheme.bodyBold, foregroundColor: colorTheme.textLowEmphasis, ), - child: Text(context.translations.cancelLabel.capitalize()), + child: Text(context.translations.cancelLabel.sentenceCase), ), ), VerticalDivider(thickness: 1, width: 4, color: colorTheme.borders), @@ -173,7 +173,7 @@ class GiphyActions extends StatelessWidget { textStyle: textTheme.bodyBold, foregroundColor: colorTheme.textLowEmphasis, ), - child: Text(context.translations.shuffleLabel.capitalize()), + child: Text(context.translations.shuffleLabel.sentenceCase), ), ), VerticalDivider(thickness: 1, width: 4, color: colorTheme.borders), @@ -187,7 +187,7 @@ class GiphyActions extends StatelessWidget { textStyle: textTheme.bodyBold, foregroundColor: colorTheme.accentPrimary, ), - child: Text(context.translations.sendLabel.capitalize()), + child: Text(context.translations.sendLabel.sentenceCase), ), ), ], diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart index ec54d488aa..7e6f3ae518 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart @@ -79,6 +79,7 @@ class StreamMessageWidget extends StatefulWidget { this.onShowMessage, this.userAvatarBuilder, this.quotedMessageBuilder, + this.deletedMessageBuilder, this.editMessageInputBuilder, this.textBuilder, this.bottomRowBuilderWithDefaultWidget, @@ -161,6 +162,11 @@ class StreamMessageWidget extends StatefulWidget { /// {@endtemplate} final Widget Function(BuildContext, Message)? quotedMessageBuilder; + /// {@template deletedMessageBuilder} + /// Widget builder for building deleted message + /// {@endtemplate} + final Widget Function(BuildContext, Message)? deletedMessageBuilder; + /// {@template message} /// The message to display. /// {@endtemplate} @@ -407,6 +413,7 @@ class StreamMessageWidget extends StatefulWidget { Widget Function(BuildContext, Message)? editMessageInputBuilder, Widget Function(BuildContext, Message)? textBuilder, Widget Function(BuildContext, Message)? quotedMessageBuilder, + Widget Function(BuildContext, Message)? deletedMessageBuilder, BottomRowBuilderWithDefaultWidget? bottomRowBuilderWithDefaultWidget, void Function(BuildContext, Message)? onMessageActions, void Function(BuildContext, Message)? onBouncedErrorMessageActions, @@ -473,6 +480,8 @@ class StreamMessageWidget extends StatefulWidget { editMessageInputBuilder ?? this.editMessageInputBuilder, textBuilder: textBuilder ?? this.textBuilder, quotedMessageBuilder: quotedMessageBuilder ?? this.quotedMessageBuilder, + deletedMessageBuilder: + deletedMessageBuilder ?? this.deletedMessageBuilder, bottomRowBuilderWithDefaultWidget: bottomRowBuilderWithDefaultWidget ?? this.bottomRowBuilderWithDefaultWidget, onMessageActions: onMessageActions ?? this.onMessageActions, @@ -753,6 +762,7 @@ class _StreamMessageWidgetState extends State borderRadiusGeometry: widget.borderRadiusGeometry, textBuilder: widget.textBuilder, quotedMessageBuilder: widget.quotedMessageBuilder, + deletedMessageBuilder: widget.deletedMessageBuilder, onLinkTap: widget.onLinkTap, onMentionTap: widget.onMentionTap, onQuotedMessageTap: widget.onQuotedMessageTap, @@ -1194,14 +1204,3 @@ extension on Poll { return copyWith(name: trimmedName); } } - -extension on String { - String get sentenceCase { - if (isEmpty) return this; - - final firstChar = this[0].toUpperCase(); - final restOfString = substring(1).toLowerCase(); - - return '$firstChar$restOfString'; - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart index f56e636cdc..51009602b9 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart @@ -72,6 +72,7 @@ class MessageWidgetContent extends StatelessWidget { this.onLinkTap, this.textBuilder, this.quotedMessageBuilder, + this.deletedMessageBuilder, this.bottomRowBuilderWithDefaultWidget, this.userAvatarBuilder, }); @@ -184,6 +185,9 @@ class MessageWidgetContent extends StatelessWidget { /// {@macro quotedMessageBuilder} final Widget Function(BuildContext, Message)? quotedMessageBuilder; + /// {@macro deletedMessageBuilder} + final Widget Function(BuildContext, Message)? deletedMessageBuilder; + /// {@macro translateUserAvatar} final bool translateUserAvatar; @@ -341,6 +345,19 @@ class MessageWidgetContent extends StatelessWidget { ); } + Widget _buildDeletedMessage(BuildContext context) { + if (deletedMessageBuilder case final builder?) { + return builder(context, message); + } + + return StreamDeletedMessage( + borderRadiusGeometry: borderRadiusGeometry, + borderSide: borderSide, + shape: shape, + messageTheme: messageTheme, + ); + } + Widget _buildMessageCard(BuildContext context) { if (message.isDeleted) { return Container( @@ -348,12 +365,7 @@ class MessageWidgetContent extends StatelessWidget { end: reverse && isFailedState ? 12.0 : 0.0, start: !reverse && isFailedState ? 12.0 : 0.0, ), - child: StreamDeletedMessage( - borderRadiusGeometry: borderRadiusGeometry, - borderSide: borderSide, - shape: shape, - messageTheme: messageTheme, - ), + child: _buildDeletedMessage(context), ); } diff --git a/packages/stream_chat_flutter/lib/src/utils/extensions.dart b/packages/stream_chat_flutter/lib/src/utils/extensions.dart index 3b9ea9ac21..4fbb58a52a 100644 --- a/packages/stream_chat_flutter/lib/src/utils/extensions.dart +++ b/packages/stream_chat_flutter/lib/src/utils/extensions.dart @@ -44,8 +44,20 @@ extension DurationExtension on Duration { /// String extension extension StringExtension on String { /// Returns the capitalized string - String capitalize() => - isNotEmpty ? '${this[0].toUpperCase()}${substring(1).toLowerCase()}' : ''; + @Deprecated('Use sentenceCase instead') + String capitalize() => sentenceCase; + + /// Returns the string in sentence case. + /// + /// Example: 'hello WORLD' -> 'Hello world' + String get sentenceCase { + if (isEmpty) return this; + + final firstChar = this[0].toUpperCase(); + final restOfString = substring(1).toLowerCase(); + + return '$firstChar$restOfString'; + } /// Returns the biggest line of a text. String biggestLine() { diff --git a/sample_app/lib/pages/channel_page.dart b/sample_app/lib/pages/channel_page.dart index 988ac96959..be6537b8a9 100644 --- a/sample_app/lib/pages/channel_page.dart +++ b/sample_app/lib/pages/channel_page.dart @@ -1,4 +1,4 @@ -// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use, avoid_redundant_argument_values import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -212,40 +212,39 @@ class _ChannelPageState extends State { final channel = StreamChannel.of(context).channel; final channelConfig = channel.config; + final currentUser = StreamChat.of(context).currentUser; + final isSentByCurrentUser = message.user?.id == currentUser?.id; + final canDeleteOwnMessage = channel.canDeleteOwnMessage; + final customOptions = [ + if (isSentByCurrentUser && canDeleteOwnMessage) + StreamMessageAction( + isDestructive: true, + title: const Text('Delete Message for Me'), + action: DeleteMessageForMe(message: message), + leading: const StreamSvgIcon(icon: StreamSvgIcons.delete), + ), if (channelConfig?.userMessageReminders == true) ...[ if (reminder != null) ...[ StreamMessageAction( - leading: StreamSvgIcon( - icon: StreamSvgIcons.time, - color: colorTheme.textLowEmphasis, - ), title: const Text('Edit Reminder'), + leading: const StreamSvgIcon(icon: StreamSvgIcons.time), action: EditReminder(message: message, reminder: reminder), ), StreamMessageAction( - leading: StreamSvgIcon( - icon: StreamSvgIcons.checkAll, - color: colorTheme.textLowEmphasis, - ), title: const Text('Remove from later'), + leading: const StreamSvgIcon(icon: StreamSvgIcons.checkAll), action: RemoveReminder(message: message, reminder: reminder), ), ] else ...[ StreamMessageAction( - leading: StreamSvgIcon( - icon: StreamSvgIcons.time, - color: colorTheme.textLowEmphasis, - ), title: const Text('Remind me'), + leading: const StreamSvgIcon(icon: StreamSvgIcons.time), action: CreateReminder(message: message), ), StreamMessageAction( - leading: Icon( - Icons.bookmark_border, - color: colorTheme.textLowEmphasis, - ), title: const Text('Save for later'), + leading: const Icon(Icons.bookmark_border), action: CreateBookmark(message: message), ), ], @@ -301,6 +300,7 @@ class _ChannelPageState extends State { CreateBookmark() => _createBookmark(it.message), EditReminder() => _editReminder(it.message, it.reminder), RemoveReminder() => _removeReminder(it.message, it.reminder), + DeleteMessageForMe() => _deleteMessageForMe(it.message), _ => null, }, attachmentBuilders: [locationAttachmentBuilder], @@ -374,6 +374,24 @@ class _ChannelPageState extends State { return client.createReminder(messageId).ignore(); } + Future _deleteMessageForMe(Message message) async { + final confirmDelete = await showStreamDialog( + context: context, + builder: (context) => const StreamMessageActionConfirmationModal( + isDestructiveAction: true, + title: Text('Delete for me'), + content: Text('Are you sure you want to delete this message for you?'), + cancelActionTitle: Text('Cancel'), + confirmActionTitle: Text('Delete'), + ), + ); + + if (confirmDelete != true) return; + + final channel = StreamChannel.of(context).channel; + return channel.deleteMessageForMe(message).ignore(); + } + bool defaultFilter(Message m) { final currentUser = StreamChat.of(context).currentUser; final isMyMessage = m.user?.id == currentUser?.id; @@ -419,3 +437,7 @@ final class RemoveReminder extends ReminderMessageAction { @override final MessageReminder reminder; } + +final class DeleteMessageForMe extends CustomMessageAction { + const DeleteMessageForMe({required super.message}); +}