diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index 62dfc6b740..12567e16eb 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -35,6 +35,7 @@ onto Stream's OpenAPI-generated API client. - [User Groups](#user-groups) - [Link Previews](#link-previews) - [Partial Updates](#partial-updates) + - [Channel Lifecycle](#channel-lifecycle) - [Channel and Member Custom Data](#channel-and-member-custom-data) - [Migration Checklist](#migration-checklist) - [For AI Agents](#for-ai-agents) @@ -83,6 +84,7 @@ from the spec, so don't subclass them or depend on their private constructors. | [**Polls**](#polls) | Poll calls return a `Result`; eight responses become `PollResponse`, `PollOptionResponse` and `PollVoteResponse`; queries take `limit`/`next`/`prev`; `VotingVisibility` is an extension type; `extraData` is `custom` | | [**File Upload**](#file-upload) | Uploads return a `Result` and deletes a `Result` instead of throwing; `AttachmentFileUploaderProvider` receives a `Dio` | | [**Partial Updates**](#partial-updates) | Channel and member partial updates — `updatePartial`, `updateName`, `updateImage`, slow mode, pin and archive — return a `Result` instead of throwing; their responses take the API's names, `UpdateChannelPartialResponse` and `UpdateMemberPartialResponse`, and `partialMemberUpdate` becomes `updateMemberPartial` | +| [**Channel Lifecycle**](#channel-lifecycle) | Hiding, showing and deleting a channel return a `Result` instead of throwing; stopping watching still throws | | [**Channel and Member Custom Data**](#channel-and-member-custom-data) | `extraData` is `custom` on `ChannelModel`, `Member` and `Channel`, and holds the custom data verbatim; the channel and member flags it carried are fields | | _(filled in per feature as PRs land)_ | | @@ -108,6 +110,7 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `UploadState`'s `Preparing` / `InProgress` / `Success` / `Failed` | `UploadStatePreparing` / `UploadStateInProgress` / `UploadStateSuccess` / `UploadStateFailed` | `renamed` | Frees `Success` for `Result` | | `PagedValue.error(StreamChatError)` (`stream_chat_flutter_core`) | `PagedValue.error(StreamChatException)` | `retyped` | | | `StreamChannelListController.muteChannel` / `unmuteChannel` → `Future` (`stream_chat_flutter_core`) | `Future>` | `retyped` | Returns a `Result` instead of throwing, so a `try`/`catch` around either no longer catches a failed call. A subclass overriding one needs the new return type | +| `StreamChannelListController.deleteChannel` → `Future` (`stream_chat_flutter_core`) | `Future>` | `retyped` | Returns a `Result` instead of throwing, so a `try`/`catch` around it no longer catches a failed call. A subclass overriding it needs the new return type | | `errorBuilder: Function(BuildContext, StreamChatError)` (scroll views) | `Function(BuildContext, StreamChatException)` | `retyped` | | | `StreamAttachmentValidator.validate()` / `.validateCount()` returning `StreamChatError?` | returning `AttachmentValidationError?` | `retyped` | `stream_chat_flutter`. They always returned rather than threw; the return type now says so | | `AttachmentLimitReachedError` / `AttachmentTooLargeError` / `AttachmentBlockedError` extending `StreamChatError` | extending `sealed AttachmentValidationError` | `retyped` | A refused attachment is not a failed call, so it is no longer one of the `StreamException` kinds. `switch` over them is exhaustive | @@ -261,6 +264,9 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `PartialUpdateChannelResponse()..channel = …`, `PartialUpdateMemberResponse()..channelMember = …` and their other setters | `UpdateChannelPartialResponse(duration: …, channel: …)` / `UpdateMemberPartialResponse(duration: …, channelMember: …)` | `retyped` | Plain classes with a const constructor and final fields | | `PartialUpdateChannelResponse` / `PartialUpdateMemberResponse` identity `==` | value `==`, plus `copyWith` | `retyped` | Two instances with the same fields are now equal | | `StreamChatApi.channel.updateChannelPartial` / `enableSlowdown` / `disableSlowdown` / `updateMemberPartial` | the `StreamChatClient` methods | `removed` | The endpoints moved to the generated client | +| `StreamChatClient.hideChannel` / `showChannel` / `deleteChannel` → `Future` | `Future>` / `Future>` / `Future>` | `retyped` | Returns a `Result` instead of throwing | +| `Channel.hide` / `show` / `delete` → `Future` | `Future>` / `Future>` / `Future>` | `retyped` | Returns a `Result` instead of throwing. Calling one before the channel is initialized still throws a `StateError` | +| `StreamChatApi.channel.hideChannel` / `showChannel` / `deleteChannel` | the `StreamChatClient` methods | `removed` | The endpoints moved to the generated client | | `MemberUpdatePayload(pinned: true).toJson()` / `MemberUpdateType.pinned.name` | `{'pinned': true}` / `'pinned'` | `removed` | Same for `archived`. Or call `pinChannel` / `unpinChannel` / `archiveChannel` / `unarchiveChannel` | | `ChannelModel.extraData` / `Member.extraData` | `ChannelModel.custom` / `Member.custom` | `renamed` | The wire's name for it; also the constructor and `copyWith` arguments | | `Channel.extraData` / `Channel.extraDataStream` / `Channel(…, extraData:)` / `StreamChatClient.channel(…, extraData:)` | `Channel.custom` / `Channel.customStream` / `custom:` | `renamed` | Follows `ChannelModel.custom`; the setter is renamed too | @@ -723,6 +729,38 @@ directly — `updateMemberPartial(channelId: …, channelType: …, set: {'pinne > field the API adds later reaches you without another break. `MemberUpdatePayload` and `MemberUpdateType` named > only two of the keys a membership accepts, so a plain map replaces them. +### Channel Lifecycle + +**Hiding, showing and deleting a channel return a `Result` instead of throwing.** That covers +`StreamChatClient.hideChannel`, `showChannel` and `deleteChannel`, `Channel.hide`, `show` and `delete`, and +`StreamChannelListController.deleteChannel` in `stream_chat_flutter_core`. A `try`/`catch` around one still +compiles, but no longer catches a failed call: read the returned `Result` instead. + +```dart +// v10 +try { + await channel.delete(); + Navigator.of(context).pop(); +} on StreamChatException catch (e) { + report(e); +} + +// v11 +final result = await channel.delete(); +result.fold( + onSuccess: (_) => Navigator.of(context).pop(), + onFailure: (error, _) => report(error), +); +``` + +**Each call answers its own response** where v10 answered `EmptyResponse`: `HideChannelResponse`, +`ShowChannelResponse` and `DeleteChannelResponse`, which carries the deleted channel in a nullable `channel`. + +**`StreamChatApi.channel.hideChannel`, `showChannel` and `deleteChannel` are removed.** Call them on +`StreamChatClient`. + +> **Why:** the endpoints moved onto the generated client, which returns a `Result` for every call. Each write +> answers its own envelope so a field the API adds later reaches you without another break. ### Channel and Member Custom Data **`extraData` is renamed `custom` on `ChannelModel`, `Member` and `Channel`.** That covers the fields, the diff --git a/openapi-migration/11-channels-and-members.md b/openapi-migration/11-channels-and-members.md index bffb0763b4..344c919491 100644 --- a/openapi-migration/11-channels-and-members.md +++ b/openapi-migration/11-channels-and-members.md @@ -1,8 +1,8 @@ # 11 — Channels, Members & Sync -**Goal:** The biggest group, and the one every controller above it reads through `ChannelState`. [15](15-partial-updates.md) split the partial channel and member updates out of it. +**Goal:** The biggest group, and the one every controller above it reads through `ChannelState`. [15](15-partial-updates.md) split the partial channel and member updates out of it, and [16](16-channel-lifecycle.md) hiding, showing and deleting a channel. -**Size:** 23 hand-written method(s) across 2 file(s) → 22 generated operation(s). +**Size:** 20 hand-written method(s) across 2 file(s) → 19 generated operation(s). > This whole file is generated by `openapi-migration/tool/generate_plan.py`, prose included. > Edit its `GROUPS` entry and re-run the script — edits made here are lost on the next run. @@ -23,10 +23,7 @@ | `channel_api.dart` | `addMembers` | `AddMembersResponse` | | `channel_api.dart` | `removeMembers` | `RemoveMembersResponse` | | `channel_api.dart` | `sendEvent` | `EmptyResponse` | -| `channel_api.dart` | `deleteChannel` | `EmptyResponse` | | `channel_api.dart` | `truncateChannel` | `EmptyResponse` | -| `channel_api.dart` | `hideChannel` | `EmptyResponse` | -| `channel_api.dart` | `showChannel` | `EmptyResponse` | | `channel_api.dart` | `markRead` | `EmptyResponse` | | `channel_api.dart` | `markUnread` | `EmptyResponse` | | `channel_api.dart` | `markUnreadByTimestamp` | `EmptyResponse` | @@ -41,7 +38,6 @@ | Verb | Path | Operation | Response | | --- | --- | --- | --- | -| `DELETE` | `/api/v2/chat/channels/{type}/{id}` | `deleteChannel` | `DeleteChannelResponse` | | `POST` | `/api/v2/chat/channels/delete` | `deleteChannels` | `DeleteChannelsResponse` | | `GET` | `/api/v2/chat/channels/{type}/{id}` | `getChannel` | `ChannelStateResponse` | | `GET` | `/api/v2/chat/channels/{type}/{id}/messages` | `getManyMessages` | `GetManyMessagesResponse` | @@ -49,7 +45,6 @@ | `POST` | `/api/v2/chat/channels/{type}/query` | `getOrCreateDistinctChannel` | `ChannelStateResponse` | | `GET` | `/api/v2/chat/channels/{type}/{id}/pinned_messages` | `getPinnedMessages` | `GetPinnedMessagesResponse` | | `POST` | `/api/v2/chat/channels/grouped` | `groupedQueryChannels` | `GroupedQueryChannelsResponse` | -| `POST` | `/api/v2/chat/channels/{type}/{id}/hide` | `hideChannel` | `HideChannelResponse` | | `POST` | `/api/v2/chat/channels/read` | `markChannelsRead` | `MarkReadResponse` | | `POST` | `/api/v2/chat/channels/delivered` | `markDelivered` | `MarkDeliveredResponse` | | `POST` | `/api/v2/chat/channels/{type}/{id}/read` | `markRead` | `MarkReadResponse` | @@ -58,7 +53,6 @@ | `GET` | `/api/v2/chat/members` | `queryMembers` | `MembersResponse` | | `POST` | `/api/v2/chat/channels/{type}/{id}/event` | `sendEvent` | `EventResponse` | | `POST` | `/api/v2/chat/channels/{type}/{id}/message` | `sendMessage` | `SendMessageResponse` | -| `POST` | `/api/v2/chat/channels/{type}/{id}/show` | `showChannel` | `ShowChannelResponse` | | `POST` | `/api/v2/chat/channels/{type}/{id}/stop-watching` | `stopWatchingChannel` | `DurationResponse` | | `POST` | `/api/v2/chat/sync` | `sync` | `SyncResponse` | | `POST` | `/api/v2/chat/channels/{type}/{id}/truncate` | `truncateChannel` | `TruncateChannelResponse` | diff --git a/openapi-migration/16-channel-lifecycle.md b/openapi-migration/16-channel-lifecycle.md new file mode 100644 index 0000000000..579578969e --- /dev/null +++ b/openapi-migration/16-channel-lifecycle.md @@ -0,0 +1,66 @@ +# 16 — Channel Lifecycle + +**Goal:** Move hiding, showing and deleting a channel ahead of [11](11-channels-and-members.md): they answer only a `duration` or a channel [15](15-partial-updates.md) already maps, so they need none of the message mappers. + +**Size:** 0 hand-written method(s) across 0 file(s) → 3 generated operation(s). + +> This whole file is generated by `openapi-migration/tool/generate_plan.py`, prose included. +> Edit its `GROUPS` entry and re-run the script — edits made here are lost on the next run. + +## Scope + +### Hand-written today + +| File | Method | Returns | +| --- | --- | --- | + +### Generated operations that cover it + +| Verb | Path | Operation | Response | +| --- | --- | --- | --- | +| `DELETE` | `/api/v2/chat/channels/{type}/{id}` | `deleteChannel` | `DeleteChannelResponse` | +| `POST` | `/api/v2/chat/channels/{type}/{id}/hide` | `hideChannel` | `HideChannelResponse` | +| `POST` | `/api/v2/chat/channels/{type}/{id}/show` | `showChannel` | `ShowChannelResponse` | + +## Decisions taken + +- **Split out of [11](11-channels-and-members.md), ahead of it.** None of the three reads anything + into client state — the `channel.hidden`, `channel.visible` and `channel.deleted` events do that — + and the one channel they answer goes through `channels_mapper.dart`. +- **The v2 routes are the v1 handlers.** `lib/chat/routes.go` mounts hide, show and delete in the + shared `coreRoutes`; none is gated, in beta or deprecated. +- **Moved off `ChannelApi`:** `hideChannel`, `showChannel` and `deleteChannel`, each now a + `StreamChatClient` method over `ChannelsRepository`. +- **Each write answers its own envelope,** where v10 answered `EmptyResponse`: `HideChannelResponse`, + `ShowChannelResponse` and `DeleteChannelResponse`, freezed, in `models/response/`, with the spec's + names. `DeleteChannelResponse.channel` is nullable, as the spec declares it. `Channel.hide`, `show` + and `delete` keep their names. +- **`StreamChannelListController.deleteChannel` returns the `Result`,** as `muteChannel` does. +- **`hard_delete` is not exposed.** The server refuses it from a client-side token, and v10 never sent it. +- **Show sends no body,** where v1 sent `{}`; the server accepts both. Hide still sends `clear_history`. +- **The read and delivery receipts stay in 11 for now.** `markRead` and its siblings share endpoints + with the thread read calls, fabricate a response on the local-unread path, and feed + `MessageListUnreadController` and the public `ChannelDeliveryReporter`, which rely on a throw; they + move in a slice of their own. + +## Risks + + +## Definition of done + +- [x] Every method above either routes through `DefaultApi` or is listed here as deliberately left + hand-written, with the reason. +- [x] Public methods return `Future>`; no `getOrThrow()` inside the SDK. +- [x] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [x] `copywith_audit.py` was run for every model that loses a v10 `copyWith`, every site it reports is + handled, and the semantics change is in the CHANGELOG and the guide ([README rule 2](README.md#domain-models)). +- [x] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group + persists anything. +- [x] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. +- [x] CHANGELOG entry under `🛑️ Breaking` for each break; PR title `refactor(llc)!:`. +- [x] Decisions recorded in this file, and the status box ticked in `README.md`. +- [x] Public dartdoc follows [`STYLE_GUIDE.md` § Documentation](../STYLE_GUIDE.md#documentation) + and [`EFFECTIVE_DART_DOC.md`](../EFFECTIVE_DART_DOC.md), including on symbols this group + retyped but whose docs it left alone. +- [x] Tests follow [`TESTING.md`](../TESTING.md): no `group` organizing a file by method, each + name states its subject and behaviour. diff --git a/openapi-migration/README.md b/openapi-migration/README.md index 823b54d3bb..7e1e77e707 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -22,14 +22,15 @@ generated operations in scope, the decisions that group has to make, its risks, | [08](08-moderation-and-blocklists.md) | Moderation & Blocklists | 0 | 34 | ☑ | | [09](09-users.md) | Users | 9 | 9 | ☐ | | [10](10-messages.md) | Messages & Search | 14 | 12 | ☐ | -| [11](11-channels-and-members.md) | Channels, Members & Sync | 23 | 22 | ☐ | +| [11](11-channels-and-members.md) | Channels, Members & Sync | 20 | 19 | ☐ | | [12](12-uploads-cdn.md) | Uploads (CDN) | 8 | 8 | ☑ | | [13](13-push-preferences.md) | Push Preferences | 1 | 1 | ☐ | | [14](14-banned-users.md) | Banned Users — split out of 08 | 1 | 1 | ☐ | | [15](15-partial-updates.md) | Partial Updates — split out of 11 | 0 | 2 | ☑ | +| [16](16-channel-lifecycle.md) | Channel Lifecycle — split out of 11 | 0 | 3 | ☑ | | [21](21-custom-data.md) | Custom Data Check — no model still says `extraData` | — | — | ☐ | -**Coverage:** 67 hand-written methods across 9 files, and all 129 generated operations, each claimed by exactly +**Coverage:** 64 hand-written methods across 9 files, and all 129 generated operations, each claimed by exactly one group. Verified mechanically — see [Keeping this plan honest](#keeping-this-plan-honest). @@ -196,6 +197,8 @@ surfaces before it reaches `Message` and `ChannelState`: read nothing into `ChannelState`. It landed first; the full update waits in 11 for group 10's message mappers. As the first group to map `ChannelModel` and `Member`, it promoted their parked fields and renamed their custom data `custom` (rule 9). +- **16** is hiding, showing and deleting a channel, split out of 11 after 15 because they answer nothing + `channels_mapper.dart` cannot map; the read and delivery receipts stay in 11 for a slice of their own. - **12** comes late because it needs its own hand-written multipart client and is the highest-traffic path in the SDK. - **09** is last. Every group before it maps users through `user_mapper.dart` onto today's `User`; 09 migrates the diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 146141434e..0c6e751a02 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -660,9 +660,13 @@ def match(verb, path): '/api/v2/chat/channels/{type}/{id}/file', '/api/v2/chat/channels/{type}/{id}/image'), unless_ops=('PATCH /api/v2/chat/channels/{type}/{id}', - 'PATCH /api/v2/chat/channels/{type}/{id}/member')), + 'PATCH /api/v2/chat/channels/{type}/{id}/member', + 'POST /api/v2/chat/channels/{type}/{id}/hide', + 'POST /api/v2/chat/channels/{type}/{id}/show', + 'DELETE /api/v2/chat/channels/{type}/{id}')), goal='The biggest group, and the one every controller above it reads through `ChannelState`. ' - '[15](15-partial-updates.md) split the partial channel and member updates out of it.', + '[15](15-partial-updates.md) split the partial channel and member updates out of it, and ' + '[16](16-channel-lifecycle.md) hiding, showing and deleting a channel.', decisions=[ '`ChannelState`, `ChannelModel` and `Member` are public, persisted, and rebuilt from WebSocket ' 'events. Keep ours and map.', @@ -903,6 +907,39 @@ def match(verb, path): ], done=DONE.replace('- [ ]', '- [x]'), ), + dict( + num='16', slug='channel-lifecycle', title='Channel Lifecycle', + hand=[], + match=only_ops('POST /api/v2/chat/channels/{type}/{id}/hide', + 'POST /api/v2/chat/channels/{type}/{id}/show', + 'DELETE /api/v2/chat/channels/{type}/{id}'), + goal='Move hiding, showing and deleting a channel ahead of ' + '[11](11-channels-and-members.md): they answer only a `duration` or a channel ' + '[15](15-partial-updates.md) already maps, so they need none of the message mappers.', + decisions=[], + taken=textwrap.dedent("""\ + - **Split out of [11](11-channels-and-members.md), ahead of it.** None of the three reads anything + into client state — the `channel.hidden`, `channel.visible` and `channel.deleted` events do that — + and the one channel they answer goes through `channels_mapper.dart`. + - **The v2 routes are the v1 handlers.** `lib/chat/routes.go` mounts hide, show and delete in the + shared `coreRoutes`; none is gated, in beta or deprecated. + - **Moved off `ChannelApi`:** `hideChannel`, `showChannel` and `deleteChannel`, each now a + `StreamChatClient` method over `ChannelsRepository`. + - **Each write answers its own envelope,** where v10 answered `EmptyResponse`: `HideChannelResponse`, + `ShowChannelResponse` and `DeleteChannelResponse`, freezed, in `models/response/`, with the spec's + names. `DeleteChannelResponse.channel` is nullable, as the spec declares it. `Channel.hide`, `show` + and `delete` keep their names. + - **`StreamChannelListController.deleteChannel` returns the `Result`,** as `muteChannel` does. + - **`hard_delete` is not exposed.** The server refuses it from a client-side token, and v10 never sent it. + - **Show sends no body,** where v1 sent `{}`; the server accepts both. Hide still sends `clear_history`. + - **The read and delivery receipts stay in 11 for now.** `markRead` and its siblings share endpoints + with the thread read calls, fabricate a response on the local-unread path, and feed + `MessageListUnreadController` and the public `ChannelDeliveryReporter`, which rely on a throw; they + move in a slice of their own. + """), + risks=[], + done=DONE.replace('- [ ]', '- [x]'), + ), dict( num='21', slug='custom-data', title='Custom Data Check', hand=[], diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 7c770729a2..055dcaeac8 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -104,6 +104,8 @@ - `UpdateChannelPartialResponse.channel` and `UpdateMemberPartialResponse.channelMember` are nullable, and `UpdateChannelPartialResponse.members` is a non-nullable list. - `StreamChatApi.channel.updateChannelPartial`, `enableSlowdown`, `disableSlowdown` and `updateMemberPartial` are removed; call them on `StreamChatClient` instead. - `MemberUpdatePayload` and `MemberUpdateType` are removed; pass `set: {'pinned': true}` or `unset: ['pinned']` (and `archived`) to `updateMemberPartial`, or call `pinChannel`, `archiveChannel` and their counterparts. +- `hideChannel`, `showChannel` and `deleteChannel` on `StreamChatClient`, and `hide`, `show` and `delete` on `Channel`, return a `Result`, `Result` and `Result` instead of throwing. +- `StreamChatApi.channel.hideChannel`, `showChannel` and `deleteChannel` are removed; call them on `StreamChatClient` instead. - `ChannelModel.extraData` and `Member.extraData` are renamed `custom`, and so are `Channel.extraData`, `Channel.extraDataStream` (now `customStream`), and the `extraData` argument of the `Channel` constructor and of `StreamChatClient.channel`. - `ChannelModel.disabled`, `hidden`, `muted`, `blocked`, `truncatedAt`, `truncatedBy` and the auto-translation settings, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, `futureChannelBanExpires` and `deletedAt`, are fields instead of entries in the custom data, so `custom` no longer holds `hidden`, `status` and the rest, and an entry with that name in `custom` or in `copyWith(custom:)` no longer sets the field. - `ChannelModel.custom` and `Member.custom` hold the custom data exactly as the API returns it, and `toJson` leaves out an entry named like one of the model's own fields. diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index e09925b2da..5310d46f95 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -1760,8 +1760,8 @@ class Channel { return _client.disableSlowMode(id!, type); } - /// Delete this channel. Messages are permanently removed. - Future delete() async { + /// Deletes this channel and its messages. + Future> delete() async { _checkInitialized(); return _client.deleteChannel(id!, type); } @@ -2343,12 +2343,10 @@ class Channel { ); } - /// Hides the channel from [StreamChatClient.queryChannels] for the user - /// until a message is added. + /// Hides this channel from the current user's channel list until a new message is added to it. /// - /// If [clearHistory] is set to true - all messages - /// will be removed for the user. - Future hide({bool clearHistory = false}) async { + /// If [clearHistory] is true, the channel's messages are also cleared for the current user. + Future> hide({bool clearHistory = false}) async { _checkInitialized(); return _client.hideChannel( id!, @@ -2357,8 +2355,8 @@ class Channel { ); } - /// Removes the hidden status for the channel. - Future show() async { + /// Shows this channel after the current user hid it. + Future> show() async { _checkInitialized(); return _client.showChannel(id!, type); } diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index a001dce0af..b7cd0aaa6d 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -63,7 +63,9 @@ import '../core/models/reaction.dart'; import '../core/models/response/add_user_group_members_response.dart'; import '../core/models/response/app_settings_response.dart'; import '../core/models/response/create_user_group_response.dart'; +import '../core/models/response/delete_channel_response.dart'; import '../core/models/response/get_user_group_response.dart'; +import '../core/models/response/hide_channel_response.dart'; import '../core/models/response/list_devices_response.dart'; import '../core/models/response/list_user_groups_response.dart'; import '../core/models/response/og_attachment_response.dart'; @@ -75,6 +77,7 @@ import '../core/models/response/query_polls_response.dart'; import '../core/models/response/remove_user_group_members_response.dart'; import '../core/models/response/search_roles_response.dart'; import '../core/models/response/search_user_groups_response.dart'; +import '../core/models/response/show_channel_response.dart'; import '../core/models/response/update_channel_partial_response.dart'; import '../core/models/response/update_member_partial_response.dart'; import '../core/models/response/update_user_group_response.dart'; @@ -1448,33 +1451,33 @@ class StreamChatClient { pagination: pagination, ); - /// Hides the channel from [queryChannels] for the user - /// until a message is added If [clearHistory] is set to true - all messages - /// will be removed for the user - Future hideChannel( + /// Hides a channel from the current user's channel list until a new message is added to it. + /// + /// If [clearHistory] is true, the channel's messages are also cleared for the current user. + Future> hideChannel( String channelId, String channelType, { bool clearHistory = false, - }) => _chatApi.channel.hideChannel( + }) => _channelsRepository.hideChannel( channelId, channelType, clearHistory: clearHistory, ); - /// Removes the hidden status for the channel - Future showChannel( + /// Shows a channel the current user hid. + Future> showChannel( String channelId, String channelType, - ) => _chatApi.channel.showChannel( + ) => _channelsRepository.showChannel( channelId, channelType, ); - /// Delete this channel. Messages are permanently removed. - Future deleteChannel( + /// Deletes a channel and its messages. + Future> deleteChannel( String channelId, String channelType, - ) => _chatApi.channel.deleteChannel( + ) => _channelsRepository.deleteChannel( channelId, channelType, ); diff --git a/packages/stream_chat/lib/src/core/api/channel_api.dart b/packages/stream_chat/lib/src/core/api/channel_api.dart index 14a762c818..a5d0e6045a 100644 --- a/packages/stream_chat/lib/src/core/api/channel_api.dart +++ b/packages/stream_chat/lib/src/core/api/channel_api.dart @@ -218,17 +218,6 @@ class ChannelApi { return EmptyResponse.fromJson(response.data); } - /// Delete this channel. Messages are permanently removed. - Future deleteChannel( - String channelId, - String channelType, - ) async { - final response = await _client.delete( - _getChannelUrl(channelId, channelType), - ); - return EmptyResponse.fromJson(response.data); - } - /// Removes all messages from the channel Future truncateChannel( String channelId, @@ -248,33 +237,6 @@ class ChannelApi { return EmptyResponse.fromJson(response.data); } - /// Hides the channel from [StreamChatClient.queryChannels] for the user - /// until a message is added If [clearHistory] is set to true - all messages - /// will be removed for the user - Future hideChannel( - String channelId, - String channelType, { - bool clearHistory = false, - }) async { - final response = await _client.post( - '${_getChannelUrl(channelId, channelType)}/hide', - data: {'clear_history': clearHistory}, - ); - return EmptyResponse.fromJson(response.data); - } - - /// Removes the hidden status for the channel - Future showChannel( - String channelId, - String channelType, - ) async { - final response = await _client.post( - '${_getChannelUrl(channelId, channelType)}/show', - data: {}, - ); - return EmptyResponse.fromJson(response.data); - } - /// Mark [channelId] of type [channelType] all messages as read /// Optionally provide a [messageId] if you want to mark channel as /// read from particular message onwards diff --git a/packages/stream_chat/lib/src/core/models/response/delete_channel_response.dart b/packages/stream_chat/lib/src/core/models/response/delete_channel_response.dart new file mode 100644 index 0000000000..da4db42692 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/delete_channel_response.dart @@ -0,0 +1,23 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../channel_model.dart'; + +part 'delete_channel_response.freezed.dart'; + +/// A channel after it was deleted. +@freezed +class DeleteChannelResponse with _$DeleteChannelResponse { + /// Creates a new [DeleteChannelResponse]. + const DeleteChannelResponse({ + required this.duration, + this.channel, + }); + + /// How long the request took to handle, such as `4.21ms`. + @override + final String duration; + + /// The deleted channel. + @override + final ChannelModel? channel; +} diff --git a/packages/stream_chat/lib/src/core/models/response/delete_channel_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/delete_channel_response.freezed.dart new file mode 100644 index 0000000000..e96f6d1194 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/delete_channel_response.freezed.dart @@ -0,0 +1,82 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// 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 'delete_channel_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$DeleteChannelResponse { + String get duration; + ChannelModel? get channel; + + /// Create a copy of DeleteChannelResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $DeleteChannelResponseCopyWith get copyWith => + _$DeleteChannelResponseCopyWithImpl( + this as DeleteChannelResponse, + _$identity, + ); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is DeleteChannelResponse && + (identical(other.duration, duration) || other.duration == duration) && + (identical(other.channel, channel) || other.channel == channel)); + } + + @override + int get hashCode => Object.hash(runtimeType, duration, channel); + + @override + String toString() { + return 'DeleteChannelResponse(duration: $duration, channel: $channel)'; + } +} + +/// @nodoc +abstract mixin class $DeleteChannelResponseCopyWith<$Res> { + factory $DeleteChannelResponseCopyWith( + DeleteChannelResponse value, + $Res Function(DeleteChannelResponse) _then, + ) = _$DeleteChannelResponseCopyWithImpl; + @useResult + $Res call({String duration, ChannelModel? channel}); +} + +/// @nodoc +class _$DeleteChannelResponseCopyWithImpl<$Res> implements $DeleteChannelResponseCopyWith<$Res> { + _$DeleteChannelResponseCopyWithImpl(this._self, this._then); + + final DeleteChannelResponse _self; + final $Res Function(DeleteChannelResponse) _then; + + /// Create a copy of DeleteChannelResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? duration = null, Object? channel = freezed}) { + return _then( + DeleteChannelResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + channel: freezed == channel + ? _self.channel + : channel // ignore: cast_nullable_to_non_nullable + as ChannelModel?, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/response/hide_channel_response.dart b/packages/stream_chat/lib/src/core/models/response/hide_channel_response.dart new file mode 100644 index 0000000000..4f70a15fa1 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/hide_channel_response.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'hide_channel_response.freezed.dart'; + +/// The outcome of hiding a channel for the current user. +@freezed +class HideChannelResponse with _$HideChannelResponse { + /// Creates a new [HideChannelResponse]. + const HideChannelResponse({ + required this.duration, + }); + + /// How long the request took to handle, such as `4.21ms`. + @override + final String duration; +} diff --git a/packages/stream_chat/lib/src/core/models/response/hide_channel_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/hide_channel_response.freezed.dart new file mode 100644 index 0000000000..e10b0ef0eb --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/hide_channel_response.freezed.dart @@ -0,0 +1,76 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// 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 'hide_channel_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$HideChannelResponse { + String get duration; + + /// Create a copy of HideChannelResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $HideChannelResponseCopyWith get copyWith => + _$HideChannelResponseCopyWithImpl( + this as HideChannelResponse, + _$identity, + ); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is HideChannelResponse && + (identical(other.duration, duration) || other.duration == duration)); + } + + @override + int get hashCode => Object.hash(runtimeType, duration); + + @override + String toString() { + return 'HideChannelResponse(duration: $duration)'; + } +} + +/// @nodoc +abstract mixin class $HideChannelResponseCopyWith<$Res> { + factory $HideChannelResponseCopyWith( + HideChannelResponse value, + $Res Function(HideChannelResponse) _then, + ) = _$HideChannelResponseCopyWithImpl; + @useResult + $Res call({String duration}); +} + +/// @nodoc +class _$HideChannelResponseCopyWithImpl<$Res> implements $HideChannelResponseCopyWith<$Res> { + _$HideChannelResponseCopyWithImpl(this._self, this._then); + + final HideChannelResponse _self; + final $Res Function(HideChannelResponse) _then; + + /// Create a copy of HideChannelResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? duration = null}) { + return _then( + HideChannelResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/response/show_channel_response.dart b/packages/stream_chat/lib/src/core/models/response/show_channel_response.dart new file mode 100644 index 0000000000..540e7488d7 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/show_channel_response.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'show_channel_response.freezed.dart'; + +/// The outcome of showing a channel the current user hid. +@freezed +class ShowChannelResponse with _$ShowChannelResponse { + /// Creates a new [ShowChannelResponse]. + const ShowChannelResponse({ + required this.duration, + }); + + /// How long the request took to handle, such as `4.21ms`. + @override + final String duration; +} diff --git a/packages/stream_chat/lib/src/core/models/response/show_channel_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/show_channel_response.freezed.dart new file mode 100644 index 0000000000..2b2700bab3 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/show_channel_response.freezed.dart @@ -0,0 +1,76 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// 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 'show_channel_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$ShowChannelResponse { + String get duration; + + /// Create a copy of ShowChannelResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $ShowChannelResponseCopyWith get copyWith => + _$ShowChannelResponseCopyWithImpl( + this as ShowChannelResponse, + _$identity, + ); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is ShowChannelResponse && + (identical(other.duration, duration) || other.duration == duration)); + } + + @override + int get hashCode => Object.hash(runtimeType, duration); + + @override + String toString() { + return 'ShowChannelResponse(duration: $duration)'; + } +} + +/// @nodoc +abstract mixin class $ShowChannelResponseCopyWith<$Res> { + factory $ShowChannelResponseCopyWith( + ShowChannelResponse value, + $Res Function(ShowChannelResponse) _then, + ) = _$ShowChannelResponseCopyWithImpl; + @useResult + $Res call({String duration}); +} + +/// @nodoc +class _$ShowChannelResponseCopyWithImpl<$Res> implements $ShowChannelResponseCopyWith<$Res> { + _$ShowChannelResponseCopyWithImpl(this._self, this._then); + + final ShowChannelResponse _self; + final $Res Function(ShowChannelResponse) _then; + + /// Create a copy of ShowChannelResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? duration = null}) { + return _then( + ShowChannelResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/repository/channels_repository.dart b/packages/stream_chat/lib/src/repository/channels_repository.dart index 2d0e995089..64e2f00ff9 100644 --- a/packages/stream_chat/lib/src/repository/channels_repository.dart +++ b/packages/stream_chat/lib/src/repository/channels_repository.dart @@ -1,6 +1,9 @@ import 'package:stream_core/stream_core.dart' show PatternMatching, Result; import '../../open_api/api.dart' as api; +import '../core/models/response/delete_channel_response.dart'; +import '../core/models/response/hide_channel_response.dart'; +import '../core/models/response/show_channel_response.dart'; import '../core/models/response/update_channel_partial_response.dart'; import '../core/models/response/update_member_partial_response.dart'; import 'mapper/channels_mapper.dart'; @@ -49,4 +52,41 @@ class ChannelsRepository { return result.map((response) => response.toModel()); } + + /// Hides a channel from the current user's channel list until a new message is added to it. + /// + /// If [clearHistory] is true, the channel's messages are also cleared for the current user. + Future> hideChannel( + String channelId, + String channelType, { + bool clearHistory = false, + }) async { + final result = await _api.hideChannel( + type: channelType, + id: channelId, + hideChannelRequest: api.HideChannelRequest(clearHistory: clearHistory), + ); + + return result.map((response) => response.toModel()); + } + + /// Shows a channel the current user hid. + Future> showChannel( + String channelId, + String channelType, + ) async { + final result = await _api.showChannel(type: channelType, id: channelId); + + return result.map((response) => response.toModel()); + } + + /// Deletes a channel and its messages. + Future> deleteChannel( + String channelId, + String channelType, + ) async { + final result = await _api.deleteChannel(type: channelType, id: channelId); + + return result.map((response) => response.toModel()); + } } diff --git a/packages/stream_chat/lib/src/repository/mapper/channels_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/channels_mapper.dart index 305c247633..37076e0c53 100644 --- a/packages/stream_chat/lib/src/repository/mapper/channels_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/channels_mapper.dart @@ -7,6 +7,9 @@ import '../../core/models/chat_preferences.dart'; import '../../core/models/command.dart'; import '../../core/models/member.dart'; import '../../core/models/push_level.dart'; +import '../../core/models/response/delete_channel_response.dart'; +import '../../core/models/response/hide_channel_response.dart'; +import '../../core/models/response/show_channel_response.dart'; import '../../core/models/response/update_channel_partial_response.dart'; import '../../core/models/response/update_member_partial_response.dart'; import 'user_mapper.dart'; @@ -152,3 +155,24 @@ extension UpdateMemberPartialResponseMapper on api.UpdateMemberPartialResponse { channelMember: channelMember?.toModel(), ); } + +/// Maps a generated [api.HideChannelResponse] to a [HideChannelResponse]. +extension HideChannelResponseMapper on api.HideChannelResponse { + /// Converts this response into a [HideChannelResponse]. + HideChannelResponse toModel() => HideChannelResponse(duration: duration); +} + +/// Maps a generated [api.ShowChannelResponse] to a [ShowChannelResponse]. +extension ShowChannelResponseMapper on api.ShowChannelResponse { + /// Converts this response into a [ShowChannelResponse]. + ShowChannelResponse toModel() => ShowChannelResponse(duration: duration); +} + +/// Maps a generated [api.DeleteChannelResponse] to a [DeleteChannelResponse]. +extension DeleteChannelResponseMapper on api.DeleteChannelResponse { + /// Converts this response into a [DeleteChannelResponse]. + DeleteChannelResponse toModel() => DeleteChannelResponse( + duration: duration, + channel: channel?.toModel(), + ); +} diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index f27792f2a7..f676d34b6a 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -123,8 +123,10 @@ export 'src/core/models/read.dart'; export 'src/core/models/response/add_user_group_members_response.dart'; export 'src/core/models/response/app_settings_response.dart'; export 'src/core/models/response/create_user_group_response.dart'; +export 'src/core/models/response/delete_channel_response.dart'; export 'src/core/models/response/flag_response.dart'; export 'src/core/models/response/get_user_group_response.dart'; +export 'src/core/models/response/hide_channel_response.dart'; export 'src/core/models/response/list_devices_response.dart'; export 'src/core/models/response/list_user_groups_response.dart'; export 'src/core/models/response/mute_users_response.dart'; @@ -137,6 +139,7 @@ export 'src/core/models/response/query_polls_response.dart'; export 'src/core/models/response/remove_user_group_members_response.dart'; export 'src/core/models/response/search_roles_response.dart'; export 'src/core/models/response/search_user_groups_response.dart'; +export 'src/core/models/response/show_channel_response.dart'; export 'src/core/models/response/unmute_users_response.dart'; export 'src/core/models/response/update_channel_partial_response.dart'; export 'src/core/models/response/update_member_partial_response.dart'; diff --git a/packages/stream_chat/test/src/client/channel/channel_test.dart b/packages/stream_chat/test/src/client/channel/channel_test.dart index 6ad86432f6..2ca15e4e81 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -3817,12 +3817,16 @@ void main() { ).called(1); }); - test('`.delete`', () async { - when(() => client.deleteChannel(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); + test("Channel.delete returns the client's deleteChannel response", () async { + final response = DeleteChannelResponse( + duration: '0.01ms', + channel: ChannelModel(cid: channelCid), + ); + when(() => client.deleteChannel(channelId, channelType)).thenAnswer((_) async => Result.success(response)); final res = await channel.delete(); - expect(res, isNotNull); + expect(res.getOrNull(), response); verify(() => client.deleteChannel(channelId, channelType)).called(1); }); @@ -4816,8 +4820,9 @@ void main() { verify(() => moderationClient.shadowBan(userId, channelCid: channelCid)).called(1); }); - test('`.hide`', () async { + test("Channel.hide returns the client's hideChannel response", () async { const clearHistory = true; + const response = HideChannelResponse(duration: '0.01ms'); when( () => client.hideChannel( @@ -4825,11 +4830,11 @@ void main() { channelType, clearHistory: clearHistory, ), - ).thenAnswer((_) async => EmptyResponse()); + ).thenAnswer((_) async => const Result.success(response)); final res = await channel.hide(clearHistory: clearHistory); - expect(res, isNotNull); + expect(res.getOrNull(), response); verify( () => client.hideChannel( @@ -4840,12 +4845,13 @@ void main() { ).called(1); }); - test('`.show`', () async { - when(() => client.showChannel(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); + test("Channel.show returns the client's showChannel response", () async { + const response = ShowChannelResponse(duration: '0.01ms'); + when(() => client.showChannel(channelId, channelType)).thenAnswer((_) async => const Result.success(response)); final res = await channel.show(); - expect(res, isNotNull); + expect(res.getOrNull(), response); verify(() => client.showChannel(channelId, channelType)).called(1); }); diff --git a/packages/stream_chat/test/src/client/client_delete_channel_test.dart b/packages/stream_chat/test/src/client/client_delete_channel_test.dart new file mode 100644 index 0000000000..019338a87b --- /dev/null +++ b/packages/stream_chat/test/src/client/client_delete_channel_test.dart @@ -0,0 +1,77 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/open_api/api.dart' as api; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; + +void main() { + test('StreamChatClient.deleteChannel sends the channel and returns the deleted channel', () async { + final response = api.DeleteChannelResponse( + duration: '0.01ms', + channel: api.ChannelResponse( + cid: 'messaging:general', + id: 'general', + type: 'messaging', + createdAt: DateTime.utc(2026), + updatedAt: DateTime.utc(2026, 2), + deletedAt: DateTime.utc(2026, 3), + custom: const {'name': 'General'}, + disabled: false, + frozen: false, + ), + ); + + final defaultApi = MockDefaultApi(); + when(() => defaultApi.deleteChannel(type: 'messaging', id: 'general')).thenAnswer( + (_) async => Result.success(response), + ); + final client = _client(defaultApi); + + final res = await client.deleteChannel('general', 'messaging'); + + expect(res.getOrNull()!.duration, '0.01ms'); + final channel = res.getOrNull()!.channel!; + expect(channel.cid, 'messaging:general'); + expect(channel.name, 'General'); + expect(channel.createdAt, DateTime.utc(2026)); + expect(channel.updatedAt, DateTime.utc(2026, 2)); + expect(channel.deletedAt, DateTime.utc(2026, 3)); + verify(() => defaultApi.deleteChannel(type: 'messaging', id: 'general')).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.deleteChannel returns a null channel when the response has none', () async { + final defaultApi = MockDefaultApi(); + when(() => defaultApi.deleteChannel(type: 'messaging', id: 'general')).thenAnswer( + (_) async => const Result.success(api.DeleteChannelResponse(duration: '0.01ms')), + ); + final client = _client(defaultApi); + + final res = await client.deleteChannel('general', 'messaging'); + + expect(res, const Result.success(DeleteChannelResponse(duration: '0.01ms'))); + }); + + test('StreamChatClient.deleteChannel returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.deleteChannel( + type: any(named: 'type'), + id: any(named: 'id'), + ), + ).thenAnswer((_) async => const Result.failure(error)); + final client = _client(defaultApi); + + final res = await client.deleteChannel('general', 'messaging'); + + expect(res.exceptionOrNull(), error); + }); +} + +StreamChatClient _client(api.DefaultApi defaultApi) { + final client = StreamChatClient('test-api-key', defaultApi: defaultApi); + addTearDown(client.dispose); + return client; +} diff --git a/packages/stream_chat/test/src/client/client_hide_channel_test.dart b/packages/stream_chat/test/src/client/client_hide_channel_test.dart new file mode 100644 index 0000000000..6154e9a7b9 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_hide_channel_test.dart @@ -0,0 +1,65 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/open_api/api.dart' as api; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; + +void main() { + setUpAll(() => registerFallbackValue(const api.HideChannelRequest())); + + test('StreamChatClient.hideChannel sends the history kept by default and returns the response', () async { + const request = api.HideChannelRequest(clearHistory: false); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.hideChannel(type: 'messaging', id: 'general', hideChannelRequest: request), + ).thenAnswer((_) async => const Result.success(api.HideChannelResponse(duration: '0.01ms'))); + final client = _client(defaultApi); + + final res = await client.hideChannel('general', 'messaging'); + + expect(res.getOrNull(), const HideChannelResponse(duration: '0.01ms')); + verify(() => defaultApi.hideChannel(type: 'messaging', id: 'general', hideChannelRequest: request)).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.hideChannel sends the history cleared and returns the response', () async { + const request = api.HideChannelRequest(clearHistory: true); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.hideChannel(type: 'messaging', id: 'general', hideChannelRequest: request), + ).thenAnswer((_) async => const Result.success(api.HideChannelResponse(duration: '0.01ms'))); + final client = _client(defaultApi); + + final res = await client.hideChannel('general', 'messaging', clearHistory: true); + + expect(res.getOrNull(), const HideChannelResponse(duration: '0.01ms')); + verify(() => defaultApi.hideChannel(type: 'messaging', id: 'general', hideChannelRequest: request)).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.hideChannel returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.hideChannel( + type: any(named: 'type'), + id: any(named: 'id'), + hideChannelRequest: any(named: 'hideChannelRequest'), + ), + ).thenAnswer((_) async => const Result.failure(error)); + final client = _client(defaultApi); + + final res = await client.hideChannel('general', 'messaging'); + + expect(res.exceptionOrNull(), error); + }); +} + +StreamChatClient _client(api.DefaultApi defaultApi) { + final client = StreamChatClient('test-api-key', defaultApi: defaultApi); + addTearDown(client.dispose); + return client; +} diff --git a/packages/stream_chat/test/src/client/client_show_channel_test.dart b/packages/stream_chat/test/src/client/client_show_channel_test.dart new file mode 100644 index 0000000000..78268f55a1 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_show_channel_test.dart @@ -0,0 +1,44 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/open_api/api.dart' as api; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; + +void main() { + test('StreamChatClient.showChannel sends the channel and returns the response', () async { + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.showChannel(type: 'messaging', id: 'general'), + ).thenAnswer((_) async => const Result.success(api.ShowChannelResponse(duration: '0.01ms'))); + final client = _client(defaultApi); + + final res = await client.showChannel('general', 'messaging'); + + expect(res.getOrNull(), const ShowChannelResponse(duration: '0.01ms')); + verify(() => defaultApi.showChannel(type: 'messaging', id: 'general')).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.showChannel returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.showChannel( + type: any(named: 'type'), + id: any(named: 'id'), + ), + ).thenAnswer((_) async => const Result.failure(error)); + final client = _client(defaultApi); + + final res = await client.showChannel('general', 'messaging'); + + expect(res.exceptionOrNull(), error); + }); +} + +StreamChatClient _client(api.DefaultApi defaultApi) { + final client = StreamChatClient('test-api-key', defaultApi: defaultApi); + addTearDown(client.dispose); + return client; +} diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 9cbc7bca99..a539108527 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -3349,48 +3349,6 @@ void main() { verifyNoMoreInteractions(fakeChatApi.general); }); - test('`.hideChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - - when(() => fakeChatApi.channel.hideChannel(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.hideChannel(channelId, channelType); - - expect(res, isNotNull); - - verify(() => fakeChatApi.channel.hideChannel(channelId, channelType)).called(1); - verifyNoMoreInteractions(fakeChatApi.channel); - }); - - test('`.showChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - - when(() => fakeChatApi.channel.showChannel(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.showChannel(channelId, channelType); - - expect(res, isNotNull); - - verify(() => fakeChatApi.channel.showChannel(channelId, channelType)).called(1); - verifyNoMoreInteractions(fakeChatApi.channel); - }); - - test('`.deleteChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - - when(() => fakeChatApi.channel.deleteChannel(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.deleteChannel(channelId, channelType); - - expect(res, isNotNull); - - verify(() => fakeChatApi.channel.deleteChannel(channelId, channelType)).called(1); - verifyNoMoreInteractions(fakeChatApi.channel); - }); - test('`.truncateChannel`', () async { const channelType = 'test-channel-type'; const channelId = 'test-channel-id'; diff --git a/packages/stream_chat/test/src/core/api/channel_api_test.dart b/packages/stream_chat/test/src/core/api/channel_api_test.dart index 7815a67a5b..b2923e0127 100644 --- a/packages/stream_chat/test/src/core/api/channel_api_test.dart +++ b/packages/stream_chat/test/src/core/api/channel_api_test.dart @@ -646,22 +646,6 @@ void main() { verifyNoMoreInteractions(client); }); - test('deleteChannel', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - - final path = _getChannelUrl(channelId, channelType); - - when(() => client.delete(path)).thenAnswer((_) async => successResponse(path, data: {})); - - final res = await channelApi.deleteChannel(channelId, channelType); - - expect(res, isNotNull); - - verify(() => client.delete(path)).called(1); - verifyNoMoreInteractions(client); - }); - test('truncateChannel', () async { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; @@ -688,82 +672,6 @@ void main() { verifyNoMoreInteractions(client); }); - test('hideChannel', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - - final path = '${_getChannelUrl(channelId, channelType)}/hide'; - - when( - () => client.post( - path, - data: { - 'clear_history': false, - }, - ), - ).thenAnswer((_) async => successResponse(path, data: {})); - - final res = await channelApi.hideChannel(channelId, channelType); - - expect(res, isNotNull); - - verify(() => client.post(path, data: any(named: 'data'))).called(1); - verifyNoMoreInteractions(client); - }); - - test('hideChannel with clear_history: true', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - - final path = '${_getChannelUrl(channelId, channelType)}/hide'; - - when( - () => client.post( - path, - data: { - 'clear_history': true, - }, - ), - ).thenAnswer((_) async => successResponse(path, data: {})); - - final res = await channelApi.hideChannel( - channelId, - channelType, - clearHistory: true, - ); - - expect(res, isNotNull); - - verify(() => client.post(path, data: any(named: 'data'))).called(1); - verifyNoMoreInteractions(client); - }); - - test('showChannel', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - - final path = '${_getChannelUrl(channelId, channelType)}/show'; - - when( - () => client.post( - path, - data: {}, - ), - ).thenAnswer((_) async => successResponse(path, data: {})); - - final res = await channelApi.showChannel(channelId, channelType); - - expect(res, isNotNull); - - verify( - () => client.post( - path, - data: {}, - ), - ).called(1); - verifyNoMoreInteractions(client); - }); - test('markRead', () async { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index af3403b6f9..72f56f3953 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -8,6 +8,7 @@ - The sort argument on every list controller is typed `List`, `List` and so on, rather than `SortOrder`. On the controllers that have a default, the `sort` property is non-nullable: leaving it unset selects that default, and `ChannelSort.empty` / `MemberSort.empty` / … query with the ordering the API applies on its own. - `defaultChannelListSort`, `defaultMemberListSort`, `defaultUserListSort`, `defaultDraftListSort`, `defaultMessageReminderListSort` and `defaultPollVoteListSort` are removed. Use `ChannelSort.defaultSort`, `MemberSort.defaultSort` and so on from `stream_chat`. Leaving a controller's sort unset still applies the same ordering. - `StreamChannelListController.muteChannel` and `unmuteChannel` return a `Result` instead of throwing. +- `StreamChannelListController.deleteChannel` returns a `Result` instead of throwing. - `StreamPollController.addOption` and `updateOption` take the option's custom data as `custom` instead of `extraData`. 🐞 Fixed diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart index aa7d81f943..b7145a63db 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart @@ -282,9 +282,7 @@ class StreamChannelListController extends PagedValueNotifier { } /// Deletes the [channel] and updates the list. - Future deleteChannel(Channel channel) async { - await channel.delete(); - } + Future> deleteChannel(Channel channel) => channel.delete(); /// Mutes the [channel] for the current user. /// diff --git a/packages/stream_chat_flutter_core/test/stream_channel_list_controller_test.dart b/packages/stream_chat_flutter_core/test/stream_channel_list_controller_test.dart index 0486a58d14..7d0eddf0b1 100644 --- a/packages/stream_chat_flutter_core/test/stream_channel_list_controller_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_channel_list_controller_test.dart @@ -447,7 +447,18 @@ void main() { expect(controller.value.isSuccess, isTrue); }); - test('muteChannel passes on the failure instead of throwing', () async { + test('deleteChannel returns the failure from Channel.delete', () async { + const error = StreamClientException(message: 'boom'); + final channel = MockChannel(); + when(channel.delete).thenAnswer((_) async => const Result.failure(error)); + + final controller = StreamChannelListController(client: client); + final res = await controller.deleteChannel(channel); + + expect(res.exceptionOrNull(), error); + }); + + test('muteChannel returns the failure from Channel.mute', () async { const error = StreamClientException(message: 'boom'); final channel = MockChannel(); when(channel.mute).thenAnswer((_) async => const Result.failure(error)); @@ -459,7 +470,7 @@ void main() { expect(res.exceptionOrNull(), error); }); - test('unmuteChannel passes on the failure instead of throwing', () async { + test('unmuteChannel returns the failure from Channel.unmute', () async { const error = StreamClientException(message: 'boom'); final channel = MockChannel(); when(channel.unmute).thenAnswer((_) async => const Result.failure(error)); diff --git a/sample_app/lib/pages/chat_info_screen.dart b/sample_app/lib/pages/chat_info_screen.dart index 5e66d7777c..8d581f39cc 100644 --- a/sample_app/lib/pages/chat_info_screen.dart +++ b/sample_app/lib/pages/chat_info_screen.dart @@ -231,7 +231,9 @@ class _ActionsSection extends StatelessWidget { ); if (confirmed != true) return; - await channel.delete(); + final result = await channel.delete(); + if (result.isFailure) return; + // Pop every screen until we land on the channel list — going back to // the channel page would crash trying to read state from the now // deleted channel. diff --git a/sample_app/lib/pages/group_info_screen.dart b/sample_app/lib/pages/group_info_screen.dart index 06b905614f..843fd2760c 100644 --- a/sample_app/lib/pages/group_info_screen.dart +++ b/sample_app/lib/pages/group_info_screen.dart @@ -357,7 +357,9 @@ class _ActionsSection extends StatelessWidget { ); if (confirmed != true) return; - await channel.delete(); + final result = await channel.delete(); + if (result.isFailure) return; + // Pop every screen until we land on the channel list — going back to // the channel page would crash trying to read state from the now // deleted channel. diff --git a/sample_app/lib/widgets/channel_list.dart b/sample_app/lib/widgets/channel_list.dart index 8b9ee6f112..e98daacb25 100644 --- a/sample_app/lib/widgets/channel_list.dart +++ b/sample_app/lib/widgets/channel_list.dart @@ -282,7 +282,10 @@ Future _maybeDeleteChannel(BuildContext context, Channel channel) async { ); if (confirmed != true) return; - await channel.delete(); + + final result = await channel.delete(); + if (result.isFailure) return; + if (router.canPop()) router.pop(); }