From 876e8270b91ea4eab1bc82a43e934cb1f4e69f7d Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 6 Oct 2026 08:56:24 +0200 Subject: [PATCH 1/5] refactor(llc, core)!: route hide, show and delete channel through the generated client Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 40 ++++++++ openapi-migration/11-channels-and-members.md | 11 +-- openapi-migration/16-channel-lifecycle.md | 69 ++++++++++++++ openapi-migration/README.md | 8 +- openapi-migration/tool/generate_plan.py | 52 ++++++++++- packages/stream_chat/CHANGELOG.md | 2 + .../lib/src/client/channel/channel.dart | 16 ++-- .../stream_chat/lib/src/client/client.dart | 25 ++--- .../lib/src/core/api/channel_api.dart | 38 -------- .../response/delete_channel_response.dart | 23 +++++ .../delete_channel_response.freezed.dart | 82 +++++++++++++++++ .../response/hide_channel_response.dart | 16 ++++ .../hide_channel_response.freezed.dart | 76 +++++++++++++++ .../response/show_channel_response.dart | 16 ++++ .../show_channel_response.freezed.dart | 76 +++++++++++++++ .../src/repository/channels_repository.dart | 40 ++++++++ .../src/repository/mapper/channel_mapper.dart | 24 +++++ packages/stream_chat/lib/stream_chat.dart | 3 + .../test/src/client/channel/channel_test.dart | 24 +++-- .../client/client_delete_channel_test.dart | 77 ++++++++++++++++ .../src/client/client_hide_channel_test.dart | 65 +++++++++++++ .../src/client/client_show_channel_test.dart | 44 +++++++++ .../test/src/client/client_test.dart | 42 --------- .../test/src/core/api/channel_api_test.dart | 92 ------------------- .../stream_chat_flutter_core/CHANGELOG.md | 1 + .../src/stream_channel_list_controller.dart | 4 +- .../stream_channel_list_controller_test.dart | 12 +++ sample_app/lib/pages/chat_info_screen.dart | 8 +- sample_app/lib/pages/group_info_screen.dart | 8 +- sample_app/lib/widgets/channel_list.dart | 9 +- 30 files changed, 784 insertions(+), 219 deletions(-) create mode 100644 openapi-migration/16-channel-lifecycle.md create mode 100644 packages/stream_chat/lib/src/core/models/response/delete_channel_response.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/delete_channel_response.freezed.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/hide_channel_response.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/hide_channel_response.freezed.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/show_channel_response.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/show_channel_response.freezed.dart create mode 100644 packages/stream_chat/test/src/client/client_delete_channel_test.dart create mode 100644 packages/stream_chat/test/src/client/client_hide_channel_test.dart create mode 100644 packages/stream_chat/test/src/client/client_show_channel_test.dart diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index 47af5489f9..8ea6820ae0 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -34,6 +34,7 @@ onto Stream's OpenAPI-generated API client. - [User Groups](#user-groups) - [Link Previews](#link-previews) - [Partial Updates](#partial-updates) + - [Channel Lifecycle](#channel-lifecycle) - [Migration Checklist](#migration-checklist) - [For AI Agents](#for-ai-agents) - [Contributing to this guide](#contributing-to-this-guide) @@ -80,6 +81,7 @@ from the spec, so don't subclass them or depend on their private constructors. | [**Guest Users**](#guest-users) | `connectGuestUser` is unchanged; `StreamChatApi.guest` and `ConnectGuestUserResponse` are removed | | [**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 | | _(filled in per feature as PRs land)_ | | --- @@ -104,6 +106,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 | @@ -237,6 +240,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` | | _(more added per feature as PRs land)_ | | | | @@ -693,6 +699,40 @@ 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. Stopping watching — +`StreamChatClient.stopChannelWatching` and `Channel.stopWatching` — has not moved yet and still throws. + +```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. + ### Moderation **Muting, banning and flagging return a `Result` instead of throwing**, on both `StreamChatClient` diff --git a/openapi-migration/11-channels-and-members.md b/openapi-migration/11-channels-and-members.md index dbc1f7adb5..de3f253616 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` | @@ -103,6 +97,7 @@ ## Risks +- **The generated `stopWatchingChannel` cannot be called as generated.** It sends no body, while the spec declares a required empty-object one and the handler refuses a missing body with `400 invalid json data`. Fix the generator to send `{}` for a required empty body and regenerate before moving it; [16](16-channel-lifecycle.md) left it here for that reason. - `sync` and `queryMembers` live in `general_api.dart`, not `channel_api.dart` — this group reaches into that file. - `queryChannels` drives the channel list controllers and the offline cache; a shape change here is felt everywhere. - Channel `custom`/`extraData` promotion, same class of problem as messages. diff --git a/openapi-migration/16-channel-lifecycle.md b/openapi-migration/16-channel-lifecycle.md new file mode 100644 index 0000000000..89b82dc832 --- /dev/null +++ b/openapi-migration/16-channel-lifecycle.md @@ -0,0 +1,69 @@ +# 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 `channel_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. +- **Stopping watching stays in 11, hand-written.** The spec declares a required, empty-object body for + `stop-watching`, but the generated `stopWatchingChannel` sends none, and the handler's + `ParseAndValidateRequest` refuses a missing body with `400 invalid json data` on v1 and v2 alike. + It moves once the generator emits the body; it is not paired with the other three, so they move now. +- **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 + +- Until stopping watching moves, `stopChannelWatching` and `Channel.stopWatching` still throw while the other channel writes here return a `Result`; `migrations/v11-migration.md` says so. + +## 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] `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 abfe460f4d..7ae6bb41dd 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -22,13 +22,14 @@ 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 | ☑ | -**Coverage:** 80 hand-written methods across 10 files, and all 129 generated operations, each claimed by exactly +**Coverage:** 77 hand-written methods across 10 files, and all 129 generated operations, each claimed by exactly one group. Verified mechanically — see [Keeping this plan honest](#keeping-this-plan-honest). @@ -170,6 +171,9 @@ surfaces before it reaches `Message` and `ChannelState`: - **10–11** are the core of the SDK, and carry the `custom` / `extraData` promotion problem. - **15** is the partial channel and member updates, split out of 11 so `channel_mapper.dart` lands on calls that read nothing into `ChannelState`. It landed first; the full update waits in 11 for group 10's message mappers. +- **16** is hiding, showing and deleting a channel, split out of 11 after 15 because they answer nothing + `channel_mapper.dart` cannot map. Stopping watching waits in 11 for a generator fix, and the read and delivery + receipts 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 cf4ef9b662..a2929a1b92 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -587,9 +587,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.', @@ -622,6 +626,10 @@ def match(verb, path): 'Verify it decodes before relying on it.', ], risks=[ + '**The generated `stopWatchingChannel` cannot be called as generated.** It sends no body, while the ' + 'spec declares a required empty-object one and the handler refuses a missing body with `400 invalid ' + 'json data`. Fix the generator to send `{}` for a required empty body and regenerate before moving it; ' + '[16](16-channel-lifecycle.md) left it here for that reason.', '`sync` and `queryMembers` live in `general_api.dart`, not `channel_api.dart` — this group reaches ' 'into that file.', '`queryChannels` drives the channel list controllers and the offline cache; a shape change here is ' @@ -832,6 +840,46 @@ 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 `channel_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. + - **Stopping watching stays in 11, hand-written.** The spec declares a required, empty-object body for + `stop-watching`, but the generated `stopWatchingChannel` sends none, and the handler's + `ParseAndValidateRequest` refuses a missing body with `400 invalid json data` on v1 and v2 alike. + It moves once the generator emits the body; it is not paired with the other three, so they move now. + - **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=[ + 'Until stopping watching moves, `stopChannelWatching` and `Channel.stopWatching` still throw while the ' + 'other channel writes here return a `Result`; `migrations/v11-migration.md` says so.', + ], + done=DONE.replace('- [ ]', '- [x]'), + ), ] diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 745a17d133..d42f270420 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -91,6 +91,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. 🐞 Fixed diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index 927670bdd1..297881012d 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -1731,8 +1731,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); } @@ -2314,12 +2314,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!, @@ -2328,8 +2326,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 49d2ff4e92..d1e4f183af 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -63,13 +63,16 @@ 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'; 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'; @@ -1440,33 +1443,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 3bf0d558fe..1028e3366b 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/channel_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/channel_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/channel_mapper.dart index 0010f37353..e804cb6385 100644 --- a/packages/stream_chat/lib/src/repository/mapper/channel_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/channel_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'; @@ -185,3 +188,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 4f5e10febe..14cbd62b5e 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'; @@ -132,6 +134,7 @@ export 'src/core/models/response/og_attachment_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 03f7f11d42..3c20a0d7fa 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -3833,12 +3833,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); }); @@ -4832,8 +4836,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( @@ -4841,11 +4846,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( @@ -4856,12 +4861,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 be8da5fc1c..bbbce8b14f 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -2748,48 +2748,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 a3edda1866..bf8933dc5b 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 eb58ab35c6..84be3182e8 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. 🐞 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..4bd43ee3f8 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,6 +447,18 @@ void main() { expect(controller.value.isSuccess, isTrue); }); + test('deleteChannel passes on the failure instead of throwing', () 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.isFailure, isTrue); + expect(res.exceptionOrNull(), error); + }); + test('muteChannel passes on the failure instead of throwing', () async { const error = StreamClientException(message: 'boom'); final channel = MockChannel(); diff --git a/sample_app/lib/pages/chat_info_screen.dart b/sample_app/lib/pages/chat_info_screen.dart index 5e66d7777c..96ca608977 100644 --- a/sample_app/lib/pages/chat_info_screen.dart +++ b/sample_app/lib/pages/chat_info_screen.dart @@ -221,6 +221,7 @@ class _ActionsSection extends StatelessWidget { Future _confirmDelete(BuildContext context) async { final navigator = Navigator.of(context); + final messenger = ScaffoldMessenger.of(context); final channel = StreamChannel.of(context).channel; final confirmed = await _showConfirmationDialog( @@ -231,7 +232,12 @@ class _ActionsSection extends StatelessWidget { ); if (confirmed != true) return; - await channel.delete(); + final result = await channel.delete(); + if (result case Failure(:final error)) { + messenger.showSnackBar(SnackBar(content: Text('Failed to delete: $error'))); + 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..2afd81fb03 100644 --- a/sample_app/lib/pages/group_info_screen.dart +++ b/sample_app/lib/pages/group_info_screen.dart @@ -347,6 +347,7 @@ class _ActionsSection extends StatelessWidget { Future _confirmDelete(BuildContext context) async { final navigator = Navigator.of(context); + final messenger = ScaffoldMessenger.of(context); final channel = StreamChannel.of(context).channel; final confirmed = await _showConfirmationDialog( @@ -357,7 +358,12 @@ class _ActionsSection extends StatelessWidget { ); if (confirmed != true) return; - await channel.delete(); + final result = await channel.delete(); + if (result case Failure(:final error)) { + messenger.showSnackBar(SnackBar(content: Text('Failed to delete: $error'))); + 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..c002a5a0f7 100644 --- a/sample_app/lib/widgets/channel_list.dart +++ b/sample_app/lib/widgets/channel_list.dart @@ -272,6 +272,7 @@ Future _maybeLeaveChannel(BuildContext context, Channel channel) async { // channel route). Future _maybeDeleteChannel(BuildContext context, Channel channel) async { final router = GoRouter.of(context); + final messenger = ScaffoldMessenger.of(context); final subject = channel.isOneToOne ? 'conversation' : 'group'; final confirmed = await _showConfirmationDialog( @@ -282,7 +283,13 @@ Future _maybeDeleteChannel(BuildContext context, Channel channel) async { ); if (confirmed != true) return; - await channel.delete(); + + final result = await channel.delete(); + if (result case Failure(:final error)) { + messenger.showSnackBar(SnackBar(content: Text('Failed to delete: $error'))); + return; + } + if (router.canPop()) router.pop(); } From 1879f2f4b0cf017e330346c53c5524de7ca56d52 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 6 Oct 2026 08:58:24 +0200 Subject: [PATCH 2/5] docs: drop the stop-watching notes from the group 16 docs Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 3 +-- openapi-migration/11-channels-and-members.md | 1 - openapi-migration/16-channel-lifecycle.md | 5 ----- openapi-migration/README.md | 3 +-- openapi-migration/tool/generate_plan.py | 13 +------------ 5 files changed, 3 insertions(+), 22 deletions(-) diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index 8ea6820ae0..1b04bde91d 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -704,8 +704,7 @@ directly — `updateMemberPartial(channelId: …, channelType: …, set: {'pinne **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. Stopping watching — -`StreamChatClient.stopChannelWatching` and `Channel.stopWatching` — has not moved yet and still throws. +compiles, but no longer catches a failed call: read the returned `Result` instead. ```dart // v10 diff --git a/openapi-migration/11-channels-and-members.md b/openapi-migration/11-channels-and-members.md index de3f253616..cbfda809cb 100644 --- a/openapi-migration/11-channels-and-members.md +++ b/openapi-migration/11-channels-and-members.md @@ -97,7 +97,6 @@ ## Risks -- **The generated `stopWatchingChannel` cannot be called as generated.** It sends no body, while the spec declares a required empty-object one and the handler refuses a missing body with `400 invalid json data`. Fix the generator to send `{}` for a required empty body and regenerate before moving it; [16](16-channel-lifecycle.md) left it here for that reason. - `sync` and `queryMembers` live in `general_api.dart`, not `channel_api.dart` — this group reaches into that file. - `queryChannels` drives the channel list controllers and the offline cache; a shape change here is felt everywhere. - Channel `custom`/`extraData` promotion, same class of problem as messages. diff --git a/openapi-migration/16-channel-lifecycle.md b/openapi-migration/16-channel-lifecycle.md index 89b82dc832..16500045a7 100644 --- a/openapi-migration/16-channel-lifecycle.md +++ b/openapi-migration/16-channel-lifecycle.md @@ -37,10 +37,6 @@ 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. -- **Stopping watching stays in 11, hand-written.** The spec declares a required, empty-object body for - `stop-watching`, but the generated `stopWatchingChannel` sends none, and the handler's - `ParseAndValidateRequest` refuses a missing body with `400 invalid json data` on v1 and v2 alike. - It moves once the generator emits the body; it is not paired with the other three, so they move now. - **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 @@ -49,7 +45,6 @@ ## Risks -- Until stopping watching moves, `stopChannelWatching` and `Channel.stopWatching` still throw while the other channel writes here return a `Result`; `migrations/v11-migration.md` says so. ## Definition of done diff --git a/openapi-migration/README.md b/openapi-migration/README.md index 7ae6bb41dd..7175c8f2ee 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -172,8 +172,7 @@ surfaces before it reaches `Message` and `ChannelState`: - **15** is the partial channel and member updates, split out of 11 so `channel_mapper.dart` lands on calls that read nothing into `ChannelState`. It landed first; the full update waits in 11 for group 10's message mappers. - **16** is hiding, showing and deleting a channel, split out of 11 after 15 because they answer nothing - `channel_mapper.dart` cannot map. Stopping watching waits in 11 for a generator fix, and the read and delivery - receipts for a slice of their own. + `channel_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 a2929a1b92..9df2879b35 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -626,10 +626,6 @@ def match(verb, path): 'Verify it decodes before relying on it.', ], risks=[ - '**The generated `stopWatchingChannel` cannot be called as generated.** It sends no body, while the ' - 'spec declares a required empty-object one and the handler refuses a missing body with `400 invalid ' - 'json data`. Fix the generator to send `{}` for a required empty body and regenerate before moving it; ' - '[16](16-channel-lifecycle.md) left it here for that reason.', '`sync` and `queryMembers` live in `general_api.dart`, not `channel_api.dart` — this group reaches ' 'into that file.', '`queryChannels` drives the channel list controllers and the offline cache; a shape change here is ' @@ -864,20 +860,13 @@ def match(verb, path): 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. - - **Stopping watching stays in 11, hand-written.** The spec declares a required, empty-object body for - `stop-watching`, but the generated `stopWatchingChannel` sends none, and the handler's - `ParseAndValidateRequest` refuses a missing body with `400 invalid json data` on v1 and v2 alike. - It moves once the generator emits the body; it is not paired with the other three, so they move now. - **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=[ - 'Until stopping watching moves, `stopChannelWatching` and `Channel.stopWatching` still throw while the ' - 'other channel writes here return a `Result`; `migrations/v11-migration.md` says so.', - ], + risks=[], done=DONE.replace('- [ ]', '- [x]'), ), ] From 0b57ae5d6fbe6ec4c29c0934061ee259e52a75b1 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 6 Oct 2026 09:07:33 +0200 Subject: [PATCH 3/5] chore(sample): stay on the screen without a snackbar when a channel delete fails Co-Authored-By: Claude Opus 5.5 --- sample_app/lib/pages/chat_info_screen.dart | 6 +----- sample_app/lib/pages/group_info_screen.dart | 6 +----- sample_app/lib/widgets/channel_list.dart | 6 +----- 3 files changed, 3 insertions(+), 15 deletions(-) diff --git a/sample_app/lib/pages/chat_info_screen.dart b/sample_app/lib/pages/chat_info_screen.dart index 96ca608977..8d581f39cc 100644 --- a/sample_app/lib/pages/chat_info_screen.dart +++ b/sample_app/lib/pages/chat_info_screen.dart @@ -221,7 +221,6 @@ class _ActionsSection extends StatelessWidget { Future _confirmDelete(BuildContext context) async { final navigator = Navigator.of(context); - final messenger = ScaffoldMessenger.of(context); final channel = StreamChannel.of(context).channel; final confirmed = await _showConfirmationDialog( @@ -233,10 +232,7 @@ class _ActionsSection extends StatelessWidget { if (confirmed != true) return; final result = await channel.delete(); - if (result case Failure(:final error)) { - messenger.showSnackBar(SnackBar(content: Text('Failed to delete: $error'))); - return; - } + 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 diff --git a/sample_app/lib/pages/group_info_screen.dart b/sample_app/lib/pages/group_info_screen.dart index 2afd81fb03..843fd2760c 100644 --- a/sample_app/lib/pages/group_info_screen.dart +++ b/sample_app/lib/pages/group_info_screen.dart @@ -347,7 +347,6 @@ class _ActionsSection extends StatelessWidget { Future _confirmDelete(BuildContext context) async { final navigator = Navigator.of(context); - final messenger = ScaffoldMessenger.of(context); final channel = StreamChannel.of(context).channel; final confirmed = await _showConfirmationDialog( @@ -359,10 +358,7 @@ class _ActionsSection extends StatelessWidget { if (confirmed != true) return; final result = await channel.delete(); - if (result case Failure(:final error)) { - messenger.showSnackBar(SnackBar(content: Text('Failed to delete: $error'))); - return; - } + 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 diff --git a/sample_app/lib/widgets/channel_list.dart b/sample_app/lib/widgets/channel_list.dart index c002a5a0f7..e98daacb25 100644 --- a/sample_app/lib/widgets/channel_list.dart +++ b/sample_app/lib/widgets/channel_list.dart @@ -272,7 +272,6 @@ Future _maybeLeaveChannel(BuildContext context, Channel channel) async { // channel route). Future _maybeDeleteChannel(BuildContext context, Channel channel) async { final router = GoRouter.of(context); - final messenger = ScaffoldMessenger.of(context); final subject = channel.isOneToOne ? 'conversation' : 'group'; final confirmed = await _showConfirmationDialog( @@ -285,10 +284,7 @@ Future _maybeDeleteChannel(BuildContext context, Channel channel) async { if (confirmed != true) return; final result = await channel.delete(); - if (result case Failure(:final error)) { - messenger.showSnackBar(SnackBar(content: Text('Failed to delete: $error'))); - return; - } + if (result.isFailure) return; if (router.canPop()) router.pop(); } From b0e14d7572364db7293c59c4f432e686a6bea4f6 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 6 Oct 2026 09:07:33 +0200 Subject: [PATCH 4/5] test(core): name the controller failure tests after the channel method they pass on Co-Authored-By: Claude Opus 5.5 --- .../test/stream_channel_list_controller_test.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 4bd43ee3f8..5a7fb57bd9 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,7 @@ void main() { expect(controller.value.isSuccess, isTrue); }); - test('deleteChannel 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)); @@ -459,7 +459,7 @@ void main() { expect(res.exceptionOrNull(), error); }); - test('muteChannel passes on the failure instead of throwing', () async { + 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)); @@ -471,7 +471,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)); From 5bf02fc955301f0222a8721bb4e782713ba3cedc Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 13 Oct 2026 10:55:59 +0200 Subject: [PATCH 5/5] test(core): drop the redundant isFailure check from the deleteChannel controller test Co-Authored-By: Claude Opus 5.5 --- .../test/stream_channel_list_controller_test.dart | 1 - 1 file changed, 1 deletion(-) 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 5a7fb57bd9..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 @@ -455,7 +455,6 @@ void main() { final controller = StreamChannelListController(client: client); final res = await controller.deleteChannel(channel); - expect(res.isFailure, isTrue); expect(res.exceptionOrNull(), error); });