diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index 469d08b350..a03cf53153 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -38,6 +38,7 @@ onto Stream's OpenAPI-generated API client. - [Channel Lifecycle](#channel-lifecycle) - [Read Receipts](#read-receipts) - [Unread Counts](#unread-counts) + - [User Blocking](#user-blocking) - [Channel and Member Custom Data](#channel-and-member-custom-data) - [Migration Checklist](#migration-checklist) - [For AI Agents](#for-ai-agents) @@ -89,6 +90,7 @@ from the spec, so don't subclass them or depend on their private constructors. | [**Channel Lifecycle**](#channel-lifecycle) | Hiding, showing and deleting a channel return a `Result` instead of throwing; stopping watching still throws | | [**Read Receipts**](#read-receipts) | Marking read, unread and delivered return a `Result` instead of throwing; `ChannelDeliveryReporter`'s callback returns a `Result` | | [**Unread Counts**](#unread-counts) | `getUnreadCount` returns a `Result` instead of throwing; the response and its `UnreadCounts*` models no longer decode JSON and compare by value | +| [**User Blocking**](#user-blocking) | `blockUser`, `unblockUser` and `getBlockedUsers` (was `queryBlockedUsers`) return a `Result` instead of throwing; their responses are renamed `BlockUsersResponse` and `GetBlockedUsersResponse`, `unblockUser` answers a new `UnblockUsersResponse`, and `UserBlock`'s fields are all non-nullable | | [**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)_ | | @@ -287,6 +289,20 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `GetUnreadCountResponse`, `UnreadCountsChannel`, `UnreadCountsThread`, `UnreadCountsChannelType` identity `==` | value `==`, plus `copyWith` | `retyped` | Two instances with the same fields are now equal | | `GetUnreadCountResponse.duration` (`String?`) | `String` | `retyped` | Always present; drop any `!` or `?? ''` | | `StreamChatApi.user.getUnreadCount` | `StreamChatClient.getUnreadCount` | `removed` | The endpoint moved to the generated client | +| `StreamChatClient.blockUser` → `Future` | `Future>` | `retyped` | Returns a `Result` instead of throwing. The blocked user is still added to `OwnUser.blockedUserIds` on success | +| `StreamChatClient.unblockUser` → `Future` | `Future>` | `retyped` | Returns a `Result` instead of throwing. The user is still removed from `OwnUser.blockedUserIds` on success | +| `StreamChatClient.queryBlockedUsers` | `StreamChatClient.getBlockedUsers` | `renamed` | Same call, the API's name | +| `StreamChatClient.queryBlockedUsers` → `Future` | `Future>` | `retyped` | Returns a `Result` instead of throwing. `OwnUser.blockedUserIds` is still replaced on success | +| `UserBlockResponse` | `BlockUsersResponse` | `renamed` | Same fields | +| `BlockedUsersResponse` | `GetBlockedUsersResponse` | `renamed` | Same fields | +| `UserBlockResponse.fromJson`, `BlockedUsersResponse.fromJson`, `UserBlock.fromJson` / `toJson` | — | `removed` | The responses and model are plain classes; construct them directly | +| `UserBlockResponse()..blockedUserId = …`, `BlockedUsersResponse()..blocks = …` and their other setters | `BlockUsersResponse(duration: …, blockedUserId: …, …)`, `GetBlockedUsersResponse(duration: …, blocks: …)` | `retyped` | Plain classes with a const constructor and final fields | +| `UserBlockResponse`, `BlockedUsersResponse` identity `==` | value `==`, plus `copyWith` | `retyped` | Two instances with the same fields are now equal | +| `UserBlockResponse.duration`, `BlockedUsersResponse.duration` (`String?`) | `String` | `retyped` | Always present; drop any `!` or `?? ''` | +| `UserBlock extends Equatable`, `UserBlock.props` | `UserBlock` (value `==`, `copyWith`) | `removed` | Equality is unchanged; `props` is gone and `UserBlock` is no longer an `Equatable` | +| `UserBlock.blockedUser` (`User?`), `.userId` / `.blockedUserId` (`String?`), `.createdAt` (`DateTime?`) | `User`, `String`, `DateTime` — required in the constructor | `retyped` | The server always sends them; drop any `!`, `?.` or `?? …` | +| `UserBlock.copyWith(user: User?, …)`, keeping a field when passed `null` | `copyWith(user: User, …)` | `retyped` | Every argument is non-nullable, so a call that passed a nullable value no longer compiles; pass the value or leave the argument out | +| `StreamChatApi.user.blockUser` / `unblockUser` / `queryBlockedUsers` | `StreamChatClient.blockUser` / `unblockUser` / `getBlockedUsers` | `removed` | The endpoints moved to the generated client | | `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 | | `channel.extraData['disabled']` / `['hidden']` / `['muted']` / `['blocked']` / `['truncated_at']` / `['truncated_by']` / `['auto_translation_enabled']` / `['auto_translation_language']` | `ChannelModel.disabled` / `hidden` / `muted` / `blocked` / `truncatedAt` / `truncatedBy` / `autoTranslationEnabled` / `autoTranslationLanguage` | `retyped` | Fields now, not custom data; `copyWith(custom:)` no longer sets them | @@ -909,6 +925,47 @@ always present. > **Why:** the endpoint moved onto the generated client, which returns a `Result` for every call. The response > keeps its v10 name and fields; what changes is the error handling, the JSON codecs and value equality. +### User Blocking + +**`blockUser`, `unblockUser` and `getBlockedUsers` return a `Result` instead of throwing, and `queryBlockedUsers` +is renamed `getBlockedUsers`.** They answer `BlockUsersResponse` (renamed from `UserBlockResponse`), +`UnblockUsersResponse` (new; `unblockUser` returned an `EmptyResponse`) and `GetBlockedUsersResponse` (renamed from +`BlockedUsersResponse`). A `try`/`catch` around them still compiles, but no longer catches a failed call: read the +returned `Result` instead. On success they still update the current user's `blockedUserIds`; a failure leaves it as +it was. + +```dart +// v10 +try { + final response = await client.queryBlockedUsers(); + showBlocked(response.blocks.map((block) => block.blockedUser!)); +} on StreamChatException catch (e) { + report(e); +} + +// v11 +final result = await client.getBlockedUsers(); +switch (result) { + case Success(:final data): showBlocked(data.blocks.map((block) => block.blockedUser)); + case Failure(:final error): report(error); +} +``` + +**`UserBlock.blockedUser`, `userId`, `blockedUserId` and `createdAt` are non-nullable** and required in the +constructor; drop any `!`, `?.` or `?? …` on them. `UserBlock` no longer decodes from or encodes to JSON and no +longer extends `Equatable`; it still compares by value and keeps `copyWith`. `BlockUsersResponse` and +`GetBlockedUsersResponse` no longer decode from JSON, are plain classes with a const constructor, compare by value +and gain `copyWith`, and their `duration` is always present. + +**`StreamChatApi.user.blockUser`, `unblockUser` and `queryBlockedUsers` are removed.** Call `blockUser`, +`unblockUser` and `getBlockedUsers` on `StreamChatClient`. + +> **Why:** the endpoints moved onto the generated client, which returns a `Result` for every call. `blockUser` and +> `unblockUser` keep their names, because each takes one user id, and answer the API's responses under the API's +> names. `queryBlockedUsers` becomes `getBlockedUsers`, the API's name, because the call takes no filter, sort or +> pagination. The server always sends every `UserBlock` field, so the model no longer makes callers handle nulls +> that never arrive. + ### Moderation **Muting, banning and flagging return a `Result` instead of throwing**, on both `StreamChatClient` diff --git a/openapi-migration/09-users.md b/openapi-migration/09-users.md index 5f6189bd1f..974f006662 100644 --- a/openapi-migration/09-users.md +++ b/openapi-migration/09-users.md @@ -1,8 +1,8 @@ # 09 — Users -**Goal:** `User` is the most widely referenced public model in the SDK; this is where keep-vs-adopt costs the most. [18](18-unread-counts.md) split the current user's unread counts out of it. +**Goal:** `User` is the most widely referenced public model in the SDK; this is where keep-vs-adopt costs the most. [18](18-unread-counts.md) split the current user's unread counts out of it, and [19](19-user-blocking.md) blocking users. -**Size:** 8 hand-written method(s) across 1 file(s) → 8 generated operation(s). +**Size:** 5 hand-written method(s) across 1 file(s) → 5 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. @@ -16,9 +16,6 @@ | `user_api.dart` | `queryUsers` | `QueryUsersResponse` | | `user_api.dart` | `updateUsers` | `UpdateUsersResponse` | | `user_api.dart` | `partialUpdateUsers` | `UpdateUsersResponse` | -| `user_api.dart` | `blockUser` | `UserBlockResponse` | -| `user_api.dart` | `unblockUser` | `EmptyResponse` | -| `user_api.dart` | `queryBlockedUsers` | `BlockedUsersResponse` | | `user_api.dart` | `getActiveLiveLocations` | `GetActiveLiveLocationsResponse` | | `user_api.dart` | `updateLiveLocation` | `Location` | @@ -26,11 +23,8 @@ | Verb | Path | Operation | Response | | --- | --- | --- | --- | -| `POST` | `/api/v2/users/block` | `blockUsers` | `BlockUsersResponse` | -| `GET` | `/api/v2/users/block` | `getBlockedUsers` | `GetBlockedUsersResponse` | | `GET` | `/api/v2/users/live_locations` | `getUserLiveLocations` | `SharedLocationsResponse` | | `GET` | `/api/v2/users` | `queryUsers` | `QueryUsersResponse` | -| `POST` | `/api/v2/users/unblock` | `unblockUsers` | `UnblockUsersResponse` | | `PUT` | `/api/v2/users/live_locations` | `updateLiveLocation` | `SharedLocationResponse` | | `POST` | `/api/v2/users` | `updateUsers` | `UpdateUsersResponse` | | `PATCH` | `/api/v2/users` | `updateUsersPartial` | `UpdateUsersResponse` | diff --git a/openapi-migration/19-user-blocking.md b/openapi-migration/19-user-blocking.md new file mode 100644 index 0000000000..e8a2bcdc47 --- /dev/null +++ b/openapi-migration/19-user-blocking.md @@ -0,0 +1,69 @@ +# 19 — User Blocking + +**Goal:** Move blocking and unblocking a user, and listing the blocked users, ahead of [09](09-users.md): they embed `User` only through the existing `user_mapper.dart`, and nothing persists them. + +**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 | +| --- | --- | --- | --- | +| `POST` | `/api/v2/users/block` | `blockUsers` | `BlockUsersResponse` | +| `GET` | `/api/v2/users/block` | `getBlockedUsers` | `GetBlockedUsersResponse` | +| `POST` | `/api/v2/users/unblock` | `unblockUsers` | `UnblockUsersResponse` | + +## Decisions taken + +- **Split out of [09](09-users.md), ahead of it, as one slice.** Blocking and unblocking are a pair, + and the list refreshes the same `OwnUser.blockedUserIds`. No WebSocket or persisted model embeds + `UserBlock`, so it needs no converter and no storage codec. +- **The v2 routes are the v1 handlers.** `lib/chat/routes.go` mounts one `userBlockRoutes` table + (`v1.BlockUsers`, `v1.UnblockUsers`, `v1.GetBlockedUsers`) both at the root and under `/api/v2/`; + only the JSON encoding differs. None is gated, in beta or deprecated. +- **Moved off `UserApi`:** `blockUser`, `unblockUser` and `queryBlockedUsers`, now `StreamChatClient` + methods over `UsersRepository`. +- **`blockUser` and `unblockUser` keep their names,** rather than the spec's `blockUsers` and + `unblockUsers`: each takes one user id, and a method that takes several would be added beside them. +- **Their envelopes take the spec's plural names,** `BlockUsersResponse` (renamed from + `UserBlockResponse`) and `UnblockUsersResponse` (new; v10 returned `EmptyResponse`, but the generated + response is a named one). If the API starts blocking several users in one call, these responses gain + fields without a rename, and a batch method can return the same envelope. +- **`queryBlockedUsers` is renamed `getBlockedUsers`,** answering `GetBlockedUsersResponse` (renamed + from `BlockedUsersResponse`), as the spec names them: the call takes no filter, sort or pagination. +- **`UserBlock` is a freezed plain model with every field non-nullable,** following the wire rather + than v10, which typed `blockedUser`, `userId`, `blockedUserId` and `createdAt` nullable. The + server always sends them; the break is approved for this group. +- **`OwnUser.blockedUserIds` is updated only on success,** through `Result.onSuccess`, as v10 did by + throwing before it reached the update. Failures are still logged, through `Result.onFailure`. + +## 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 945e27aab0..6d2ee1abff 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -20,7 +20,7 @@ generated operations in scope, the decisions that group has to make, its risks, | [06](06-reminders.md) | Message Reminders | 4 | 4 | ☐ | | [07](07-threads-and-drafts.md) | Threads & Drafts | 7 | 7 | ☐ | | [08](08-moderation-and-blocklists.md) | Moderation & Blocklists | 0 | 34 | ☑ | -| [09](09-users.md) | Users | 8 | 8 | ☐ | +| [09](09-users.md) | Users | 5 | 5 | ☐ | | [10](10-messages.md) | Messages & Search | 14 | 12 | ☐ | | [11](11-channels-and-members.md) | Channels, Members & Sync | 13 | 15 | ☐ | | [12](12-uploads-cdn.md) | Uploads (CDN) | 8 | 8 | ☑ | @@ -30,9 +30,10 @@ generated operations in scope, the decisions that group has to make, its risks, | [16](16-channel-lifecycle.md) | Channel Lifecycle — split out of 11 | 0 | 3 | ☑ | | [17](17-read-receipts.md) | Read Receipts — split out of 11 | 0 | 4 | ☑ | | [18](18-unread-counts.md) | Unread Counts — split out of 09 | 0 | 1 | ☑ | +| [19](19-user-blocking.md) | User Blocking — split out of 09 | 0 | 3 | ☑ | | [21](21-custom-data.md) | Custom Data Check — no model still says `extraData` | — | — | ☐ | -**Coverage:** 56 hand-written methods across 9 files, and all 129 generated operations, each claimed by exactly +**Coverage:** 53 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). @@ -204,6 +205,8 @@ surfaces before it reaches `Message` and `ChannelState`: - **17** is the read and delivery receipts, split out of 11 after 16. They answer only a `duration` and a read event, which waits for group 10's message mappers. - **18** is the current user's unread counts, split out of 09 because it embeds no `User` and nothing persists it. +- **19** is blocking and unblocking users and listing the blocked ones, split out of 09 because it embeds `User` + only through the existing mappers and nothing persists it. - **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 91a8a66148..27c890a239 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -579,9 +579,10 @@ def match(verb, path): dict( num='09', slug='users', title='Users', hand=['user_api.dart'], - match=owns('/api/v2/users'), + match=owns('/api/v2/users', unless=('/api/v2/users/block', '/api/v2/users/unblock')), goal='`User` is the most widely referenced public model in the SDK; this is where keep-vs-adopt costs the ' - 'most. [18](18-unread-counts.md) split the current user\'s unread counts out of it.', + 'most. [18](18-unread-counts.md) split the current user\'s unread counts out of it, and ' + '[19](19-user-blocking.md) blocking users.', decisions=[ '`User` and `OwnUser` are public, persisted, and embedded in nearly every other response. This group ' 'restructures them, last: the mappers in `user_mapper.dart` already map the generated types onto the ' @@ -1015,6 +1016,39 @@ def match(verb, path): risks=[], done=DONE.replace('- [ ]', '- [x]'), ), + dict( + num='19', slug='user-blocking', title='User Blocking', + hand=[], + match=only_ops('POST /api/v2/users/block', 'GET /api/v2/users/block', 'POST /api/v2/users/unblock'), + goal='Move blocking and unblocking a user, and listing the blocked users, ahead of [09](09-users.md): they ' + 'embed `User` only through the existing `user_mapper.dart`, and nothing persists them.', + decisions=[], + taken=textwrap.dedent("""\ + - **Split out of [09](09-users.md), ahead of it, as one slice.** Blocking and unblocking are a pair, + and the list refreshes the same `OwnUser.blockedUserIds`. No WebSocket or persisted model embeds + `UserBlock`, so it needs no converter and no storage codec. + - **The v2 routes are the v1 handlers.** `lib/chat/routes.go` mounts one `userBlockRoutes` table + (`v1.BlockUsers`, `v1.UnblockUsers`, `v1.GetBlockedUsers`) both at the root and under `/api/v2/`; + only the JSON encoding differs. None is gated, in beta or deprecated. + - **Moved off `UserApi`:** `blockUser`, `unblockUser` and `queryBlockedUsers`, now `StreamChatClient` + methods over `UsersRepository`. + - **`blockUser` and `unblockUser` keep their names,** rather than the spec's `blockUsers` and + `unblockUsers`: each takes one user id, and a method that takes several would be added beside them. + - **Their envelopes take the spec's plural names,** `BlockUsersResponse` (renamed from + `UserBlockResponse`) and `UnblockUsersResponse` (new; v10 returned `EmptyResponse`, but the generated + response is a named one). If the API starts blocking several users in one call, these responses gain + fields without a rename, and a batch method can return the same envelope. + - **`queryBlockedUsers` is renamed `getBlockedUsers`,** answering `GetBlockedUsersResponse` (renamed + from `BlockedUsersResponse`), as the spec names them: the call takes no filter, sort or pagination. + - **`UserBlock` is a freezed plain model with every field non-nullable,** following the wire rather + than v10, which typed `blockedUser`, `userId`, `blockedUserId` and `createdAt` nullable. The + server always sends them; the break is approved for this group. + - **`OwnUser.blockedUserIds` is updated only on success,** through `Result.onSuccess`, as v10 did by + throwing before it reached the update. Failures are still logged, through `Result.onFailure`. + """), + 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 299b677300..d80c884894 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -116,6 +116,14 @@ - `UnreadCountsChannel`, `UnreadCountsThread` and `UnreadCountsChannelType` no longer decode from or encode to JSON. - `GetUnreadCountResponse`, `UnreadCountsChannel`, `UnreadCountsThread` and `UnreadCountsChannelType` compare by value and gain `copyWith`. - `StreamChatApi.user.getUnreadCount` is removed; call `StreamChatClient.getUnreadCount` instead. +- `StreamChatClient.blockUser` returns a `Result` and `unblockUser` a `Result` instead of throwing. +- `StreamChatClient.queryBlockedUsers` is renamed `getBlockedUsers` and returns a `Result` instead of throwing. +- `UserBlockResponse` is renamed `BlockUsersResponse`, and `BlockedUsersResponse` is renamed `GetBlockedUsersResponse`. +- `BlockUsersResponse` and `GetBlockedUsersResponse` no longer decode from JSON, are immutable, built through a const constructor, and their `duration` is a non-nullable `String`. +- `BlockUsersResponse` and `GetBlockedUsersResponse` compare by value and gain `copyWith`. +- `UserBlock` no longer decodes from or encodes to JSON and no longer extends `Equatable`. +- `UserBlock.blockedUser`, `userId`, `blockedUserId` and `createdAt` are required and non-nullable, and `UserBlock.copyWith` takes every field as a non-nullable value. +- `StreamChatApi.user.blockUser`, `unblockUser` and `queryBlockedUsers` are removed; call `blockUser`, `unblockUser` and `getBlockedUsers` 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/client.dart b/packages/stream_chat/lib/src/client/client.dart index c539f0c1b7..880e301787 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -62,8 +62,10 @@ import '../core/models/reaction.dart'; import '../core/models/request/message_delivery.dart'; import '../core/models/response/add_user_group_members_response.dart'; import '../core/models/response/app_settings_response.dart'; +import '../core/models/response/block_users_response.dart'; import '../core/models/response/create_user_group_response.dart'; import '../core/models/response/delete_channel_response.dart'; +import '../core/models/response/get_blocked_users_response.dart'; import '../core/models/response/get_unread_count_response.dart'; import '../core/models/response/get_user_group_response.dart'; import '../core/models/response/hide_channel_response.dart'; @@ -81,6 +83,7 @@ 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/unblock_users_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'; @@ -1798,64 +1801,49 @@ class StreamChatClient { final _userBlockLock = Lock(); - /// Blocks a user with the provided [userId]. - Future blockUser(String userId) async { - try { - final response = await _userBlockLock.synchronized( - () => _chatApi.user.blockUser(userId), - ); - - final blockedUserId = response.blockedUserId; - final currentBlockedUserIds = [...?state.currentUser?.blockedUserIds]; - if (!currentBlockedUserIds.contains(blockedUserId)) { - // Add the new blocked user to the blocked user list. - state.blockedUserIds = [...currentBlockedUserIds, blockedUserId]; - } - - return response; - } catch (e, stk) { - logger.e(() => 'Error blocking user', error: e, stackTrace: stk); - rethrow; - } + /// Blocks the user with the given [userId] for the current user. + /// + /// On success, the blocked user's id is added to [OwnUser.blockedUserIds] on [ClientState.currentUser]. A failure + /// leaves it unchanged. + Future> blockUser(String userId) async { + final result = await _userBlockLock.synchronized(() => _usersRepository.blockUser(userId)); + return result + .onSuccess((response) => _addBlockedUserId(response.blockedUserId)) + .onFailure((e, stk) => logger.e(() => 'Error blocking user', error: e, stackTrace: stk)); } - /// Unblocks a previously blocked user with the provided [userId]. - Future unblockUser(String userId) async { - try { - final response = await _userBlockLock.synchronized( - () => _chatApi.user.unblockUser(userId), - ); - - final unblockedUserId = userId; - final currentBlockedUserIds = [...?state.currentUser?.blockedUserIds]; - if (currentBlockedUserIds.contains(unblockedUserId)) { - // Remove the unblocked user from the blocked user list. - state.blockedUserIds = currentBlockedUserIds..remove(unblockedUserId); - } - - return response; - } catch (e, stk) { - logger.e(() => 'Error unblocking user', error: e, stackTrace: stk); - rethrow; - } + /// Unblocks the user with the given [userId] for the current user. + /// + /// On success, [userId] is removed from [OwnUser.blockedUserIds] on [ClientState.currentUser]. A failure leaves it + /// unchanged. + Future> unblockUser(String userId) async { + final result = await _userBlockLock.synchronized(() => _usersRepository.unblockUser(userId)); + return result + .onSuccess((_) => _removeBlockedUserId(userId)) + .onFailure((e, stk) => logger.e(() => 'Error unblocking user', error: e, stackTrace: stk)); } - /// Retrieves a list of all users that the current user has blocked. - Future queryBlockedUsers() async { - try { - final response = await _userBlockLock.synchronized( - () => _chatApi.user.queryBlockedUsers(), - ); + /// Gets the users the current user has blocked. + /// + /// On success, [OwnUser.blockedUserIds] on [ClientState.currentUser] is replaced with the ids of the returned + /// users. A failure leaves it unchanged. + Future> getBlockedUsers() async { + final result = await _userBlockLock.synchronized(_usersRepository.getBlockedUsers); + return result + .onSuccess((response) => state.blockedUserIds = [for (final block in response.blocks) block.blockedUserId]) + .onFailure((e, stk) => logger.e(() => 'Error getting blocked users', error: e, stackTrace: stk)); + } - // Update the blocked user IDs with the latest data. - final blockedUserIds = response.blocks.map((it) => it.blockedUserId); - state.blockedUserIds = [...blockedUserIds.nonNulls]; + void _addBlockedUserId(String userId) { + final blockedUserIds = [...?state.currentUser?.blockedUserIds]; + if (blockedUserIds.contains(userId)) return; + state.blockedUserIds = [...blockedUserIds, userId]; + } - return response; - } catch (e, stk) { - logger.e(() => 'Error querying blocked users', error: e, stackTrace: stk); - rethrow; - } + void _removeBlockedUserId(String userId) { + final blockedUserIds = [...?state.currentUser?.blockedUserIds]; + if (!blockedUserIds.remove(userId)) return; + state.blockedUserIds = blockedUserIds; } /// Gets how many unread messages and threads the current user has. diff --git a/packages/stream_chat/lib/src/core/api/responses.dart b/packages/stream_chat/lib/src/core/api/responses.dart index 707bdd4bc3..fef131e992 100644 --- a/packages/stream_chat/lib/src/core/api/responses.dart +++ b/packages/stream_chat/lib/src/core/api/responses.dart @@ -15,7 +15,6 @@ import '../models/reaction.dart'; import '../models/read.dart'; import '../models/thread.dart'; import '../models/user.dart'; -import '../models/user_block.dart'; part 'responses.g.dart'; @@ -378,35 +377,6 @@ class ChannelStateResponse extends _BaseResponse { static ChannelStateResponse fromJson(Map json) => _$ChannelStateResponseFromJson(json); } -/// Contains information about a [User] that was banned from a [Channel] or App. -@JsonSerializable(createToJson: false) -class UserBlockResponse extends _BaseResponse { - /// User that banned the [user]. - @JsonKey(defaultValue: '') - late String blockedByUserId; - - /// Reason for the ban. - @JsonKey(defaultValue: '') - late String blockedUserId; - - /// Timestamp when the [user] was banned. - late DateTime createdAt; - - /// Create a new instance from a json - static UserBlockResponse fromJson(Map json) => _$UserBlockResponseFromJson(json); -} - -/// Model response for [StreamChatClient.queryBlockedUsers] api call -@JsonSerializable(createToJson: false) -class BlockedUsersResponse extends _BaseResponse { - /// Updated users - @JsonKey(defaultValue: []) - late List blocks; - - /// Create a new instance from a json - static BlockedUsersResponse fromJson(Map json) => _$BlockedUsersResponseFromJson(json); -} - /// Model response for [StreamChatClient.getThread] api call @JsonSerializable(createToJson: false) class GetThreadResponse extends _BaseResponse { diff --git a/packages/stream_chat/lib/src/core/api/responses.g.dart b/packages/stream_chat/lib/src/core/api/responses.g.dart index 9ae7c9adb8..fb67c5d102 100644 --- a/packages/stream_chat/lib/src/core/api/responses.g.dart +++ b/packages/stream_chat/lib/src/core/api/responses.g.dart @@ -188,19 +188,6 @@ ChannelStateResponse _$ChannelStateResponseFromJson( ..watcherCount = (json['watcher_count'] as num?)?.toInt() ?? 0 ..read = (json['read'] as List?)?.map((e) => Read.fromJson(e as Map)).toList() ?? []; -UserBlockResponse _$UserBlockResponseFromJson(Map json) => UserBlockResponse() - ..duration = json['duration'] as String? - ..blockedByUserId = json['blocked_by_user_id'] as String? ?? '' - ..blockedUserId = json['blocked_user_id'] as String? ?? '' - ..createdAt = DateTime.parse(json['created_at'] as String); - -BlockedUsersResponse _$BlockedUsersResponseFromJson( - Map json, -) => BlockedUsersResponse() - ..duration = json['duration'] as String? - ..blocks = - (json['blocks'] as List?)?.map((e) => UserBlock.fromJson(e as Map)).toList() ?? []; - GetThreadResponse _$GetThreadResponseFromJson(Map json) => GetThreadResponse() ..duration = json['duration'] as String? ..thread = Thread.fromJson(json['thread'] as Map); diff --git a/packages/stream_chat/lib/src/core/api/user_api.dart b/packages/stream_chat/lib/src/core/api/user_api.dart index 257d14d872..8940d993fa 100644 --- a/packages/stream_chat/lib/src/core/api/user_api.dart +++ b/packages/stream_chat/lib/src/core/api/user_api.dart @@ -64,34 +64,6 @@ class UserApi { return UpdateUsersResponse.fromJson(response.data); } - /// Blocks a user - Future blockUser(String userId) async { - final response = await _client.post( - '/users/block', - data: {'blocked_user_id': userId}, - ); - - return UserBlockResponse.fromJson(response.data); - } - - /// Unblocks a user - Future unblockUser(String userId) async { - final response = await _client.post( - '/users/unblock', - data: {'blocked_user_id': userId}, - ); - return EmptyResponse.fromJson(response.data); - } - - /// Requests blocked users. - Future queryBlockedUsers() async { - final response = await _client.get( - '/users/block', - ); - - return BlockedUsersResponse.fromJson(response.data); - } - /// Retrieves all the active live locations of the current user. Future getActiveLiveLocations() async { final response = await _client.get( diff --git a/packages/stream_chat/lib/src/core/models/response/block_users_response.dart b/packages/stream_chat/lib/src/core/models/response/block_users_response.dart new file mode 100644 index 0000000000..caed1e6765 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/block_users_response.dart @@ -0,0 +1,33 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'block_users_response.freezed.dart'; + +/// The block the current user placed on another user. +/// +/// Returned by [StreamChatClient.blockUser]. +@freezed +class BlockUsersResponse with _$BlockUsersResponse { + /// Creates a new [BlockUsersResponse]. + const BlockUsersResponse({ + required this.duration, + required this.blockedByUserId, + required this.blockedUserId, + required this.createdAt, + }); + + /// How long the request took to handle, such as `4.21ms`. + @override + final String duration; + + /// The id of the user who placed the block. + @override + final String blockedByUserId; + + /// The id of the blocked user. + @override + final String blockedUserId; + + /// The time the block was placed. + @override + final DateTime createdAt; +} diff --git a/packages/stream_chat/lib/src/core/models/response/block_users_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/block_users_response.freezed.dart new file mode 100644 index 0000000000..632ec399b3 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/block_users_response.freezed.dart @@ -0,0 +1,109 @@ +// 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 'block_users_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$BlockUsersResponse { + String get duration; + String get blockedByUserId; + String get blockedUserId; + DateTime get createdAt; + + /// Create a copy of BlockUsersResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $BlockUsersResponseCopyWith get copyWith => _$BlockUsersResponseCopyWithImpl( + this as BlockUsersResponse, + _$identity, + ); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is BlockUsersResponse && + (identical(other.duration, duration) || other.duration == duration) && + (identical(other.blockedByUserId, blockedByUserId) || other.blockedByUserId == blockedByUserId) && + (identical(other.blockedUserId, blockedUserId) || other.blockedUserId == blockedUserId) && + (identical(other.createdAt, createdAt) || other.createdAt == createdAt)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + duration, + blockedByUserId, + blockedUserId, + createdAt, + ); + + @override + String toString() { + return 'BlockUsersResponse(duration: $duration, blockedByUserId: $blockedByUserId, blockedUserId: $blockedUserId, createdAt: $createdAt)'; + } +} + +/// @nodoc +abstract mixin class $BlockUsersResponseCopyWith<$Res> { + factory $BlockUsersResponseCopyWith( + BlockUsersResponse value, + $Res Function(BlockUsersResponse) _then, + ) = _$BlockUsersResponseCopyWithImpl; + @useResult + $Res call({ + String duration, + String blockedByUserId, + String blockedUserId, + DateTime createdAt, + }); +} + +/// @nodoc +class _$BlockUsersResponseCopyWithImpl<$Res> implements $BlockUsersResponseCopyWith<$Res> { + _$BlockUsersResponseCopyWithImpl(this._self, this._then); + + final BlockUsersResponse _self; + final $Res Function(BlockUsersResponse) _then; + + /// Create a copy of BlockUsersResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? duration = null, + Object? blockedByUserId = null, + Object? blockedUserId = null, + Object? createdAt = null, + }) { + return _then( + BlockUsersResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + blockedByUserId: null == blockedByUserId + ? _self.blockedByUserId + : blockedByUserId // ignore: cast_nullable_to_non_nullable + as String, + blockedUserId: null == blockedUserId + ? _self.blockedUserId + : blockedUserId // ignore: cast_nullable_to_non_nullable + as String, + createdAt: null == createdAt + ? _self.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/response/get_blocked_users_response.dart b/packages/stream_chat/lib/src/core/models/response/get_blocked_users_response.dart new file mode 100644 index 0000000000..64e0535542 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/get_blocked_users_response.dart @@ -0,0 +1,25 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../user_block.dart'; + +part 'get_blocked_users_response.freezed.dart'; + +/// The users the current user has blocked. +/// +/// Returned by [StreamChatClient.getBlockedUsers]. +@freezed +class GetBlockedUsersResponse with _$GetBlockedUsersResponse { + /// Creates a new [GetBlockedUsersResponse]. + const GetBlockedUsersResponse({ + required this.duration, + required this.blocks, + }); + + /// How long the request took to handle, such as `4.21ms`. + @override + final String duration; + + /// One block for each user the current user has blocked. + @override + final List blocks; +} diff --git a/packages/stream_chat/lib/src/core/models/response/get_blocked_users_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/get_blocked_users_response.freezed.dart new file mode 100644 index 0000000000..06a265ed4b --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/get_blocked_users_response.freezed.dart @@ -0,0 +1,86 @@ +// 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 'get_blocked_users_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$GetBlockedUsersResponse { + String get duration; + List get blocks; + + /// Create a copy of GetBlockedUsersResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $GetBlockedUsersResponseCopyWith get copyWith => + _$GetBlockedUsersResponseCopyWithImpl( + this as GetBlockedUsersResponse, + _$identity, + ); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is GetBlockedUsersResponse && + (identical(other.duration, duration) || other.duration == duration) && + const DeepCollectionEquality().equals(other.blocks, blocks)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + duration, + const DeepCollectionEquality().hash(blocks), + ); + + @override + String toString() { + return 'GetBlockedUsersResponse(duration: $duration, blocks: $blocks)'; + } +} + +/// @nodoc +abstract mixin class $GetBlockedUsersResponseCopyWith<$Res> { + factory $GetBlockedUsersResponseCopyWith( + GetBlockedUsersResponse value, + $Res Function(GetBlockedUsersResponse) _then, + ) = _$GetBlockedUsersResponseCopyWithImpl; + @useResult + $Res call({String duration, List blocks}); +} + +/// @nodoc +class _$GetBlockedUsersResponseCopyWithImpl<$Res> implements $GetBlockedUsersResponseCopyWith<$Res> { + _$GetBlockedUsersResponseCopyWithImpl(this._self, this._then); + + final GetBlockedUsersResponse _self; + final $Res Function(GetBlockedUsersResponse) _then; + + /// Create a copy of GetBlockedUsersResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? duration = null, Object? blocks = null}) { + return _then( + GetBlockedUsersResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + blocks: null == blocks + ? _self.blocks + : blocks // ignore: cast_nullable_to_non_nullable + as List, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/response/unblock_users_response.dart b/packages/stream_chat/lib/src/core/models/response/unblock_users_response.dart new file mode 100644 index 0000000000..88c24338ff --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/unblock_users_response.dart @@ -0,0 +1,18 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'unblock_users_response.freezed.dart'; + +/// The outcome of the current user unblocking another user. +/// +/// Returned by [StreamChatClient.unblockUser]. +@freezed +class UnblockUsersResponse with _$UnblockUsersResponse { + /// Creates a new [UnblockUsersResponse]. + const UnblockUsersResponse({ + 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/unblock_users_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/unblock_users_response.freezed.dart new file mode 100644 index 0000000000..f19a40d4ec --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/unblock_users_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 'unblock_users_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$UnblockUsersResponse { + String get duration; + + /// Create a copy of UnblockUsersResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $UnblockUsersResponseCopyWith get copyWith => + _$UnblockUsersResponseCopyWithImpl( + this as UnblockUsersResponse, + _$identity, + ); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is UnblockUsersResponse && + (identical(other.duration, duration) || other.duration == duration)); + } + + @override + int get hashCode => Object.hash(runtimeType, duration); + + @override + String toString() { + return 'UnblockUsersResponse(duration: $duration)'; + } +} + +/// @nodoc +abstract mixin class $UnblockUsersResponseCopyWith<$Res> { + factory $UnblockUsersResponseCopyWith( + UnblockUsersResponse value, + $Res Function(UnblockUsersResponse) _then, + ) = _$UnblockUsersResponseCopyWithImpl; + @useResult + $Res call({String duration}); +} + +/// @nodoc +class _$UnblockUsersResponseCopyWithImpl<$Res> implements $UnblockUsersResponseCopyWith<$Res> { + _$UnblockUsersResponseCopyWithImpl(this._self, this._then); + + final UnblockUsersResponse _self; + final $Res Function(UnblockUsersResponse) _then; + + /// Create a copy of UnblockUsersResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? duration = null}) { + return _then( + UnblockUsersResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/user_block.dart b/packages/stream_chat/lib/src/core/models/user_block.dart index 8b0c3b0624..33b874b20f 100644 --- a/packages/stream_chat/lib/src/core/models/user_block.dart +++ b/packages/stream_chat/lib/src/core/models/user_block.dart @@ -1,63 +1,40 @@ -import 'package:equatable/equatable.dart'; -import 'package:json_annotation/json_annotation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + import 'user.dart'; -part 'user_block.g.dart'; +part 'user_block.freezed.dart'; -/// Contains information about a [User] blocked from a [Channel] or App. -@JsonSerializable() -class UserBlock extends Equatable { - /// Creates a new instance of [UserBlock] +/// A block one [User] placed on another. +/// +/// Listed in [GetBlockedUsersResponse.blocks]. +@freezed +class UserBlock with _$UserBlock { + /// Creates a new [UserBlock]. const UserBlock({ required this.user, - this.blockedUser, - this.userId, - this.blockedUserId, - this.createdAt, + required this.blockedUser, + required this.userId, + required this.blockedUserId, + required this.createdAt, }); - /// Create a new instance from a json - factory UserBlock.fromJson(Map json) => _$UserBlockFromJson(json); - - /// User that blocked the [blockedUser]. + /// The user who placed the block. + @override final User user; - /// User that was blocked by the [user]. - final User? blockedUser; - - /// ID of the [user]. - final String? userId; - - /// ID of the [blockedUser]. - final String? blockedUserId; - - /// Timestamp when the [user] was blocked. - final DateTime? createdAt; + /// The user [user] blocked. + @override + final User blockedUser; - /// Serialize to json - Map toJson() => _$UserBlockToJson(this); + /// The id of [user]. + @override + final String userId; - /// Returns a copy of this object with the given fields updated. - UserBlock copyWith({ - User? user, - User? blockedUser, - String? userId, - String? blockedUserId, - DateTime? createdAt, - }) => UserBlock( - user: user ?? this.user, - blockedUser: blockedUser ?? this.blockedUser, - userId: userId ?? this.userId, - blockedUserId: blockedUserId ?? this.blockedUserId, - createdAt: createdAt ?? this.createdAt, - ); + /// The id of [blockedUser]. + @override + final String blockedUserId; + /// The time [user] blocked [blockedUser]. @override - List get props => [ - user, - blockedUser, - userId, - blockedUserId, - createdAt, - ]; + final DateTime createdAt; } diff --git a/packages/stream_chat/lib/src/core/models/user_block.freezed.dart b/packages/stream_chat/lib/src/core/models/user_block.freezed.dart new file mode 100644 index 0000000000..a4d5d6ab69 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/user_block.freezed.dart @@ -0,0 +1,112 @@ +// 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 'user_block.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$UserBlock { + User get user; + User get blockedUser; + String get userId; + String get blockedUserId; + DateTime get createdAt; + + /// Create a copy of UserBlock + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $UserBlockCopyWith get copyWith => _$UserBlockCopyWithImpl(this as UserBlock, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is UserBlock && + (identical(other.user, user) || other.user == user) && + (identical(other.blockedUser, blockedUser) || other.blockedUser == blockedUser) && + (identical(other.userId, userId) || other.userId == userId) && + (identical(other.blockedUserId, blockedUserId) || other.blockedUserId == blockedUserId) && + (identical(other.createdAt, createdAt) || other.createdAt == createdAt)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + user, + blockedUser, + userId, + blockedUserId, + createdAt, + ); + + @override + String toString() { + return 'UserBlock(user: $user, blockedUser: $blockedUser, userId: $userId, blockedUserId: $blockedUserId, createdAt: $createdAt)'; + } +} + +/// @nodoc +abstract mixin class $UserBlockCopyWith<$Res> { + factory $UserBlockCopyWith(UserBlock value, $Res Function(UserBlock) _then) = _$UserBlockCopyWithImpl; + @useResult + $Res call({ + User user, + User blockedUser, + String userId, + String blockedUserId, + DateTime createdAt, + }); +} + +/// @nodoc +class _$UserBlockCopyWithImpl<$Res> implements $UserBlockCopyWith<$Res> { + _$UserBlockCopyWithImpl(this._self, this._then); + + final UserBlock _self; + final $Res Function(UserBlock) _then; + + /// Create a copy of UserBlock + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? user = null, + Object? blockedUser = null, + Object? userId = null, + Object? blockedUserId = null, + Object? createdAt = null, + }) { + return _then( + UserBlock( + user: null == user + ? _self.user + : user // ignore: cast_nullable_to_non_nullable + as User, + blockedUser: null == blockedUser + ? _self.blockedUser + : blockedUser // ignore: cast_nullable_to_non_nullable + as User, + userId: null == userId + ? _self.userId + : userId // ignore: cast_nullable_to_non_nullable + as String, + blockedUserId: null == blockedUserId + ? _self.blockedUserId + : blockedUserId // ignore: cast_nullable_to_non_nullable + as String, + createdAt: null == createdAt + ? _self.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/user_block.g.dart b/packages/stream_chat/lib/src/core/models/user_block.g.dart deleted file mode 100644 index 6e36cb806f..0000000000 --- a/packages/stream_chat/lib/src/core/models/user_block.g.dart +++ /dev/null @@ -1,23 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'user_block.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -UserBlock _$UserBlockFromJson(Map json) => UserBlock( - user: User.fromJson(json['user'] as Map), - blockedUser: json['blocked_user'] == null ? null : User.fromJson(json['blocked_user'] as Map), - userId: json['user_id'] as String?, - blockedUserId: json['blocked_user_id'] as String?, - createdAt: json['created_at'] == null ? null : DateTime.parse(json['created_at'] as String), -); - -Map _$UserBlockToJson(UserBlock instance) => { - 'user': instance.user.toJson(), - 'blocked_user': instance.blockedUser?.toJson(), - 'user_id': instance.userId, - 'blocked_user_id': instance.blockedUserId, - 'created_at': instance.createdAt?.toIso8601String(), -}; diff --git a/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart index 0c2ea1f46c..19b0c59e7f 100644 --- a/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart @@ -1,12 +1,16 @@ import '../../../open_api/api.dart' as api; import '../../core/models/own_user.dart'; import '../../core/models/privacy_settings.dart'; +import '../../core/models/response/block_users_response.dart'; import '../../core/models/response/create_guest_user_response.dart'; +import '../../core/models/response/get_blocked_users_response.dart'; import '../../core/models/response/get_unread_count_response.dart'; +import '../../core/models/response/unblock_users_response.dart'; import '../../core/models/unread_counts_channel.dart'; import '../../core/models/unread_counts_channel_type.dart'; import '../../core/models/unread_counts_thread.dart'; import '../../core/models/user.dart'; +import '../../core/models/user_block.dart'; import '../../core/util/extension.dart'; // TODO(openapi-migration): re-point these mappers in group 09. @@ -140,3 +144,41 @@ extension UnreadCountsChannelTypeMapper on api.UnreadCountsChannelType { unreadCount: unreadCount, ); } + +/// Maps a generated [api.BlockUsersResponse] to a [BlockUsersResponse]. +extension BlockUsersResponseMapper on api.BlockUsersResponse { + /// Converts this response into a [BlockUsersResponse]. + BlockUsersResponse toModel() => BlockUsersResponse( + duration: duration, + blockedByUserId: blockedByUserId, + blockedUserId: blockedUserId, + createdAt: createdAt, + ); +} + +/// Maps a generated [api.UnblockUsersResponse] to an [UnblockUsersResponse]. +extension UnblockUsersResponseMapper on api.UnblockUsersResponse { + /// Converts this response into an [UnblockUsersResponse]. + UnblockUsersResponse toModel() => UnblockUsersResponse(duration: duration); +} + +/// Maps a generated [api.GetBlockedUsersResponse] to a [GetBlockedUsersResponse]. +extension GetBlockedUsersResponseMapper on api.GetBlockedUsersResponse { + /// Converts this response into a [GetBlockedUsersResponse]. + GetBlockedUsersResponse toModel() => GetBlockedUsersResponse( + duration: duration, + blocks: [for (final block in blocks) block.toModel()], + ); +} + +/// Maps a generated [api.BlockedUserResponse] to a [UserBlock]. +extension BlockedUserResponseMapper on api.BlockedUserResponse { + /// Converts this block into a [UserBlock]. + UserBlock toModel() => UserBlock( + user: user.toModel(), + blockedUser: blockedUser.toModel(), + userId: userId, + blockedUserId: blockedUserId, + createdAt: createdAt, + ); +} diff --git a/packages/stream_chat/lib/src/repository/users_repository.dart b/packages/stream_chat/lib/src/repository/users_repository.dart index ac381ec194..8f9bf5e319 100644 --- a/packages/stream_chat/lib/src/repository/users_repository.dart +++ b/packages/stream_chat/lib/src/repository/users_repository.dart @@ -1,7 +1,10 @@ import 'package:stream_core/stream_core.dart' show PatternMatching, Result; import '../../open_api/api.dart' as api; +import '../core/models/response/block_users_response.dart'; +import '../core/models/response/get_blocked_users_response.dart'; import '../core/models/response/get_unread_count_response.dart'; +import '../core/models/response/unblock_users_response.dart'; import 'mapper/user_mapper.dart'; /// Repository dedicated to user operations. @@ -16,4 +19,22 @@ class UsersRepository { final result = await _api.unreadCounts(); return result.map((response) => response.toModel()); } + + /// Blocks the user with the given [userId] for the current user. + Future> blockUser(String userId) async { + final result = await _api.blockUsers(blockUsersRequest: api.BlockUsersRequest(blockedUserId: userId)); + return result.map((response) => response.toModel()); + } + + /// Unblocks the user with the given [userId] for the current user. + Future> unblockUser(String userId) async { + final result = await _api.unblockUsers(unblockUsersRequest: api.UnblockUsersRequest(blockedUserId: userId)); + return result.map((response) => response.toModel()); + } + + /// Gets the users the current user has blocked. + Future> getBlockedUsers() async { + final result = await _api.getBlockedUsers(); + return result.map((response) => response.toModel()); + } } diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index 3961ded772..86de999612 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -122,9 +122,11 @@ export 'src/core/models/read.dart'; export 'src/core/models/request/message_delivery.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/block_users_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_blocked_users_response.dart'; export 'src/core/models/response/get_unread_count_response.dart'; export 'src/core/models/response/get_user_group_response.dart'; export 'src/core/models/response/hide_channel_response.dart'; @@ -143,6 +145,7 @@ 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/unblock_users_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/fixtures/user_block.json b/packages/stream_chat/test/fixtures/user_block.json deleted file mode 100644 index 124781307e..0000000000 --- a/packages/stream_chat/test/fixtures/user_block.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "user": { - "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", - "role": "test-role", - "name": "John", - "image": "https://getstream.io/random_png/?id=cool-shadow-7&name=Cool+shadow", - "banned": true, - "online": true, - "created_at": "2021-08-03 12:39:21.817646", - "updated_at": "2021-08-04 12:39:21.817646", - "last_active": "2021-08-05 12:39:21.817646" - }, - "blocked_user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "user_id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", - "blocked_user_id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "created_at": "2020-01-28T22:17:30.83015Z" -} \ No newline at end of file diff --git a/packages/stream_chat/test/src/client/client_block_user_test.dart b/packages/stream_chat/test/src/client/client_block_user_test.dart new file mode 100644 index 0000000000..dc54d408ac --- /dev/null +++ b/packages/stream_chat/test/src/client/client_block_user_test.dart @@ -0,0 +1,101 @@ +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.blockUser sends the user id and returns the block', () async { + final defaultApi = MockDefaultApi(); + when(() => defaultApi.blockUsers(blockUsersRequest: _request)).thenAnswer( + (_) async => Result.success(_blockUsersResponse()), + ); + final client = _client(defaultApi); + + final res = await client.blockUser(_blockedUserId); + + expect( + res.getOrNull(), + BlockUsersResponse( + duration: '4.21ms', + blockedByUserId: 'user-id', + blockedUserId: _blockedUserId, + createdAt: DateTime.utc(2026, 10, 7, 9, 30), + ), + ); + verify(() => defaultApi.blockUsers(blockUsersRequest: _request)).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test("StreamChatClient.blockUser adds the blocked user to the current user's blocked user ids", () async { + final defaultApi = MockDefaultApi(); + when(() => defaultApi.blockUsers(blockUsersRequest: _request)).thenAnswer( + (_) async => Result.success(_blockUsersResponse()), + ); + final client = _clientWithOwnUser(defaultApi, blockedUserIds: const ['other-user']); + + await client.blockUser(_blockedUserId); + + expect(client.state.currentUser!.blockedUserIds, ['other-user', _blockedUserId]); + }); + + test('StreamChatClient.blockUser does not add an already blocked user again', () async { + final defaultApi = MockDefaultApi(); + when(() => defaultApi.blockUsers(blockUsersRequest: _request)).thenAnswer( + (_) async => Result.success(_blockUsersResponse()), + ); + final client = _clientWithOwnUser(defaultApi, blockedUserIds: const [_blockedUserId]); + + await client.blockUser(_blockedUserId); + + expect(client.state.currentUser!.blockedUserIds, [_blockedUserId]); + }); + + test('StreamChatClient.blockUser returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final defaultApi = MockDefaultApi(); + when(() => defaultApi.blockUsers(blockUsersRequest: _request)).thenAnswer( + (_) async => const Result.failure(error), + ); + final client = _client(defaultApi); + + final res = await client.blockUser(_blockedUserId); + + expect(res.exceptionOrNull(), error); + }); + + test("StreamChatClient.blockUser leaves the current user's blocked user ids unchanged on failure", () async { + final defaultApi = MockDefaultApi(); + when(() => defaultApi.blockUsers(blockUsersRequest: _request)).thenAnswer( + (_) async => const Result.failure(StreamClientException(message: 'boom')), + ); + final client = _clientWithOwnUser(defaultApi, blockedUserIds: const ['other-user']); + + await client.blockUser(_blockedUserId); + + expect(client.state.currentUser!.blockedUserIds, ['other-user']); + }); +} + +const _blockedUserId = 'blocked-user-id'; +const _request = api.BlockUsersRequest(blockedUserId: _blockedUserId); + +StreamChatClient _client(api.DefaultApi defaultApi) { + final client = StreamChatClient('test-api-key', defaultApi: defaultApi); + addTearDown(client.dispose); + return client; +} + +StreamChatClient _clientWithOwnUser(api.DefaultApi defaultApi, {required List blockedUserIds}) { + final client = _client(defaultApi); + client.state.currentUser = OwnUser(id: 'user-id', blockedUserIds: blockedUserIds); + return client; +} + +api.BlockUsersResponse _blockUsersResponse() => api.BlockUsersResponse( + duration: '4.21ms', + blockedByUserId: 'user-id', + blockedUserId: _blockedUserId, + createdAt: DateTime.utc(2026, 10, 7, 9, 30), +); diff --git a/packages/stream_chat/test/src/client/client_get_blocked_users_test.dart b/packages/stream_chat/test/src/client/client_get_blocked_users_test.dart new file mode 100644 index 0000000000..ea3bd5caf1 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_get_blocked_users_test.dart @@ -0,0 +1,108 @@ +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 '../fakes.dart'; +import '../mocks.dart'; + +void main() { + test('StreamChatClient.getBlockedUsers returns the blocks', () async { + final defaultApi = MockDefaultApi(); + when(defaultApi.getBlockedUsers).thenAnswer((_) async => Result.success(_getBlockedUsersResponse())); + final client = _client(defaultApi); + + final res = await client.getBlockedUsers(); + + expect( + res.getOrNull(), + GetBlockedUsersResponse( + duration: '4.21ms', + blocks: [ + UserBlock( + user: fakeUser('user-id'), + blockedUser: fakeUser('blocked-user-1'), + userId: 'user-id', + blockedUserId: 'blocked-user-1', + createdAt: DateTime.utc(2026, 10, 1), + ), + UserBlock( + user: fakeUser('user-id'), + blockedUser: fakeUser('blocked-user-2'), + userId: 'user-id', + blockedUserId: 'blocked-user-2', + createdAt: DateTime.utc(2026, 10, 2), + ), + ], + ), + ); + verify(defaultApi.getBlockedUsers).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test("StreamChatClient.getBlockedUsers replaces the current user's blocked user ids", () async { + final defaultApi = MockDefaultApi(); + when(defaultApi.getBlockedUsers).thenAnswer((_) async => Result.success(_getBlockedUsersResponse())); + final client = _clientWithOwnUser(defaultApi); + + await client.getBlockedUsers(); + + expect(client.state.currentUser!.blockedUserIds, ['blocked-user-1', 'blocked-user-2']); + }); + + test('StreamChatClient.getBlockedUsers returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final defaultApi = MockDefaultApi(); + when(defaultApi.getBlockedUsers).thenAnswer((_) async => const Result.failure(error)); + final client = _client(defaultApi); + + final res = await client.getBlockedUsers(); + + expect(res.exceptionOrNull(), error); + }); + + test("StreamChatClient.getBlockedUsers leaves the current user's blocked user ids unchanged on failure", () async { + final defaultApi = MockDefaultApi(); + when(defaultApi.getBlockedUsers).thenAnswer( + (_) async => const Result.failure(StreamClientException(message: 'boom')), + ); + final client = _clientWithOwnUser(defaultApi); + + await client.getBlockedUsers(); + + expect(client.state.currentUser!.blockedUserIds, ['stale-blocked-user']); + }); +} + +StreamChatClient _client(api.DefaultApi defaultApi) { + final client = StreamChatClient('test-api-key', defaultApi: defaultApi); + addTearDown(client.dispose); + return client; +} + +// A client whose current user starts with a blocked user the server no longer reports. +StreamChatClient _clientWithOwnUser(api.DefaultApi defaultApi) { + final client = _client(defaultApi); + client.state.currentUser = OwnUser(id: 'user-id', blockedUserIds: const ['stale-blocked-user']); + return client; +} + +api.GetBlockedUsersResponse _getBlockedUsersResponse() => api.GetBlockedUsersResponse( + duration: '4.21ms', + blocks: [ + api.BlockedUserResponse( + user: fakeUserResponse('user-id'), + blockedUser: fakeUserResponse('blocked-user-1'), + userId: 'user-id', + blockedUserId: 'blocked-user-1', + createdAt: DateTime.utc(2026, 10, 1), + ), + api.BlockedUserResponse( + user: fakeUserResponse('user-id'), + blockedUser: fakeUserResponse('blocked-user-2'), + userId: 'user-id', + blockedUserId: 'blocked-user-2', + createdAt: DateTime.utc(2026, 10, 2), + ), + ], +); diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 04e20a75c1..fa8dd7baff 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -3641,223 +3641,6 @@ void main() { verifyNoMoreInteractions(fakeChatApi.user); }); - test('`.blockUser`', () async { - const userId = 'test-user-id'; - - when(() => fakeChatApi.user.blockUser(userId)).thenAnswer( - (_) async => UserBlockResponse.fromJson({ - 'blocked_by_user_id': 'deven', - 'blocked_user_id': 'jaap', - 'created_at': '2024-10-01 12:45:23.456', - }), - ); - - final res = await client.blockUser(userId); - - expect(res, isNotNull); - - verify( - () => fakeChatApi.user.blockUser(userId), - ).called(1); - verifyNoMoreInteractions(fakeChatApi.user); - }); - - test('`.unblockUser`', () async { - const userId = 'test-user-id'; - - when(() => fakeChatApi.user.unblockUser(userId)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.unblockUser(userId); - - expect(res, isNotNull); - - verify( - () => fakeChatApi.user.unblockUser(userId), - ).called(1); - verifyNoMoreInteractions(fakeChatApi.user); - }); - - test('`.queryBlockedUsers`', () async { - final users = List.generate( - 3, - (index) => User(id: 'test-user-id-$index'), - ); - - when(() => fakeChatApi.user.queryBlockedUsers()).thenAnswer( - (_) async => BlockedUsersResponse() - ..blocks = [ - UserBlock(user: users[0], blockedUser: users[1]), - UserBlock(user: users[0], blockedUser: users[2]), - ], - ); - - final res = await client.queryBlockedUsers(); - expect(res, isNotNull); - expect(res.blocks.length, 2); - - verify(() => fakeChatApi.user.queryBlockedUsers()).called(1); - verifyNoMoreInteractions(fakeChatApi.user); - }); - - group('Block user state management', () { - test('blockUser should update blockedUserIds on client state', () async { - final testUser = OwnUser(id: 'test-user'); - const userId = 'blocked-user-id'; - - // Verify initial state - expect(client.state.currentUser?.blockedUserIds, isEmpty); - - when(() => fakeChatApi.user.blockUser(userId)).thenAnswer( - (_) async => UserBlockResponse() - ..blockedUserId = userId - ..blockedByUserId = testUser.id - ..createdAt = DateTime.now(), - ); - - await client.blockUser(userId); - - // Verify - should now include the blocked user ID - expect(client.state.currentUser?.blockedUserIds, contains(userId)); - verify(() => fakeChatApi.user.blockUser(userId)).called(1); - verifyNoMoreInteractions(fakeChatApi.user); - }); - - test( - 'blockUser should not duplicate existing blocked user IDs', - () async { - const userId = 'blocked-user-id'; - client.state.blockedUserIds = const [userId]; - - // Verify the user is already in the blocked list - expect(client.state.currentUser?.blockedUserIds, contains(userId)); - - when(() => fakeChatApi.user.blockUser(userId)).thenAnswer( - (_) async => UserBlockResponse() - ..blockedUserId = userId - ..blockedByUserId = client.state.currentUser!.id - ..createdAt = DateTime.now(), - ); - - await client.blockUser(userId); - - // Verify - should still have only one entry - expect(client.state.currentUser?.blockedUserIds, contains(userId)); - expect(client.state.currentUser?.blockedUserIds.length, 1); - verify(() => fakeChatApi.user.blockUser(userId)).called(1); - verifyNoMoreInteractions(fakeChatApi.user); - }, - ); - - test('unblockUser should remove user from blockedUserIds', () async { - const blockedUserId = 'blocked-user-id'; - const otherBlockedId = 'other-blocked-id'; - client.state.blockedUserIds = const [blockedUserId, otherBlockedId]; - - // Verify initial state includes both blocked IDs - expect( - client.state.currentUser?.blockedUserIds, - containsAll([blockedUserId, otherBlockedId]), - ); - - when(() => fakeChatApi.user.unblockUser(blockedUserId)).thenAnswer( - (_) async => EmptyResponse(), - ); - - await client.unblockUser(blockedUserId); - - // Verify - blockedUserId should be removed - expect( - client.state.currentUser?.blockedUserIds, - contains(otherBlockedId), - ); - - expect( - client.state.currentUser?.blockedUserIds, - isNot(contains(blockedUserId)), - ); - - verify(() => fakeChatApi.user.unblockUser(blockedUserId)).called(1); - verifyNoMoreInteractions(fakeChatApi.user); - }); - - test( - 'unblockUser should be resilient if user ID not in blocked list', - () async { - const nonBlockedUserId = 'not-in-list'; - const otherBlockedId = 'other-blocked-id'; - client.state.blockedUserIds = const [otherBlockedId]; - - // Verify initial state - expect( - client.state.currentUser?.blockedUserIds, - contains(otherBlockedId), - ); - - expect( - client.state.currentUser?.blockedUserIds, - isNot(contains(nonBlockedUserId)), - ); - - when(() => fakeChatApi.user.unblockUser(nonBlockedUserId)).thenAnswer( - (_) async => EmptyResponse(), - ); - - await client.unblockUser(nonBlockedUserId); - - // Verify - should remain unchanged - expect(client.state.currentUser?.blockedUserIds, contains(otherBlockedId)); - expect(client.state.currentUser?.blockedUserIds, isNot(contains(nonBlockedUserId))); - verify(() => fakeChatApi.user.unblockUser(nonBlockedUserId)).called(1); - verifyNoMoreInteractions(fakeChatApi.user); - }, - ); - - test( - 'queryBlockedUsers should update client state with blockedUserIds', - () async { - const blockedId1 = 'blocked-1'; - const blockedId2 = 'blocked-2'; - - // Verify initial state - expect(client.state.currentUser?.blockedUserIds, isEmpty); - - // Create mock users - final blockedUser1 = User(id: 'blocked-user-1'); - final blockedUser2 = User(id: 'blocked-user-2'); - - // Mock the queryBlockedUsers API call - when(() => fakeChatApi.user.queryBlockedUsers()).thenAnswer( - (_) async => BlockedUsersResponse() - ..blocks = [ - UserBlock( - user: user, - userId: user.id, - blockedUser: blockedUser1, - blockedUserId: blockedId1, - ), - UserBlock( - user: user, - userId: user.id, - blockedUser: blockedUser2, - blockedUserId: blockedId2, - ), - ], - ); - - await client.queryBlockedUsers(); - - // Verify - should now include both blocked IDs - expect( - client.state.currentUser?.blockedUserIds, - containsAll([blockedId1, blockedId2]), - ); - - verify(() => fakeChatApi.user.queryBlockedUsers()).called(1); - verifyNoMoreInteractions(fakeChatApi.user); - }, - ); - }); - test('`.getActiveLiveLocations`', () async { final locations = [ Location( diff --git a/packages/stream_chat/test/src/client/client_unblock_user_test.dart b/packages/stream_chat/test/src/client/client_unblock_user_test.dart new file mode 100644 index 0000000000..e0b2ed83e6 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_unblock_user_test.dart @@ -0,0 +1,89 @@ +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.unblockUser sends the user id and returns the response', () async { + final defaultApi = MockDefaultApi(); + when(() => defaultApi.unblockUsers(unblockUsersRequest: _request)).thenAnswer( + (_) async => const Result.success(api.UnblockUsersResponse(duration: '4.21ms')), + ); + final client = _client(defaultApi); + + final res = await client.unblockUser(_blockedUserId); + + expect(res.getOrNull(), const UnblockUsersResponse(duration: '4.21ms')); + verify(() => defaultApi.unblockUsers(unblockUsersRequest: _request)).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test("StreamChatClient.unblockUser removes the user from the current user's blocked user ids", () async { + final defaultApi = MockDefaultApi(); + when(() => defaultApi.unblockUsers(unblockUsersRequest: _request)).thenAnswer( + (_) async => const Result.success(api.UnblockUsersResponse(duration: '4.21ms')), + ); + final client = _clientWithOwnUser(defaultApi, blockedUserIds: const [_blockedUserId, 'other-user']); + + await client.unblockUser(_blockedUserId); + + expect(client.state.currentUser!.blockedUserIds, ['other-user']); + }); + + test( + "StreamChatClient.unblockUser leaves the current user's blocked user ids unchanged when the user was not blocked", + () async { + final defaultApi = MockDefaultApi(); + when(() => defaultApi.unblockUsers(unblockUsersRequest: _request)).thenAnswer( + (_) async => const Result.success(api.UnblockUsersResponse(duration: '4.21ms')), + ); + final client = _clientWithOwnUser(defaultApi, blockedUserIds: const ['other-user']); + + await client.unblockUser(_blockedUserId); + + expect(client.state.currentUser!.blockedUserIds, ['other-user']); + }, + ); + + test('StreamChatClient.unblockUser returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final defaultApi = MockDefaultApi(); + when(() => defaultApi.unblockUsers(unblockUsersRequest: _request)).thenAnswer( + (_) async => const Result.failure(error), + ); + final client = _client(defaultApi); + + final res = await client.unblockUser(_blockedUserId); + + expect(res.exceptionOrNull(), error); + }); + + test("StreamChatClient.unblockUser leaves the current user's blocked user ids unchanged on failure", () async { + final defaultApi = MockDefaultApi(); + when(() => defaultApi.unblockUsers(unblockUsersRequest: _request)).thenAnswer( + (_) async => const Result.failure(StreamClientException(message: 'boom')), + ); + final client = _clientWithOwnUser(defaultApi, blockedUserIds: const [_blockedUserId, 'other-user']); + + await client.unblockUser(_blockedUserId); + + expect(client.state.currentUser!.blockedUserIds, [_blockedUserId, 'other-user']); + }); +} + +const _blockedUserId = 'blocked-user-id'; +const _request = api.UnblockUsersRequest(blockedUserId: _blockedUserId); + +StreamChatClient _client(api.DefaultApi defaultApi) { + final client = StreamChatClient('test-api-key', defaultApi: defaultApi); + addTearDown(client.dispose); + return client; +} + +StreamChatClient _clientWithOwnUser(api.DefaultApi defaultApi, {required List blockedUserIds}) { + final client = _client(defaultApi); + client.state.currentUser = OwnUser(id: 'user-id', blockedUserIds: blockedUserIds); + return client; +} diff --git a/packages/stream_chat/test/src/core/api/responses_test.dart b/packages/stream_chat/test/src/core/api/responses_test.dart index db68051eeb..3ba17b59d7 100644 --- a/packages/stream_chat/test/src/core/api/responses_test.dart +++ b/packages/stream_chat/test/src/core/api/responses_test.dart @@ -4326,57 +4326,6 @@ void main() { expect(response.message, isA()); }); - test('UserBlockResponse', () { - const jsonExample = ''' - { - "blocked_user_id":"c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "blocked_by_user_id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", - "created_at": "2020-01-28T22:17:30.83015Z" - } - '''; - final response = UserBlockResponse.fromJson(json.decode(jsonExample)); - expect(response.blockedUserId, isA()); - expect(response.blockedByUserId, isA()); - expect(response.createdAt, isA()); - }); - - test('BlockedUsersResponse', () { - const jsonExample = ''' - { - "blocks": [ - { - "user": { - "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", - "role": "test-role", - "name": "John", - "image": "https://getstream.io/random_png/?id=cool-shadow-7&name=Cool+shadow", - "banned": true, - "online": true, - "created_at": "2021-08-03 12:39:21.817646", - "updated_at": "2021-08-04 12:39:21.817646", - "last_active": "2021-08-05 12:39:21.817646" - }, - "blocked_user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "user_id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", - "blocked_user_id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "created_at": "2020-01-28T22:17:30.83015Z" - } - ] - } - '''; - final response = BlockedUsersResponse.fromJson(json.decode(jsonExample)); - expect(response.blocks, isA>()); - }); - test('UpsertPushPreferencesResponse', () { const jsonExample = ''' { diff --git a/packages/stream_chat/test/src/core/api/user_api_test.dart b/packages/stream_chat/test/src/core/api/user_api_test.dart index c5ef7bff44..f9c1997e9f 100644 --- a/packages/stream_chat/test/src/core/api/user_api_test.dart +++ b/packages/stream_chat/test/src/core/api/user_api_test.dart @@ -137,93 +137,6 @@ void main() { verifyNoMoreInteractions(client); }); - test('blockUser', () async { - const targetUserId = 'test-target-user-id'; - - const path = '/users/block'; - - when( - () => client.post( - path, - data: { - 'blocked_user_id': targetUserId, - }, - ), - ).thenAnswer( - (_) async => successResponse( - path, - data: { - 'blocked_by_user_id': 'deven', - 'blocked_user_id': 'jaap', - 'created_at': '2024-10-01 12:45:23.456', - }, - ), - ); - - final res = await userApi.blockUser(targetUserId); - - expect(res, isNotNull); - - verify( - () => client.post( - path, - data: { - 'blocked_user_id': targetUserId, - }, - ), - ).called(1); - verifyNoMoreInteractions(client); - }); - - test('unblockUser', () async { - const targetUserId = 'test-target-user-id'; - - const path = '/users/unblock'; - - when( - () => client.post( - path, - data: { - 'blocked_user_id': targetUserId, - }, - ), - ).thenAnswer( - (_) async => successResponse( - path, - data: {}, - ), - ); - - final res = await userApi.unblockUser(targetUserId); - - expect(res, isNotNull); - - verify( - () => client.post( - path, - data: { - 'blocked_user_id': targetUserId, - }, - ), - ).called(1); - verifyNoMoreInteractions(client); - }); - - test('queryBlockedUsers', () async { - const path = '/users/block'; - - when(() => client.get(path)).thenAnswer( - (_) async => successResponse(path, data: {}), - ); - - final res = await userApi.queryBlockedUsers(); - - expect(res, isNotNull); - - verify(() => client.get(path)).called(1); - verifyNoMoreInteractions(client); - }); - test('getActiveLiveLocations', () async { const path = '/users/live_locations'; diff --git a/packages/stream_chat/test/src/core/models/user_block_test.dart b/packages/stream_chat/test/src/core/models/user_block_test.dart deleted file mode 100644 index 18162de5d7..0000000000 --- a/packages/stream_chat/test/src/core/models/user_block_test.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:stream_chat/src/core/models/user.dart'; -import 'package:stream_chat/src/core/models/user_block.dart'; -import 'package:test/test.dart'; - -import '../../utils.dart'; - -void main() { - group('src/models/user_block', () { - test('should parse json correctly', () { - final userBlock = UserBlock.fromJson(jsonFixture('user_block.json')); - expect(userBlock.userId, 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e'); - expect(userBlock.blockedUserId, 'c1c9b454-2bcc-402d-8bb0-2f3706ce1680'); - expect( - userBlock.createdAt, - DateTime.parse('2020-01-28T22:17:30.83015Z'), - ); - expect(userBlock.user, isNotNull); - expect(userBlock.blockedUser, isNotNull); - }); - - test('should serialize to json correctly', () { - final userBlock = UserBlock( - user: User(id: 'user-1'), - blockedUser: User(id: 'user-2'), - userId: 'user-1', - blockedUserId: 'user-2', - createdAt: DateTime.parse('2020-01-28T22:17:30.830150Z'), - ); - - expect( - userBlock.toJson(), - { - 'user': {'id': 'user-1', 'teams': [], 'online': false, 'banned': false}, - 'blocked_user': {'id': 'user-2', 'teams': [], 'online': false, 'banned': false}, - 'user_id': 'user-1', - 'blocked_user_id': 'user-2', - 'created_at': '2020-01-28T22:17:30.830150Z', - }, - ); - }); - }); -}