Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions migrations/v10-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions packages/stream_chat/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
142 changes: 101 additions & 41 deletions packages/stream_chat/lib/src/client/channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -920,31 +920,49 @@ class Channel {

final _deleteMessageLock = Lock();

/// Deletes the [message] from the channel.
Future<EmptyResponse> 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<EmptyResponse> 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<EmptyResponse> 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<EmptyResponse> _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();
}
Expand All @@ -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]);
Expand All @@ -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<void> _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
Expand All @@ -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<Object> retryMessage(Message message) async {
assert(
message.state.isFailed || message.isBouncedWithError,
Expand All @@ -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.
Expand Down Expand Up @@ -2996,9 +3052,13 @@ class ChannelClientState {

void _listenMessageDeleted() {
_subscriptions.add(_channel.on(EventType.messageDeleted).listen((event) {
final message = event.message!;
final hardDelete = event.hardDelete ?? false;

final message = event.message!.copyWith(
// TODO: Remove once deletedForMe is properly enriched on the backend.
deletedForMe: event.deletedForMe,
);

return deleteMessage(message, hardDelete: hardDelete);
}));
}
Expand Down
24 changes: 16 additions & 8 deletions packages/stream_chat/lib/src/client/client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<EmptyResponse> 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<EmptyResponse> deleteMessageForMe(
String messageId,
) {
return _chatApi.message.deleteMessage(
messageId,
deleteForMe: true,
);
}

/// Get a message by [messageId]
Expand Down
Loading