From 965b176d3cccda07175cbb3bf21cbc21617e50e2 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 8 Oct 2026 12:41:47 +0200 Subject: [PATCH 1/2] refactor(llc)!: route user updates through the generated client Co-Authored-By: Claude Opus 5.5 --- .claude/skills/openapi-migration/SKILL.md | 6 +- migrations/v11-migration.md | 58 +++++ openapi-migration/09-users.md | 17 +- openapi-migration/20-user-updates.md | 74 ++++++ openapi-migration/README.md | 8 +- openapi-migration/tool/generate_plan.py | 82 +++++-- packages/stream_chat/CHANGELOG.md | 11 + .../stream_chat/lib/src/client/client.dart | 36 ++- .../lib/src/core/api/requests.dart | 26 -- .../lib/src/core/api/requests.g.dart | 11 - .../lib/src/core/api/responses.dart | 11 - .../lib/src/core/api/responses.g.dart | 8 - .../lib/src/core/api/user_api.dart | 26 -- .../lib/src/core/models/own_user.dart | 6 + .../request/update_user_partial_request.dart | 29 +++ .../update_user_partial_request.freezed.dart | 97 ++++++++ .../response/update_users_response.dart | 26 ++ .../update_users_response.freezed.dart | 86 +++++++ .../stream_chat/lib/src/core/models/user.dart | 37 ++- .../src/repository/mapper/user_mapper.dart | 51 ++++ .../lib/src/repository/users_repository.dart | 21 ++ packages/stream_chat/lib/stream_chat.dart | 2 + packages/stream_chat/test/fixtures/user.json | 5 +- .../client_connect_guest_user_test.dart | 5 + .../test/src/client/client_test.dart | 58 ----- .../client_update_users_partial_test.dart | 151 ++++++++++++ .../src/client/client_update_users_test.dart | 226 ++++++++++++++++++ .../test/src/core/api/responses_test.dart | 16 -- .../test/src/core/api/user_api_test.dart | 68 ------ .../test/src/core/models/user_test.dart | 40 ++++ 30 files changed, 1021 insertions(+), 277 deletions(-) create mode 100644 openapi-migration/20-user-updates.md create mode 100644 packages/stream_chat/lib/src/core/models/request/update_user_partial_request.dart create mode 100644 packages/stream_chat/lib/src/core/models/request/update_user_partial_request.freezed.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/update_users_response.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/update_users_response.freezed.dart create mode 100644 packages/stream_chat/test/src/client/client_update_users_partial_test.dart create mode 100644 packages/stream_chat/test/src/client/client_update_users_test.dart diff --git a/.claude/skills/openapi-migration/SKILL.md b/.claude/skills/openapi-migration/SKILL.md index 0a01cdd002..e3ee0e7bad 100644 --- a/.claude/skills/openapi-migration/SKILL.md +++ b/.claude/skills/openapi-migration/SKILL.md @@ -251,9 +251,9 @@ packages/stream_chat/lib/src/ ``` - **The role decides the folder.** An envelope goes in `response/` (`CreateUserGroupResponse`). A type the caller - passes in to shape a request goes in `request/`, whatever its name: `MessageDelivery`, and also - `PartialUpdateUserRequest`, `PaginationParams` and `ThreadOptions` when their groups migrate them. Every other - model stays in `models/` (`UserGroup`). Copy `lib/src/core/models/request/message_delivery.dart`. + passes in to shape a request goes in `request/`, whatever its name: `MessageDelivery`, `UpdateUserPartialRequest`, + and also `PaginationParams` and `ThreadOptions` when their groups migrate them. Every other model stays in + `models/` (`UserGroup`). Copy `lib/src/core/models/request/message_delivery.dart`. - **Models and envelopes** are `@freezed` classes with a const constructor and `@override final` fields. Copy `lib/src/core/models/user_group.dart` and `lib/src/core/models/response/create_user_group_response.dart`. Envelopes carry `required this.duration`, documented with an example value such as `4.21ms`, plus one field per diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index a1fda30e95..3750dd75c9 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -38,6 +38,7 @@ onto Stream's OpenAPI-generated API client. - [Read Receipts](#read-receipts) - [Unread Counts](#unread-counts) - [User Blocking](#user-blocking) + - [User Updates](#user-updates) - [Migration Checklist](#migration-checklist) - [For AI Agents](#for-ai-agents) - [Contributing to this guide](#contributing-to-this-guide) @@ -88,6 +89,7 @@ from the spec, so don't subclass them or depend on their private constructors. | [**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 | +| [**User Updates**](#user-updates) | `updateUser` and `updateUsers` return a `Result` instead of throwing; `partialUpdateUser(s)` becomes `updateUserPartial` / `updateUsersPartial` and `PartialUpdateUserRequest` becomes `UpdateUserPartialRequest`; `updateUser` no longer sends `role`, `teams` or `teamsRole`, and the returned users no longer carry their private fields in `extraData` | | _(filled in per feature as PRs land)_ | | --- @@ -278,6 +280,19 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `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 `?? …` | | `StreamChatApi.user.blockUser` / `unblockUser` / `queryBlockedUsers` | `StreamChatClient.blockUser` / `unblockUser` / `getBlockedUsers` | `removed` | The endpoints moved to the generated client | +| `StreamChatClient.updateUser` / `updateUsers` → `Future` | `Future>` | `retyped` | Returns a `Result` instead of throwing | +| `StreamChatClient.partialUpdateUser` | `StreamChatClient.updateUserPartial` | `renamed` | Same arguments | +| `StreamChatClient.partialUpdateUser` → `Future` | `updateUserPartial` → `Future>` | `retyped` | Returns a `Result` instead of throwing | +| `StreamChatClient.partialUpdateUsers` | `StreamChatClient.updateUsersPartial` | `renamed` | Takes `List` | +| `StreamChatClient.partialUpdateUsers` → `Future` | `updateUsersPartial` → `Future>` | `retyped` | Returns a `Result` instead of throwing | +| `PartialUpdateUserRequest` | `UpdateUserPartialRequest` | `renamed` | Same fields | +| `PartialUpdateUserRequest.toJson` | — | `removed` | `UpdateUserPartialRequest` is a plain class | +| `UpdateUsersResponse.fromJson` | — | `removed` | The response is a plain class; construct it directly | +| `PartialUpdateUserRequest.props` | — | `removed` | No longer an `Equatable`; still compares by value, and gains `copyWith` | +| `UpdateUsersResponse()..users = …` | `UpdateUsersResponse(duration: …, users: …)` | `retyped` | A plain class with a const constructor and final fields | +| `UpdateUsersResponse` identity `==` | value `==`, plus `copyWith` | `retyped` | Two instances with the same fields are now equal | +| `UpdateUsersResponse.duration` (`String?`) | `String` | `retyped` | Always present; drop any `!` or `?? ''` | +| `StreamChatApi.user.updateUsers` / `partialUpdateUsers` | `StreamChatClient.updateUsers` / `updateUsersPartial` | `removed` | The endpoints moved to the generated client | | _(more added per feature as PRs land)_ | | | | --- @@ -894,6 +909,49 @@ and gain `copyWith`, and their `duration` is always present. > pagination. The server always sends every `UserBlock` field, so the model no longer makes callers handle nulls > that never arrive. +### User Updates + +**`updateUser` and `updateUsers` return a `Result` instead of throwing, and +`partialUpdateUser` and `partialUpdateUsers` are renamed `updateUserPartial` and `updateUsersPartial`.** +`updateUserPartial` keeps its arguments; `updateUsersPartial` takes `UpdateUserPartialRequest`, renamed from +`PartialUpdateUserRequest`. A `try`/`catch` around them still compiles, but no longer catches a failed call: read +the returned `Result` instead. None of them changes `currentUser`; the `user.updated` event still does. + +```dart +// v10 +try { + await client.partialUpdateUser(userId, set: {'favorite_color': 'green'}); +} on StreamChatException catch (e) { + report(e); +} + +// v11 +final result = await client.updateUserPartial(userId, set: {'favorite_color': 'green'}); +if (result case Failure(:final error)) report(error); +``` + +**`updateUser` sends the user's name, image, language, visibility and custom data, and nothing else.** v10 sent +the whole user, so the user's online status, ban and timestamps, and an own user's devices, mutes and unread counts, +were stored as custom data on every call; that no longer happens. `role`, `teams` and `teamsRole` are no longer +sent, so these calls cannot change them. + +**The returned users no longer carry the fields only they can see in `extraData`.** v10 left every field `User` +does not model in `extraData` as raw JSON; now their devices, mutes, channel mutes, privacy settings, unread counts, blocked +user ids, hidden channels and token revocation time are left out. Read them from `currentUser`. `deactivatedAt`, +`deletedAt` and `shadowBanned` stay in `extraData` and gain typed getters on `User`; each one is filled whenever the +response carries it. + +**`UpdateUsersResponse` no longer decodes from JSON.** It is a plain class with a const constructor and final +fields, compares by value and gains `copyWith`, and its `duration` is always present. `UpdateUserPartialRequest` +no longer encodes to JSON or extends `Equatable`; it still compares by value and gains `copyWith`. + +**`StreamChatApi.user.updateUsers` and `partialUpdateUsers` are removed.** Call `updateUsers` and +`updateUsersPartial` on `StreamChatClient`. + +> **Why:** the endpoints moved onto the generated client, which returns a `Result` for every call. The partial +> updates take the API's names, as the channel and member partial updates already do. The request carries only +> what the user can change, so nothing of the client's state ends up stored as custom data. + ### 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 f33ead4ebe..6703cfcff6 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, and [19](19-user-blocking.md) blocking 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, [19](19-user-blocking.md) blocking users, and [20](20-user-updates.md) updating users. -**Size:** 5 hand-written method(s) across 1 file(s) → 5 generated operation(s). +**Size:** 3 hand-written method(s) across 1 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. @@ -14,8 +14,6 @@ | File | Method | Returns | | --- | --- | --- | | `user_api.dart` | `queryUsers` | `QueryUsersResponse` | -| `user_api.dart` | `updateUsers` | `UpdateUsersResponse` | -| `user_api.dart` | `partialUpdateUsers` | `UpdateUsersResponse` | | `user_api.dart` | `getActiveLiveLocations` | `GetActiveLiveLocationsResponse` | | `user_api.dart` | `updateLiveLocation` | `Location` | @@ -26,22 +24,21 @@ | `GET` | `/api/v2/users/live_locations` | `getUserLiveLocations` | `SharedLocationsResponse` | | `GET` | `/api/v2/users` | `queryUsers` | `QueryUsersResponse` | | `PUT` | `/api/v2/users/live_locations` | `updateLiveLocation` | `SharedLocationResponse` | -| `POST` | `/api/v2/users` | `updateUsers` | `UpdateUsersResponse` | -| `PATCH` | `/api/v2/users` | `updateUsersPartial` | `UpdateUsersResponse` | ## Decisions to make - `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 current class for every group before it, and stay. See [01-foundation](01-foundation.md). - `PrivacySettings` and the push-preference sub-shapes — decide per type. -- `UserResponse.toModel()` leaves `OwnUser.topLevelFields`, `deleted_at`, `deactivated_at` and `revoke_tokens_issued_before` out of every user's `extraData` (`_shadowedCustomKeys`), where v1 kept the `OwnUser`-only keys in a plain user's `extraData`. Revisit once the mapper serves plain users. -- `UserFilterField.shadowBanned` and `.bypassModeration` read `extraData`, which the generated `UserResponse` has no field to fill. -- `updateUsers` reuses `User.toRequest()`. It is a full upsert, and v10's flattened body filed the user's client state (`online`, `banned`, `created_at` and similar) into the stored custom data on every call, as it did for guests in [04](04-roles-guest-and-app.md). Decide the same way here, for real users rather than fresh guests, and record it in the CHANGELOG. -- The generated `UserRequest` sends explicit `null` for an unset `language` or `invisible`, where v10 left the key out. For a guest create that made no difference; for an upsert of an existing user, confirm live that a `null` does not reset a stored value differently from an omitted key. +- `UserResponse.toModel()` and `FullUserResponse.toModel()` drop custom data named like one of the user's own fields (`_shadowedCustomKeys`): `deactivated_at`, `deleted_at` and `shadow_banned` are refilled from the typed fields, and the `OwnUser`-only keys and `revoke_tokens_issued_before` are left out, where v1 kept them in a plain user's `extraData`. Revisit once the mapper serves plain users. +- **Whether `User` promotes its `extraData`-backed getters to real fields.** `User.deactivatedAt`, `deletedAt` and `shadowBanned`, added in [20](20-user-updates.md) the way `Member` promoted its fields, arrive as root fields but live in `extraData` and are read back through getters; the constructors write them there. Promoting them is a break: their keys leave `extraData`, a key set in `extraData` no longer sets the field, and a `custom` filter or sort field naming one reads null locally. When promoting: (1) decide each field's `merge` rule (`OwnUser.merge` takes the other user's `extraData` whole today); (2) make `toJson` leave out `extraData` keys named like a field; (3) keep the constructor copying `extraData`; (4) add persistence columns; (5) point `UserFilterField.shadowBanned` at the field. Decide together with [11](11-channels-and-members.md)'s `ChannelModel` and `Member` promotions. `revokeTokensIssuedBefore` stays out of `User`. +- `UserFilterField.bypassModeration` reads `extraData`, which no generated user has a field to fill. +- `queryUsers` answers `FullUserResponse`, which [20](20-user-updates.md) already maps to `User`; the caller's own entry could map to `OwnUser` once `Mute`, `ChannelMute` and `PrivacySettings` have response mappers. ## Risks - Every other group depends on the `User` decision. - User data arrives over the WebSocket on nearly every event. +- `ConnectUserDetails.fromOwnUser` flattens the whole `extraData` into the connect payload, so the `deactivated_at`, `deleted_at` and `shadow_banned` entries the [20](20-user-updates.md) getters read go back to the server as custom data (v10 already did this for `shadow_banned`). Strip the user's own keys there when the WebSocket moves to v2. - Landing `UserResponse` -> `User` unblocks the two fields [08](08-moderation-and-blocklists.md) had to drop from `MuteUsersResponse`: the `mutes` the call created and the `ownUser` it left behind. Adding them is additive for anyone reading the response, so revisit them here rather than leaving them dropped for good. ## Definition of done diff --git a/openapi-migration/20-user-updates.md b/openapi-migration/20-user-updates.md new file mode 100644 index 0000000000..0cce36a308 --- /dev/null +++ b/openapi-migration/20-user-updates.md @@ -0,0 +1,74 @@ +# 20 — User Updates + +**Goal:** Move creating, updating and partially updating users ahead of [09](09-users.md): the response maps onto the current `User`, and nothing persists it. + +**Size:** 0 hand-written method(s) across 0 file(s) → 2 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` | `updateUsers` | `UpdateUsersResponse` | +| `PATCH` | `/api/v2/users` | `updateUsersPartial` | `UpdateUsersResponse` | + +## Decisions taken + +- **Split out of [09](09-users.md), ahead of it.** `updateUser`, `updateUsers`, `partialUpdateUser` and + `partialUpdateUsers` read nothing into client state, as in v10; the `user.updated` event does. +- **The v2 routes are the v1 handlers.** `lib/core/api/users/routes.go` mounts `UpdateUsers` and + `UpdateUsersPartial` in the common routes, at the root and under `/api/v2/`; only the JSON encoding + differs. Neither is gated, in beta or deprecated. +- **The upsert sends `User.toRequest()`.** v10 sent the whole user flattened, and v1 stored every key + its request does not declare as custom data: the user's `online`, `banned` and timestamps, and an own + user's devices, mutes and unread counts. v2 drops unknown keys, so that stops, as it did for guests + in [04](04-roles-guest-and-app.md). `User.toRequest()` also leaves out the own-user keys an `OwnUser` + decoded from the connection keeps in `extraData` (`unread_count`, `total_unread_count_by_team`, + `latest_hidden_channels`), which v2 would otherwise store as custom data. `role`, `teams` and + `teams_role` are no longer sent: a client-side token never stores them, and a different role was + refused. +- **Moved off `UserApi`:** `updateUsers` and `partialUpdateUsers`, now `StreamChatClient` methods over + `UsersRepository`. +- **Explicit `null`s are not a regression.** The generated request sends `language: null` and + `invisible: null` where v10 left the keys out; the server reads a null or missing language as empty, + and a null or missing `invisible` as unchanged. +- **The partial updates take the API's names,** `updateUserPartial` and `updateUsersPartial`, with + `UpdateUserPartialRequest` (renamed from `PartialUpdateUserRequest`, freezed, without `toJson`), as + the channel and member partial updates already do. `updateUserPartial` keeps v10's arguments. +- **`UpdateUsersResponse` keeps its name** and maps each `FullUserResponse` to a `User`. No public + full-user type: a client-side caller can only update itself, the server blanks the private fields + for anyone else, and the caller's own are on `currentUser`. `membership_deletion_task_id` is always + empty and stays out. +- **`User` gains `deactivatedAt`, `deletedAt` and `shadowBanned`** as getters over `extraData`, the + way `Member` promoted its fields. Each generated user mapper fills the ones its response carries + (`UserResponse` has no `shadowBanned`), so the values v1 left raw in `extraData` are no longer lost; + persistence needs no change. + +## 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] `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 18a0ca366c..2e2d2ac530 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 | 5 | 5 | ☐ | +| [09](09-users.md) | Users | 3 | 3 | ☐ | | [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 | ☑ | @@ -31,8 +31,9 @@ generated operations in scope, the decisions that group has to make, its risks, | [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 | ☑ | +| [20](20-user-updates.md) | User Updates — split out of 09 | 0 | 2 | ☑ | -**Coverage:** 66 hand-written methods across 10 files, and all 129 generated operations, each claimed by exactly +**Coverage:** 64 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). @@ -181,6 +182,9 @@ surfaces before it reaches `Message` and `ChannelState`: - **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. +- **20** is creating, updating and partially updating users, split out of 09 because its response maps onto the + current `User` and nothing persists it. It also promotes `deactivatedAt`, `deletedAt` and `shadowBanned` onto + `User` as getters over `extraData`. - **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 dd7d4fe241..36c45449a9 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -516,31 +516,42 @@ def match(verb, path): dict( num='09', slug='users', title='Users', hand=['user_api.dart'], - match=owns('/api/v2/users', unless=('/api/v2/users/block', '/api/v2/users/unblock')), + match=owns('/api/v2/users', unless=('/api/v2/users/block', '/api/v2/users/unblock'), + unless_ops=('POST /api/v2/users', 'PATCH /api/v2/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, and ' - '[19](19-user-blocking.md) blocking users.', + 'most. [18](18-unread-counts.md) split the current user\'s unread counts out of it, ' + '[19](19-user-blocking.md) blocking users, and [20](20-user-updates.md) updating 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 ' 'current class for every group before it, and stay. See [01-foundation](01-foundation.md).', '`PrivacySettings` and the push-preference sub-shapes — decide per type.', - '`UserResponse.toModel()` leaves `OwnUser.topLevelFields`, `deleted_at`, `deactivated_at` and ' - '`revoke_tokens_issued_before` out of every user\'s `extraData` (`_shadowedCustomKeys`), where v1 kept the ' - '`OwnUser`-only keys in a plain user\'s `extraData`. Revisit once the mapper serves plain users.', - '`UserFilterField.shadowBanned` and `.bypassModeration` read `extraData`, which the generated ' - '`UserResponse` has no field to fill.', - '`updateUsers` reuses `User.toRequest()`. It is a full upsert, and v10\'s flattened body filed the ' - 'user\'s client state (`online`, `banned`, `created_at` and similar) into the stored custom data on ' - 'every call, as it did for guests in [04](04-roles-guest-and-app.md). Decide the same way here, for real ' - 'users rather than fresh guests, and record it in the CHANGELOG.', - 'The generated `UserRequest` sends explicit `null` for an unset `language` or `invisible`, where v10 ' - 'left the key out. For a guest create that made no difference; for an upsert of an existing user, ' - 'confirm live that a `null` does not reset a stored value differently from an omitted key.', + '`UserResponse.toModel()` and `FullUserResponse.toModel()` drop custom data named like one of the user\'s ' + 'own fields (`_shadowedCustomKeys`): `deactivated_at`, `deleted_at` and `shadow_banned` are refilled from ' + 'the typed fields, and the `OwnUser`-only keys and `revoke_tokens_issued_before` are left out, where v1 ' + 'kept them in a plain user\'s `extraData`. Revisit once the mapper serves plain users.', + '**Whether `User` promotes its `extraData`-backed getters to real fields.** `User.deactivatedAt`, ' + '`deletedAt` and `shadowBanned`, added in [20](20-user-updates.md) the way `Member` promoted its fields, ' + 'arrive as root fields but live in `extraData` and are read back through getters; the constructors write ' + 'them there. Promoting them is a break: their keys leave `extraData`, a key set in `extraData` no longer ' + 'sets the field, and a `custom` filter or sort field naming one reads null locally. When promoting: ' + '(1) decide each field\'s `merge` rule (`OwnUser.merge` takes the other user\'s `extraData` whole today); ' + '(2) make `toJson` leave out `extraData` keys named like a field; (3) keep the constructor copying ' + '`extraData`; (4) add persistence columns; (5) point `UserFilterField.shadowBanned` at the field. Decide ' + 'together with [11](11-channels-and-members.md)\'s `ChannelModel` and `Member` promotions. ' + '`revokeTokensIssuedBefore` stays out of `User`.', + '`UserFilterField.bypassModeration` reads `extraData`, which no generated user has a field to fill.', + '`queryUsers` answers `FullUserResponse`, which [20](20-user-updates.md) already maps to `User`; the ' + 'caller\'s own entry could map to `OwnUser` once `Mute`, `ChannelMute` and `PrivacySettings` have ' + 'response mappers.', ], risks=[ 'Every other group depends on the `User` decision.', 'User data arrives over the WebSocket on nearly every event.', + '`ConnectUserDetails.fromOwnUser` flattens the whole `extraData` into the connect payload, so the ' + '`deactivated_at`, `deleted_at` and `shadow_banned` entries the [20](20-user-updates.md) getters read go ' + 'back to the server as custom data (v10 already did this for `shadow_banned`). Strip the user\'s own ' + 'keys there when the WebSocket moves to v2.', 'Landing `UserResponse` -> `User` unblocks the two fields [08](08-moderation-and-blocklists.md) ' 'had to drop from `MuteUsersResponse`: the `mutes` the call created and the `ownUser` it left ' 'behind. Adding them is additive for anyone reading the response, so revisit them here rather ' @@ -978,6 +989,47 @@ def match(verb, path): risks=[], done=DONE.replace('- [ ]', '- [x]'), ), + dict( + num='20', slug='user-updates', title='User Updates', + hand=[], + match=only_ops('POST /api/v2/users', 'PATCH /api/v2/users'), + goal='Move creating, updating and partially updating users ahead of [09](09-users.md): the response maps ' + 'onto the current `User`, and nothing persists it.', + decisions=[], + taken=textwrap.dedent("""\ + - **Split out of [09](09-users.md), ahead of it.** `updateUser`, `updateUsers`, `partialUpdateUser` and + `partialUpdateUsers` read nothing into client state, as in v10; the `user.updated` event does. + - **The v2 routes are the v1 handlers.** `lib/core/api/users/routes.go` mounts `UpdateUsers` and + `UpdateUsersPartial` in the common routes, at the root and under `/api/v2/`; only the JSON encoding + differs. Neither is gated, in beta or deprecated. + - **The upsert sends `User.toRequest()`.** v10 sent the whole user flattened, and v1 stored every key + its request does not declare as custom data: the user's `online`, `banned` and timestamps, and an own + user's devices, mutes and unread counts. v2 drops unknown keys, so that stops, as it did for guests + in [04](04-roles-guest-and-app.md). `User.toRequest()` also leaves out the own-user keys an `OwnUser` + decoded from the connection keeps in `extraData` (`unread_count`, `total_unread_count_by_team`, + `latest_hidden_channels`), which v2 would otherwise store as custom data. `role`, `teams` and + `teams_role` are no longer sent: a client-side token never stores them, and a different role was + refused. + - **Moved off `UserApi`:** `updateUsers` and `partialUpdateUsers`, now `StreamChatClient` methods over + `UsersRepository`. + - **Explicit `null`s are not a regression.** The generated request sends `language: null` and + `invisible: null` where v10 left the keys out; the server reads a null or missing language as empty, + and a null or missing `invisible` as unchanged. + - **The partial updates take the API's names,** `updateUserPartial` and `updateUsersPartial`, with + `UpdateUserPartialRequest` (renamed from `PartialUpdateUserRequest`, freezed, without `toJson`), as + the channel and member partial updates already do. `updateUserPartial` keeps v10's arguments. + - **`UpdateUsersResponse` keeps its name** and maps each `FullUserResponse` to a `User`. No public + full-user type: a client-side caller can only update itself, the server blanks the private fields + for anyone else, and the caller's own are on `currentUser`. `membership_deletion_task_id` is always + empty and stays out. + - **`User` gains `deactivatedAt`, `deletedAt` and `shadowBanned`** as getters over `extraData`, the + way `Member` promoted its fields. Each generated user mapper fills the ones its response carries + (`UserResponse` has no `shadowBanned`), so the values v1 left raw in `extraData` are no longer lost; + persistence needs no change. + """), + risks=[], + done=DONE.replace('- [ ]', '- [x]'), + ), ] diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index a402ae2f3b..af0b2e58f6 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -111,11 +111,21 @@ - `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. - `StreamChatApi.user.blockUser`, `unblockUser` and `queryBlockedUsers` are removed; call `blockUser`, `unblockUser` and `getBlockedUsers` on `StreamChatClient` instead. +- `StreamChatClient.updateUser` and `updateUsers` return a `Result` instead of throwing. +- `StreamChatClient.partialUpdateUser` and `partialUpdateUsers` are renamed `updateUserPartial` and `updateUsersPartial`, and return a `Result` instead of throwing. +- `PartialUpdateUserRequest` is renamed `UpdateUserPartialRequest`. +- `UpdateUserPartialRequest` no longer encodes to JSON or extends `Equatable`, so `props` is removed; it still compares by value and gains `copyWith`. +- `UpdateUsersResponse` no longer decodes from JSON, is immutable, built through a const constructor, compares by value, gains `copyWith`, and its `duration` is a non-nullable `String`. +- The users in an `UpdateUsersResponse` no longer carry their devices, mutes, channel mutes, privacy settings, unread counts, blocked user ids, hidden channels or token revocation time in `extraData`. +- `updateUser` and `updateUsers` no longer send the user's `role`, `teams` and `teamsRole`. +- `StreamChatApi.user.updateUsers` and `partialUpdateUsers` are removed; call `updateUsers` and `updateUsersPartial` on `StreamChatClient` instead. 🐞 Fixed - `connectGuestUser` no longer copies the user's online status, ban, unread counts, devices and push preferences into the created guest's `extraData`. +- `updateUser` and `updateUsers` no longer store the user's online status, ban and timestamps, or an own user's devices, mutes and unread counts, as custom data. + - `queryChannels` no longer gives up watching the channels it loads when it is called before the connection is open. Whether they can be watched is read when the request is sent, which is after the query has waited for the connection. - A message that fails because its attachments did not upload now says which ones and what each reported, instead of `Failed to upload one or more attachments`. @@ -141,6 +151,7 @@ - Added an optional `timeout` to `StreamChatClient.moderation.muteUser` that expires the mute. - Added `StreamChatClient.moderation.muteUsers` and `unmuteUsers`, which mute and unmute several users in one call and report the ids that matched no user. - Added `ChannelModel.truncatedBy`, `autoTranslationEnabled` and `autoTranslationLanguage`, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, `futureChannelBanExpires`, `deletedAt` and `topLevelFields`; the constructors accept the new fields. +- Added `User.deactivatedAt`, `deletedAt` and `shadowBanned`; the constructors accept the new fields. 🔒 Security diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index a78ec4d2e8..3c8891a91c 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -60,6 +60,7 @@ import '../core/models/push_preference.dart'; import '../core/models/push_provider.dart'; import '../core/models/reaction.dart'; import '../core/models/request/message_delivery.dart'; +import '../core/models/request/update_user_partial_request.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'; @@ -82,6 +83,7 @@ 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'; +import '../core/models/response/update_users_response.dart'; import '../core/models/role_type.dart'; import '../core/models/thread.dart'; import '../core/models/user.dart'; @@ -1764,32 +1766,26 @@ class StreamChatClient { pagination: pagination, ); - /// Update or Create the given user object. - Future updateUser(User user) => updateUsers([user]); + /// Creates [user], or replaces the data of the existing user with the same id. + /// + /// This cannot change the user's role, teams or team roles. To change only some fields, consider + /// [updateUserPartial]. + Future> updateUser(User user) => updateUsers([user]); - /// Batch update a list of users - Future updateUsers(List users) => _chatApi.user.updateUsers(users); + /// Creates or replaces each of [users], as [updateUser] does for one. + Future> updateUsers(List users) => _usersRepository.updateUsers(users); - /// Partially update the given user with [id]. - /// Use [set] to define values to be set. - /// Use [unset] to define values to be unset. - Future partialUpdateUser( + /// Partially updates the user with [id]: sets the fields in [set] and removes the fields named in [unset], leaving + /// every other field as it is. + Future> updateUserPartial( String id, { Map? set, List? unset, - }) { - final user = PartialUpdateUserRequest( - id: id, - set: set, - unset: unset, - ); - return partialUpdateUsers([user]); - } + }) => updateUsersPartial([UpdateUserPartialRequest(id: id, set: set, unset: unset)]); - /// Batch partial updates the [users]. - Future partialUpdateUsers( - List users, - ) => _chatApi.user.partialUpdateUsers(users); + /// Partially updates several users at once, applying each of [updates] to the user it names. + Future> updateUsersPartial(List updates) => + _usersRepository.updateUsersPartial(updates); final _userBlockLock = Lock(); diff --git a/packages/stream_chat/lib/src/core/api/requests.dart b/packages/stream_chat/lib/src/core/api/requests.dart index 26ea16f9d7..c566daf837 100644 --- a/packages/stream_chat/lib/src/core/api/requests.dart +++ b/packages/stream_chat/lib/src/core/api/requests.dart @@ -141,32 +141,6 @@ class PaginationParams extends Equatable { ]; } -/// Request model for the [client.partialUpdateUser] api call. -@JsonSerializable(createFactory: false) -class PartialUpdateUserRequest extends Equatable { - /// Creates a new PartialUpdateUserRequest instance. - const PartialUpdateUserRequest({ - required this.id, - this.set, - this.unset, - }); - - /// User ID. - final String id; - - /// Fields to set. - final Map? set; - - /// Fields to unset. - final List? unset; - - /// Serialize model to json - Map toJson() => _$PartialUpdateUserRequestToJson(this); - - @override - List get props => [id, set, unset]; -} - /// {@template threadOptions} /// Options for querying threads. /// {@endtemplate} diff --git a/packages/stream_chat/lib/src/core/api/requests.g.dart b/packages/stream_chat/lib/src/core/api/requests.g.dart index 8d2380876a..73d38b0d70 100644 --- a/packages/stream_chat/lib/src/core/api/requests.g.dart +++ b/packages/stream_chat/lib/src/core/api/requests.g.dart @@ -42,17 +42,6 @@ Map _$PaginationParamsToJson(PaginationParams instance) => _$PartialUpdateUserRequestToJson( - PartialUpdateUserRequest instance, -) => { - 'stringify': instance.stringify, - 'hash_code': instance.hashCode, - 'id': instance.id, - 'set': instance.set, - 'unset': instance.unset, - 'props': instance.props, -}; - Map _$ThreadOptionsToJson(ThreadOptions instance) => { 'stringify': instance.stringify, 'hash_code': instance.hashCode, diff --git a/packages/stream_chat/lib/src/core/api/responses.dart b/packages/stream_chat/lib/src/core/api/responses.dart index 81bf723f4e..9eae1c6a3c 100644 --- a/packages/stream_chat/lib/src/core/api/responses.dart +++ b/packages/stream_chat/lib/src/core/api/responses.dart @@ -160,17 +160,6 @@ class SendReactionResponse extends MessageResponse { static SendReactionResponse fromJson(Map json) => _$SendReactionResponseFromJson(json); } -/// Model response for [StreamChatClient.updateUser] api call -@JsonSerializable(createToJson: false) -class UpdateUsersResponse extends _BaseResponse { - /// Updated users - @JsonKey(defaultValue: {}) - late Map users; - - /// Create a new instance from a json - static UpdateUsersResponse fromJson(Map json) => _$UpdateUsersResponseFromJson(json); -} - /// Base Model response for message based api calls. class MessageResponse extends _BaseResponse { /// Message returned by the api call 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 81fe1ac9b9..5b73fa7e37 100644 --- a/packages/stream_chat/lib/src/core/api/responses.g.dart +++ b/packages/stream_chat/lib/src/core/api/responses.g.dart @@ -82,14 +82,6 @@ SendReactionResponse _$SendReactionResponseFromJson( ..message = Message.fromJson(json['message'] as Map) ..reaction = Reaction.fromJson(json['reaction'] as Map); -UpdateUsersResponse _$UpdateUsersResponseFromJson(Map json) => UpdateUsersResponse() - ..duration = json['duration'] as String? - ..users = - (json['users'] as Map?)?.map( - (k, e) => MapEntry(k, User.fromJson(e as Map)), - ) ?? - {}; - UpdateMessageResponse _$UpdateMessageResponseFromJson( Map json, ) => UpdateMessageResponse() 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 8940d993fa..d5c6e4557a 100644 --- a/packages/stream_chat/lib/src/core/api/user_api.dart +++ b/packages/stream_chat/lib/src/core/api/user_api.dart @@ -38,32 +38,6 @@ class UserApi { return QueryUsersResponse.fromJson(response.data); } - /// Batch update a list of users - Future updateUsers( - List users, - ) async { - final response = await _client.post( - '/users', - data: { - 'users': {for (final user in users) user.id: user}, - }, - ); - return UpdateUsersResponse.fromJson(response.data); - } - - /// Batch partial update of [users]. - Future partialUpdateUsers( - List users, - ) async { - final response = await _client.patch( - '/users', - data: { - 'users': users, - }, - ); - return UpdateUsersResponse.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/own_user.dart b/packages/stream_chat/lib/src/core/models/own_user.dart index d509f02684..0cf7e541da 100644 --- a/packages/stream_chat/lib/src/core/models/own_user.dart +++ b/packages/stream_chat/lib/src/core/models/own_user.dart @@ -12,6 +12,9 @@ part 'own_user.g.dart'; @JsonSerializable(includeIfNull: false) class OwnUser extends User { /// Constructor used for json serialization. + /// + /// The [deactivatedAt], [deletedAt] and [shadowBanned] arguments, when given, are stored in [extraData], replacing + /// the entry each one reads. OwnUser({ this.devices = const [], this.mutes = const [], @@ -38,6 +41,9 @@ class OwnUser extends User { super.invisible, super.teamsRole, super.avgResponseTime, + super.deactivatedAt, + super.deletedAt, + super.shadowBanned, }); /// Create a new instance from json. diff --git a/packages/stream_chat/lib/src/core/models/request/update_user_partial_request.dart b/packages/stream_chat/lib/src/core/models/request/update_user_partial_request.dart new file mode 100644 index 0000000000..5d953f7207 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/request/update_user_partial_request.dart @@ -0,0 +1,29 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'update_user_partial_request.freezed.dart'; + +/// A partial update of one user: the fields in [set] are set and the fields named in [unset] are removed, leaving +/// every other field as it is. +/// +/// Passed to [StreamChatClient.updateUsersPartial]. +@freezed +class UpdateUserPartialRequest with _$UpdateUserPartialRequest { + /// Creates a partial update of the user with [id]. + const UpdateUserPartialRequest({ + required this.id, + this.set, + this.unset, + }); + + /// The id of the user to update. + @override + final String id; + + /// The fields to set, keyed by name. + @override + final Map? set; + + /// The names of the fields to remove. + @override + final List? unset; +} diff --git a/packages/stream_chat/lib/src/core/models/request/update_user_partial_request.freezed.dart b/packages/stream_chat/lib/src/core/models/request/update_user_partial_request.freezed.dart new file mode 100644 index 0000000000..4923b940f9 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/request/update_user_partial_request.freezed.dart @@ -0,0 +1,97 @@ +// 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 'update_user_partial_request.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$UpdateUserPartialRequest { + String get id; + Map? get set; + List? get unset; + + /// Create a copy of UpdateUserPartialRequest + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $UpdateUserPartialRequestCopyWith get copyWith => + _$UpdateUserPartialRequestCopyWithImpl( + this as UpdateUserPartialRequest, + _$identity, + ); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is UpdateUserPartialRequest && + (identical(other.id, id) || other.id == id) && + const DeepCollectionEquality().equals(other.set, set) && + const DeepCollectionEquality().equals(other.unset, unset)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + id, + const DeepCollectionEquality().hash(set), + const DeepCollectionEquality().hash(unset), + ); + + @override + String toString() { + return 'UpdateUserPartialRequest(id: $id, set: $set, unset: $unset)'; + } +} + +/// @nodoc +abstract mixin class $UpdateUserPartialRequestCopyWith<$Res> { + factory $UpdateUserPartialRequestCopyWith( + UpdateUserPartialRequest value, + $Res Function(UpdateUserPartialRequest) _then, + ) = _$UpdateUserPartialRequestCopyWithImpl; + @useResult + $Res call({String id, Map? set, List? unset}); +} + +/// @nodoc +class _$UpdateUserPartialRequestCopyWithImpl<$Res> implements $UpdateUserPartialRequestCopyWith<$Res> { + _$UpdateUserPartialRequestCopyWithImpl(this._self, this._then); + + final UpdateUserPartialRequest _self; + final $Res Function(UpdateUserPartialRequest) _then; + + /// Create a copy of UpdateUserPartialRequest + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? set = freezed, + Object? unset = freezed, + }) { + return _then( + UpdateUserPartialRequest( + id: null == id + ? _self.id + : id // ignore: cast_nullable_to_non_nullable + as String, + set: freezed == set + ? _self.set + : set // ignore: cast_nullable_to_non_nullable + as Map?, + unset: freezed == unset + ? _self.unset + : unset // ignore: cast_nullable_to_non_nullable + as List?, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/response/update_users_response.dart b/packages/stream_chat/lib/src/core/models/response/update_users_response.dart new file mode 100644 index 0000000000..6d7a9bfc31 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/update_users_response.dart @@ -0,0 +1,26 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../user.dart'; + +part 'update_users_response.freezed.dart'; + +/// The users after an update. +/// +/// Returned by [StreamChatClient.updateUser], [StreamChatClient.updateUsers], [StreamChatClient.updateUserPartial] +/// and [StreamChatClient.updateUsersPartial]. +@freezed +class UpdateUsersResponse with _$UpdateUsersResponse { + /// Creates a new [UpdateUsersResponse]. + const UpdateUsersResponse({ + required this.duration, + required this.users, + }); + + /// How long the request took to handle, such as `4.21ms`. + @override + final String duration; + + /// The updated users, keyed by id. + @override + final Map users; +} diff --git a/packages/stream_chat/lib/src/core/models/response/update_users_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/update_users_response.freezed.dart new file mode 100644 index 0000000000..c549d538cf --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/update_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 'update_users_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$UpdateUsersResponse { + String get duration; + Map get users; + + /// Create a copy of UpdateUsersResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $UpdateUsersResponseCopyWith get copyWith => + _$UpdateUsersResponseCopyWithImpl( + this as UpdateUsersResponse, + _$identity, + ); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is UpdateUsersResponse && + (identical(other.duration, duration) || other.duration == duration) && + const DeepCollectionEquality().equals(other.users, users)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + duration, + const DeepCollectionEquality().hash(users), + ); + + @override + String toString() { + return 'UpdateUsersResponse(duration: $duration, users: $users)'; + } +} + +/// @nodoc +abstract mixin class $UpdateUsersResponseCopyWith<$Res> { + factory $UpdateUsersResponseCopyWith( + UpdateUsersResponse value, + $Res Function(UpdateUsersResponse) _then, + ) = _$UpdateUsersResponseCopyWithImpl; + @useResult + $Res call({String duration, Map users}); +} + +/// @nodoc +class _$UpdateUsersResponseCopyWithImpl<$Res> implements $UpdateUsersResponseCopyWith<$Res> { + _$UpdateUsersResponseCopyWithImpl(this._self, this._then); + + final UpdateUsersResponse _self; + final $Res Function(UpdateUsersResponse) _then; + + /// Create a copy of UpdateUsersResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? duration = null, Object? users = null}) { + return _then( + UpdateUsersResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + users: null == users + ? _self.users + : users // ignore: cast_nullable_to_non_nullable + as Map, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/user.dart b/packages/stream_chat/lib/src/core/models/user.dart index 4719012b2e..d44ec87f76 100644 --- a/packages/stream_chat/lib/src/core/models/user.dart +++ b/packages/stream_chat/lib/src/core/models/user.dart @@ -34,6 +34,9 @@ class User extends Equatable { /// print(user.image == user.extraData['image']); // true /// ``` /// {@endtemplate} + /// + /// The [deactivatedAt], [deletedAt] and [shadowBanned] arguments, when given, are stored in [extraData], replacing + /// the entry each one reads. User({ required this.id, this.role, @@ -51,11 +54,18 @@ class User extends Equatable { this.teamsRole, this.avgResponseTime, Map extraData = const {}, - }) : // For backwards compatibility, set 'name', 'image' in [extraData]. + DateTime? deactivatedAt, + DateTime? deletedAt, + bool? shadowBanned, + }) : // 'name' and 'image' are set in [extraData] for backwards compatibility; the others so they round-trip through + // toJson and fromJson without fields of their own. extraData = { ...extraData, if (name != null) 'name': name, if (image != null) 'image': image, + if (deactivatedAt != null) 'deactivated_at': deactivatedAt.toUtc().toIso8601String(), + if (deletedAt != null) 'deleted_at': deletedAt.toUtc().toIso8601String(), + if (shadowBanned != null) 'shadow_banned': shadowBanned, }; /// Create a new instance from json. @@ -147,6 +157,29 @@ class User extends Equatable { /// Map of custom user extraData. final Map extraData; + /// The date at which the user was deactivated. + /// + /// Null while the user is active, or when not known. + @JsonKey(includeToJson: false, includeFromJson: false) + DateTime? get deactivatedAt => _date('deactivated_at'); + + /// The date at which the user was deleted. + /// + /// Null while the user exists, or when not known. + @JsonKey(includeToJson: false, includeFromJson: false) + DateTime? get deletedAt => _date('deleted_at'); + + /// Whether the user is shadow banned, so that their messages reach no one else without them being told. + /// + /// Null when not known. + @JsonKey(includeToJson: false, includeFromJson: false) + bool? get shadowBanned => extraData['shadow_banned'].safeCast(); + + DateTime? _date(String key) => switch (extraData[key]) { + final String value => DateTime.tryParse(value), + _ => null, + }; + /// List of users to list of userIds. static List? toIds(List? users) => users?.map((u) => u.id).toList(); @@ -302,7 +335,7 @@ class UserFilterField extends FilterField { /// **Supported operators:** `$eq` static final shadowBanned = UserFilterField( 'shadow_banned', - (it) => it.extraData['shadow_banned'].safeCast(), + (it) => it.shadowBanned, ); /// Filters users by whether they bypass moderation. 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 e21488f28d..c3f819b81f 100644 --- a/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart @@ -1,11 +1,13 @@ import '../../../open_api/api.dart' as api; import '../../core/models/own_user.dart'; import '../../core/models/privacy_settings.dart'; +import '../../core/models/request/update_user_partial_request.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/response/update_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'; @@ -22,7 +24,11 @@ extension UserResponseMapper on api.UserResponse { ...OwnUser.topLevelFields, 'deleted_at', 'deactivated_at', + 'shadow_banned', 'revoke_tokens_issued_before', + 'unread_count', + 'total_unread_count_by_team', + 'latest_hidden_channels', }; /// Converts this response into a [User]. @@ -43,6 +49,36 @@ extension UserResponseMapper on api.UserResponse { teamsRole: teamsRole, avgResponseTime: avgResponseTime, extraData: {...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), + deactivatedAt: deactivatedAt, + deletedAt: deletedAt, + ); +} + +/// Maps a generated [api.FullUserResponse] to a [User]. +extension FullUserResponseMapper on api.FullUserResponse { + /// Converts this response into a [User]. + /// + /// Custom data named like one of the user's own fields is left out of [User.extraData]. + User toModel() => User( + id: id, + role: role, + name: name, + image: image, + createdAt: createdAt, + updatedAt: updatedAt, + lastActive: lastActive, + online: online, + banned: banned, + banExpires: banExpires, + teams: teams, + language: language, + invisible: invisible, + teamsRole: teamsRole, + avgResponseTime: avgResponseTime, + extraData: {...custom}..removeWhere((key, _) => UserResponseMapper._shadowedCustomKeys.contains(key)), + deactivatedAt: deactivatedAt, + deletedAt: deletedAt, + shadowBanned: shadowBanned, ); } @@ -182,3 +218,18 @@ extension BlockedUserResponseMapper on api.BlockedUserResponse { createdAt: createdAt, ); } + +/// Maps a generated [api.UpdateUsersResponse] to an [UpdateUsersResponse]. +extension UpdateUsersResponseMapper on api.UpdateUsersResponse { + /// Converts this response into an [UpdateUsersResponse]. + UpdateUsersResponse toModel() => UpdateUsersResponse( + duration: duration, + users: users.map((id, user) => MapEntry(id, user.toModel())), + ); +} + +/// Maps an [UpdateUserPartialRequest] to the generated [api.UpdateUserPartialRequest]. +extension UpdateUserPartialRequestMapper on UpdateUserPartialRequest { + /// Converts this update into an [api.UpdateUserPartialRequest]. + api.UpdateUserPartialRequest toRequest() => api.UpdateUserPartialRequest(id: id, set: set, unset: unset); +} diff --git a/packages/stream_chat/lib/src/repository/users_repository.dart b/packages/stream_chat/lib/src/repository/users_repository.dart index 8f9bf5e319..236d5cdb14 100644 --- a/packages/stream_chat/lib/src/repository/users_repository.dart +++ b/packages/stream_chat/lib/src/repository/users_repository.dart @@ -1,10 +1,13 @@ import 'package:stream_core/stream_core.dart' show PatternMatching, Result; import '../../open_api/api.dart' as api; +import '../core/models/request/update_user_partial_request.dart'; 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 '../core/models/response/update_users_response.dart'; +import '../core/models/user.dart'; import 'mapper/user_mapper.dart'; /// Repository dedicated to user operations. @@ -37,4 +40,22 @@ class UsersRepository { final result = await _api.getBlockedUsers(); return result.map((response) => response.toModel()); } + + /// Creates or replaces each of [users], as [StreamChatClient.updateUser] does for one. + Future> updateUsers(List users) async { + final result = await _api.updateUsers( + updateUsersRequest: api.UpdateUsersRequest(users: {for (final user in users) user.id: user.toRequest()}), + ); + + return result.map((response) => response.toModel()); + } + + /// Partially updates several users at once, applying each of [updates] to the user it names. + Future> updateUsersPartial(List updates) async { + final result = await _api.updateUsersPartial( + updateUsersPartialRequest: api.UpdateUsersPartialRequest(users: [for (final it in updates) it.toRequest()]), + ); + + 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 81757467c4..4c726f14a3 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -120,6 +120,7 @@ export 'src/core/models/reaction.dart'; export 'src/core/models/reaction_group.dart'; export 'src/core/models/read.dart'; export 'src/core/models/request/message_delivery.dart'; +export 'src/core/models/request/update_user_partial_request.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'; @@ -145,6 +146,7 @@ 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'; export 'src/core/models/response/update_user_group_response.dart'; +export 'src/core/models/response/update_users_response.dart'; export 'src/core/models/role.dart'; export 'src/core/models/role_type.dart'; export 'src/core/models/thread.dart'; diff --git a/packages/stream_chat/test/fixtures/user.json b/packages/stream_chat/test/fixtures/user.json index 0e56fefa0b..e6cd52faac 100644 --- a/packages/stream_chat/test/fixtures/user.json +++ b/packages/stream_chat/test/fixtures/user.json @@ -16,5 +16,8 @@ "language": "en", "invisible": false, "teams_role": {"team-1": "admin", "team-2": "member"}, - "avg_response_time": 120 + "avg_response_time": 120, + "deactivated_at": "2021-08-06T10:39:21.817646Z", + "deleted_at": "2021-08-07T10:39:21.817646Z", + "shadow_banned": true } \ No newline at end of file diff --git a/packages/stream_chat/test/src/client/client_connect_guest_user_test.dart b/packages/stream_chat/test/src/client/client_connect_guest_user_test.dart index c5d5ef6ccd..9471d192fa 100644 --- a/packages/stream_chat/test/src/client/client_connect_guest_user_test.dart +++ b/packages/stream_chat/test/src/client/client_connect_guest_user_test.dart @@ -102,6 +102,7 @@ void main() { 'privacy_settings': 'custom-privacy-settings', 'deleted_at': 'custom-deleted-at', 'deactivated_at': 'custom-deactivated-at', + 'shadow_banned': 'custom-shadow-banned', 'revoke_tokens_issued_before': 'custom-revoke-tokens-issued-before', }, ); @@ -113,6 +114,8 @@ void main() { 'favorite_color': 'green', 'name': 'Created Name', 'image': 'https://example.com/created.png', + 'deactivated_at': '2026-09-04T00:00:00.000Z', + 'deleted_at': '2026-09-05T00:00:00.000Z', }); }, ); @@ -287,4 +290,6 @@ User _createdGuest() => User( createdAt: DateTime.utc(2026, 9, 1), updatedAt: DateTime.utc(2026, 9, 2), lastActive: DateTime.utc(2026, 9, 3), + deactivatedAt: DateTime.utc(2026, 9, 4), + deletedAt: DateTime.utc(2026, 9, 5), ); diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 59da17c622..016f49b006 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -3316,64 +3316,6 @@ void main() { verifyNoMoreInteractions(fakeChatApi.polls); }); - test('`.updateUser`', () async { - final user = User( - id: 'test-user-id', - extraData: const {'name': 'test-user'}, - ); - - when( - () => fakeChatApi.user.updateUsers([user]), - ).thenAnswer((_) async => UpdateUsersResponse()..users = {user.id: user}); - - final res = await client.updateUser(user); - - expect(res, isNotNull); - expect(res.users, {user.id: user}); - - verify(() => fakeChatApi.user.updateUsers([user])).called(1); - verifyNoMoreInteractions(fakeChatApi.user); - }); - - test('`.partialUpdateUser`', () async { - const userId = 'test-user-id'; - - final set = {'color': 'yellow'}; - final unset = []; - - final partialUpdateRequest = PartialUpdateUserRequest( - id: userId, - set: set, - unset: unset, - ); - - final updatedUser = User( - id: userId, - extraData: {'color': set['color']}, - ); - - when(() => fakeChatApi.user.partialUpdateUsers([partialUpdateRequest])).thenAnswer( - (_) async => UpdateUsersResponse() - ..users = { - updatedUser.id: updatedUser, - }, - ); - - final res = await client.partialUpdateUser( - userId, - set: set, - unset: unset, - ); - - expect(res, isNotNull); - expect(res.users, {updatedUser.id: updatedUser}); - - verify( - () => fakeChatApi.user.partialUpdateUsers([partialUpdateRequest]), - ).called(1); - verifyNoMoreInteractions(fakeChatApi.user); - }); - test('`.getActiveLiveLocations`', () async { final locations = [ Location( diff --git a/packages/stream_chat/test/src/client/client_update_users_partial_test.dart b/packages/stream_chat/test/src/client/client_update_users_partial_test.dart new file mode 100644 index 0000000000..cb9df87389 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_update_users_partial_test.dart @@ -0,0 +1,151 @@ +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.UpdateUsersPartialRequest(users: []))); + + test('StreamChatClient.updateUserPartial sends the set and unset fields and returns the updated users', () async { + const request = api.UpdateUsersPartialRequest( + users: [ + api.UpdateUserPartialRequest(id: 'user-id', set: {'favorite_color': 'green'}, unset: ['nickname']), + ], + ); + final defaultApi = _defaultApiAnswering(Result.success(_updateUsersResponse())); + final client = _client(defaultApi); + + final res = await client.updateUserPartial( + 'user-id', + set: const {'favorite_color': 'green'}, + unset: const ['nickname'], + ); + + expect( + res.getOrNull(), + UpdateUsersResponse(duration: '4.21ms', users: {'user-id': _updatedUser()}), + ); + verify(() => defaultApi.updateUsersPartial(updateUsersPartialRequest: request)).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.updateUsersPartial sends every update and returns the updated users', () async { + const request = api.UpdateUsersPartialRequest( + users: [ + api.UpdateUserPartialRequest(id: 'first', set: {'a': 1}), + api.UpdateUserPartialRequest(id: 'second', unset: ['b']), + ], + ); + final defaultApi = _defaultApiAnswering(Result.success(_updateUsersResponse())); + final client = _client(defaultApi); + + final res = await client.updateUsersPartial(const [ + UpdateUserPartialRequest(id: 'first', set: {'a': 1}), + UpdateUserPartialRequest(id: 'second', unset: ['b']), + ]); + + expect(res.getOrNull(), UpdateUsersResponse(duration: '4.21ms', users: {'user-id': _updatedUser()})); + verify(() => defaultApi.updateUsersPartial(updateUsersPartialRequest: request)).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.updateUserPartial returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final client = _client(_defaultApiAnswering(const Result.failure(error))); + + final res = await client.updateUserPartial('user-id', set: const {'a': 1}); + + expect(res.exceptionOrNull(), error); + }); + + test('StreamChatClient.updateUsersPartial returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final client = _client(_defaultApiAnswering(const Result.failure(error))); + + final res = await client.updateUsersPartial(const [ + UpdateUserPartialRequest(id: 'user-id', unset: ['a']), + ]); + + expect(res.exceptionOrNull(), error); + }); +} + +StreamChatClient _client(api.DefaultApi defaultApi) { + final client = StreamChatClient('test-api-key', defaultApi: defaultApi); + addTearDown(client.dispose); + return client; +} + +MockDefaultApi _defaultApiAnswering(Result result) { + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateUsersPartial(updateUsersPartialRequest: any(named: 'updateUsersPartialRequest')), + ).thenAnswer((_) async => result); + return defaultApi; +} + +// The user [_updateUsersResponse] carries. +User _updatedUser() => User( + id: 'user-id', + role: 'admin', + name: 'Updated Name', + image: 'https://example.com/updated.png', + createdAt: DateTime.utc(2026, 9, 1), + updatedAt: DateTime.utc(2026, 9, 2), + lastActive: DateTime.utc(2026, 9, 3), + online: true, + banned: true, + banExpires: DateTime.utc(2026, 9, 4), + teams: const ['team-a'], + language: 'de', + invisible: false, + teamsRole: const {'team-a': 'member'}, + avgResponseTime: 42, + extraData: const {'favorite_color': 'green'}, + deactivatedAt: DateTime.utc(2026, 9, 5), + deletedAt: DateTime.utc(2026, 9, 6), + shadowBanned: false, +); + +// A response whose user sets every field, including the private ones a [User] leaves out. +api.UpdateUsersResponse _updateUsersResponse({ + Map custom = const {'favorite_color': 'green'}, +}) => api.UpdateUsersResponse( + duration: '4.21ms', + membershipDeletionTaskId: '', + users: { + 'user-id': api.FullUserResponse( + id: 'user-id', + role: 'admin', + name: 'Updated Name', + image: 'https://example.com/updated.png', + createdAt: DateTime.utc(2026, 9, 1), + updatedAt: DateTime.utc(2026, 9, 2), + lastActive: DateTime.utc(2026, 9, 3), + online: true, + banned: true, + banExpires: DateTime.utc(2026, 9, 4), + teams: const ['team-a'], + language: 'de', + invisible: false, + teamsRole: const {'team-a': 'member'}, + avgResponseTime: 42, + custom: custom, + deactivatedAt: DateTime.utc(2026, 9, 5), + deletedAt: DateTime.utc(2026, 9, 6), + shadowBanned: false, + revokeTokensIssuedBefore: DateTime.utc(2026, 9, 7), + blockedUserIds: const ['blocked-user'], + channelMutes: const [], + devices: const [], + mutes: const [], + latestHiddenChannels: const ['messaging:hidden'], + totalUnreadCount: 5, + unreadChannels: 2, + unreadCount: 4, + unreadThreads: 1, + ), + }, +); diff --git a/packages/stream_chat/test/src/client/client_update_users_test.dart b/packages/stream_chat/test/src/client/client_update_users_test.dart new file mode 100644 index 0000000000..e0dc75ea0b --- /dev/null +++ b/packages/stream_chat/test/src/client/client_update_users_test.dart @@ -0,0 +1,226 @@ +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.UpdateUsersRequest(users: {}))); + + test( + 'StreamChatClient.updateUser sends the name, image, language, visibility and custom data and returns the updated ' + 'users', + () async { + final defaultApi = _defaultApiAnswering(Result.success(_updateUsersResponse())); + final client = _client(defaultApi); + + final res = await client.updateUser( + User( + id: 'user-id', + role: 'admin', + name: 'Jane', + image: 'https://example.com/jane.png', + createdAt: DateTime.utc(2026, 1, 1), + updatedAt: DateTime.utc(2026, 1, 2), + lastActive: DateTime.utc(2026, 1, 3), + online: true, + banned: true, + banExpires: DateTime.utc(2026, 1, 4), + teams: const ['team-a'], + language: 'fr', + invisible: true, + teamsRole: const {'team-a': 'admin'}, + avgResponseTime: 12, + extraData: const {'favorite_color': 'blue'}, + deactivatedAt: DateTime.utc(2026, 1, 5), + deletedAt: DateTime.utc(2026, 1, 6), + shadowBanned: true, + ), + ); + + expect(res.getOrNull(), UpdateUsersResponse(duration: '4.21ms', users: {'user-id': _updatedUser()})); + // `User` equality leaves these two out. + expect(res.getOrNull()!.users['user-id']!.createdAt, DateTime.utc(2026, 9, 1)); + expect(res.getOrNull()!.users['user-id']!.updatedAt, DateTime.utc(2026, 9, 2)); + verify( + () => defaultApi.updateUsers( + updateUsersRequest: const api.UpdateUsersRequest( + users: { + 'user-id': api.UserRequest( + id: 'user-id', + name: 'Jane', + image: 'https://example.com/jane.png', + language: 'fr', + invisible: true, + custom: {'favorite_color': 'blue'}, + ), + }, + ), + ), + ).called(1); + verifyNoMoreInteractions(defaultApi); + }, + ); + + test("StreamChatClient.updateUser sends no custom data named like one of the user's own fields", () async { + final defaultApi = _defaultApiAnswering(Result.success(_updateUsersResponse())); + final client = _client(defaultApi); + + // The fields an own user decoded from the connection keeps in its extra data. + await client.updateUser( + User( + id: 'user-id', + extraData: const { + 'favorite_color': 'blue', + 'unread_count': 3, + 'total_unread_count_by_team': {'team-a': 1}, + 'latest_hidden_channels': ['messaging:hidden'], + 'revoke_tokens_issued_before': '2026-01-01T00:00:00.000Z', + }, + ), + ); + + verify( + () => defaultApi.updateUsers( + updateUsersRequest: const api.UpdateUsersRequest( + users: { + 'user-id': api.UserRequest(id: 'user-id', custom: {'favorite_color': 'blue'}), + }, + ), + ), + ).called(1); + }); + + test('StreamChatClient.updateUser drops custom data named like a user field', () async { + final client = _client( + _defaultApiAnswering( + Result.success( + _updateUsersResponse( + custom: const {'favorite_color': 'green', 'online': false, 'shadow_banned': true, 'unread_count': 3}, + ), + ), + ), + ); + + final res = await client.updateUser(User(id: 'user-id')); + + expect(res.getOrNull()!.users['user-id'], _updatedUser()); + }); + + test('StreamChatClient.updateUsers sends every user keyed by id and returns the updated users', () async { + final defaultApi = _defaultApiAnswering(Result.success(_updateUsersResponse())); + final client = _client(defaultApi); + + final res = await client.updateUsers([User(id: 'first', name: 'First'), User(id: 'second', name: 'Second')]); + + expect(res.getOrNull(), UpdateUsersResponse(duration: '4.21ms', users: {'user-id': _updatedUser()})); + verify( + () => defaultApi.updateUsers( + updateUsersRequest: const api.UpdateUsersRequest( + users: { + 'first': api.UserRequest(id: 'first', name: 'First', custom: {}), + 'second': api.UserRequest(id: 'second', name: 'Second', custom: {}), + }, + ), + ), + ).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.updateUser returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final client = _client(_defaultApiAnswering(const Result.failure(error))); + + final res = await client.updateUser(User(id: 'user-id')); + + expect(res.exceptionOrNull(), error); + }); + + test('StreamChatClient.updateUsers returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final client = _client(_defaultApiAnswering(const Result.failure(error))); + + final res = await client.updateUsers([User(id: 'first'), User(id: 'second')]); + + expect(res.exceptionOrNull(), error); + }); +} + +StreamChatClient _client(api.DefaultApi defaultApi) { + final client = StreamChatClient('test-api-key', defaultApi: defaultApi); + addTearDown(client.dispose); + return client; +} + +MockDefaultApi _defaultApiAnswering(Result result) { + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateUsers(updateUsersRequest: any(named: 'updateUsersRequest')), + ).thenAnswer((_) async => result); + return defaultApi; +} + +// The user [_updateUsersResponse] carries. +User _updatedUser() => User( + id: 'user-id', + role: 'admin', + name: 'Updated Name', + image: 'https://example.com/updated.png', + createdAt: DateTime.utc(2026, 9, 1), + updatedAt: DateTime.utc(2026, 9, 2), + lastActive: DateTime.utc(2026, 9, 3), + online: true, + banned: true, + banExpires: DateTime.utc(2026, 9, 4), + teams: const ['team-a'], + language: 'de', + invisible: false, + teamsRole: const {'team-a': 'member'}, + avgResponseTime: 42, + extraData: const {'favorite_color': 'green'}, + deactivatedAt: DateTime.utc(2026, 9, 5), + deletedAt: DateTime.utc(2026, 9, 6), + shadowBanned: false, +); + +// A response whose user sets every field, including the private ones a [User] leaves out. +api.UpdateUsersResponse _updateUsersResponse({ + Map custom = const {'favorite_color': 'green'}, +}) => api.UpdateUsersResponse( + duration: '4.21ms', + membershipDeletionTaskId: '', + users: { + 'user-id': api.FullUserResponse( + id: 'user-id', + role: 'admin', + name: 'Updated Name', + image: 'https://example.com/updated.png', + createdAt: DateTime.utc(2026, 9, 1), + updatedAt: DateTime.utc(2026, 9, 2), + lastActive: DateTime.utc(2026, 9, 3), + online: true, + banned: true, + banExpires: DateTime.utc(2026, 9, 4), + teams: const ['team-a'], + language: 'de', + invisible: false, + teamsRole: const {'team-a': 'member'}, + avgResponseTime: 42, + custom: custom, + deactivatedAt: DateTime.utc(2026, 9, 5), + deletedAt: DateTime.utc(2026, 9, 6), + shadowBanned: false, + revokeTokensIssuedBefore: DateTime.utc(2026, 9, 7), + blockedUserIds: const ['blocked-user'], + channelMutes: const [], + devices: const [], + mutes: const [], + latestHiddenChannels: const ['messaging:hidden'], + totalUnreadCount: 5, + unreadChannels: 2, + unreadCount: 4, + unreadThreads: 1, + ), + }, +); 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 3ba17b59d7..64d48e830e 100644 --- a/packages/stream_chat/test/src/core/api/responses_test.dart +++ b/packages/stream_chat/test/src/core/api/responses_test.dart @@ -3471,22 +3471,6 @@ void main() { expect(response.reaction, isA()); }); - test('UpdateUsersResponse', () { - const jsonExample = ''' - {"users": {"bbb19d9a-ee50-45bc-84e5-0584e79d0c9e":{ - "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", - "role": "user", - "created_at": "2020-01-28T22:17:30.826259Z", - "updated_at": "2020-01-28T22:17:31.101222Z", - "banned": false, - "online": false, - "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg", - "name": "Robin Papa" - }},"duration":"0.35ms"}'''; - final response = UpdateUsersResponse.fromJson(json.decode(jsonExample)); - expect(response.users, isA>()); - }); - test('GetMessagesByIdResponse', () { const jsonExample = r''' {"messages":[{ 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 f9c1997e9f..d685444c31 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 @@ -69,74 +69,6 @@ void main() { verifyNoMoreInteractions(client); }); - test('updateUsers', () async { - final users = List.generate(3, (index) => User(id: 'test-user-id-$index')); - - const path = '/users'; - - final updatedUsers = {for (final user in users) user.id: user}; - - when( - () => client.post( - path, - data: { - 'users': updatedUsers, - }, - ), - ).thenAnswer( - (_) async => - successResponse(path, data: {'users': updatedUsers.map((key, value) => MapEntry(key, value.toJson()))}), - ); - - final res = await userApi.updateUsers(users); - - expect(res, isNotNull); - expect(res.users.length, updatedUsers.length); - - verify(() => client.post(path, data: any(named: 'data'))).called(1); - verifyNoMoreInteractions(client); - }); - - test('partialUpdateUsers', () async { - const user = PartialUpdateUserRequest( - id: 'test-user-id', - set: {'color': 'yellow'}, - ); - - const path = '/users'; - - final updatedUser = {user.id: User(id: user.id, extraData: user.set!)}; - - when( - () => client.patch( - path, - data: { - 'users': [user], - }, - ), - ).thenAnswer( - (_) async => successResponse( - path, - data: {'users': updatedUser.map((key, value) => MapEntry(key, value.toJson()))}, - ), - ); - - final res = await userApi.partialUpdateUsers([user]); - - expect(res, isNotNull); - expect(res.users.length, updatedUser.length); - - verify( - () => client.patch( - path, - data: { - 'users': [user], - }, - ), - ).called(1); - verifyNoMoreInteractions(client); - }); - test('getActiveLiveLocations', () async { const path = '/users/live_locations'; diff --git a/packages/stream_chat/test/src/core/models/user_test.dart b/packages/stream_chat/test/src/core/models/user_test.dart index b725056693..e018faeac9 100644 --- a/packages/stream_chat/test/src/core/models/user_test.dart +++ b/packages/stream_chat/test/src/core/models/user_test.dart @@ -45,6 +45,46 @@ void main() { expect(user.invisible, false); expect(user.teamsRole, teamsRole); expect(user.avgResponseTime, avgResponseTime); + expect(user.deactivatedAt, DateTime.parse('2021-08-06T10:39:21.817646Z')); + expect(user.deletedAt, DateTime.parse('2021-08-07T10:39:21.817646Z')); + expect(user.shadowBanned, isTrue); + }); + + test('User keeps deactivatedAt, deletedAt and shadowBanned in extraData', () { + final user = User( + id: id, + deactivatedAt: DateTime.utc(2021), + deletedAt: DateTime.utc(2020), + shadowBanned: true, + extraData: const {'color': 'red'}, + ); + + expect(user.extraData, { + 'color': 'red', + 'deactivated_at': DateTime.utc(2021).toIso8601String(), + 'deleted_at': DateTime.utc(2020).toIso8601String(), + 'shadow_banned': true, + }); + }); + + test('User replaces the extraData entries that deactivatedAt, deletedAt and shadowBanned read', () { + final user = User( + id: id, + deactivatedAt: DateTime.utc(2021), + deletedAt: DateTime.utc(2020), + shadowBanned: true, + extraData: const {'deactivated_at': 'stale', 'deleted_at': 'stale', 'shadow_banned': false}, + ); + + expect(user.deactivatedAt, DateTime.utc(2021)); + expect(user.deletedAt, DateTime.utc(2020)); + expect(user.shadowBanned, isTrue); + }); + + test('User.deletedAt returns null for an extraData value that is not a date', () { + final user = User(id: id, extraData: const {'deleted_at': 'yesterday'}); + + expect(user.deletedAt, isNull); }); test('should serialize to json correctly', () { From bb65acd8c37d227311b9c9e97efe40eeaa4efce9 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 9 Oct 2026 16:35:27 +0200 Subject: [PATCH 2/2] refactor(llc): rename the user mapper file to users_mapper.dart Co-Authored-By: Claude Opus 5.5 --- packages/stream_chat/lib/src/repository/general_repository.dart | 2 +- .../stream_chat/lib/src/repository/mapper/channels_mapper.dart | 2 +- .../repository/mapper/{user_mapper.dart => users_mapper.dart} | 2 ++ packages/stream_chat/lib/src/repository/users_repository.dart | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) rename packages/stream_chat/lib/src/repository/mapper/{user_mapper.dart => users_mapper.dart} (98%) diff --git a/packages/stream_chat/lib/src/repository/general_repository.dart b/packages/stream_chat/lib/src/repository/general_repository.dart index 82ab18eceb..4e7e4cc0dc 100644 --- a/packages/stream_chat/lib/src/repository/general_repository.dart +++ b/packages/stream_chat/lib/src/repository/general_repository.dart @@ -5,7 +5,7 @@ import '../core/models/response/create_guest_user_response.dart'; import '../core/models/response/og_attachment_response.dart'; import '../core/models/user.dart'; import 'mapper/general_mapper.dart'; -import 'mapper/user_mapper.dart'; +import 'mapper/users_mapper.dart'; /// Repository dedicated to operations that belong to no single feature. class GeneralRepository { diff --git a/packages/stream_chat/lib/src/repository/mapper/channels_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/channels_mapper.dart index 87bdfee655..dc99bc6e11 100644 --- a/packages/stream_chat/lib/src/repository/mapper/channels_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/channels_mapper.dart @@ -15,7 +15,7 @@ import '../../core/models/response/mark_read_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'; +import 'users_mapper.dart'; // TODO(openapi-migration): re-point these mappers in group 11. diff --git a/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/users_mapper.dart similarity index 98% rename from packages/stream_chat/lib/src/repository/mapper/user_mapper.dart rename to packages/stream_chat/lib/src/repository/mapper/users_mapper.dart index cb9b609beb..e3469e477b 100644 --- a/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/users_mapper.dart @@ -108,6 +108,8 @@ extension UserRequestMapper on User { } /// Maps [PrivacySettings] to the generated [api.PrivacySettingsResponse]. +/// +/// The generated client names the settings a request sends [api.PrivacySettingsResponse], too. extension PrivacySettingsRequestMapper on PrivacySettings { /// Converts these settings into an [api.PrivacySettingsResponse]. api.PrivacySettingsResponse toRequest() => api.PrivacySettingsResponse( diff --git a/packages/stream_chat/lib/src/repository/users_repository.dart b/packages/stream_chat/lib/src/repository/users_repository.dart index 236d5cdb14..1328bf6af8 100644 --- a/packages/stream_chat/lib/src/repository/users_repository.dart +++ b/packages/stream_chat/lib/src/repository/users_repository.dart @@ -8,7 +8,7 @@ import '../core/models/response/get_unread_count_response.dart'; import '../core/models/response/unblock_users_response.dart'; import '../core/models/response/update_users_response.dart'; import '../core/models/user.dart'; -import 'mapper/user_mapper.dart'; +import 'mapper/users_mapper.dart'; /// Repository dedicated to user operations. class UsersRepository {