diff --git a/.claude/skills/openapi-migration/SKILL.md b/.claude/skills/openapi-migration/SKILL.md index 581b992fa9..e6344ac8ab 100644 --- a/.claude/skills/openapi-migration/SKILL.md +++ b/.claude/skills/openapi-migration/SKILL.md @@ -253,9 +253,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 a03cf53153..2477069996 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -39,6 +39,7 @@ onto Stream's OpenAPI-generated API client. - [Read Receipts](#read-receipts) - [Unread Counts](#unread-counts) - [User Blocking](#user-blocking) + - [User Updates](#user-updates) - [Channel and Member Custom Data](#channel-and-member-custom-data) - [Migration Checklist](#migration-checklist) - [For AI Agents](#for-ai-agents) @@ -91,6 +92,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` | | [**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)_ | | @@ -303,6 +305,19 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `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 | +| `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 | | `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 | @@ -966,6 +981,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/04-roles-guest-and-app.md b/openapi-migration/04-roles-guest-and-app.md index db99df5cf1..956656f697 100644 --- a/openapi-migration/04-roles-guest-and-app.md +++ b/openapi-migration/04-roles-guest-and-app.md @@ -77,7 +77,7 @@ It unwraps `GeneralRepository.createGuest`'s `Result` with `getOrElse`, the one sanctioned unwrap inside the SDK: a `Result` on a connect call could be ignored and hide a failed sign-in. - **`createGuest` lives in `GeneralRepository`**, next to `enrichUrl`. Its mappers are in - `lib/src/repository/mapper/user_mapper.dart`, with the `User` mappers they use. + `lib/src/repository/mapper/users_mapper.dart`, with the `User` mappers they use. - **The envelope is the internal `CreateGuestUserResponse`:** a freezed envelope that only `createGuest` returns. It replaces v10's public `ConnectGuestUserResponse`, whose only public reach was `StreamChatApi.guest`, removed together with `GuestApi`. @@ -100,7 +100,7 @@ non-date value). v10's flat response shadowed them, so the same guest connected; confirmed live. - **Not a regression: a custom `ban_expires` that isn't a date** fails the connect in v10 and now alike, because the socket's `me` lets it through into `OwnUser.fromJson`. -- **`User` is not restructured here.** `user_mapper.dart` maps the generated types onto today's `User`, +- **`User` is not restructured here.** `users_mapper.dart` maps the generated types onto today's `User`, following [01-foundation](01-foundation.md). ## Risks diff --git a/openapi-migration/09-users.md b/openapi-migration/09-users.md index 974f006662..43a8b74670 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,20 @@ | `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). +- `User` and `OwnUser` are public, persisted, and embedded in nearly every other response. This group restructures them, last: the mappers in `users_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. -- **`User.name` and `image` become fields** (README rule 9): `UserResponse` types them, and the constructor stops writing them into `extraData`; decide whether `name` keeps its fallback to `id`. `deleted_at`, `deactivated_at`, `revoke_tokens_issued_before` and `blocked_user_ids`, which `UserResponse` types and v1 kept in a plain user's `extraData`, become fields or are dropped, never `extraData`. `UserResponse.toModel()` then copies `custom` verbatim into the field, renamed `custom`, and `UserRequestMapper` stops reading `extraData['name']` and `['image']`. -- `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. +- **`User` promotes its `extraData`-backed values to fields** (README rule 9). `name` and `image`, which `UserResponse` types, and `deactivatedAt`, `deletedAt` and `shadowBanned`, which [20](20-user-updates.md) added as getters over `extraData`, become fields, and the constructor stops writing them into `extraData`; decide whether `name` keeps its fallback to `id`. `revoke_tokens_issued_before` and `blocked_user_ids`, which v1 kept in a plain user's `extraData`, become fields or are dropped, never `extraData`. The user mappers then copy `custom` verbatim into the field, renamed `custom`, with no `_shadowedCustomKeys`, and `UserRequestMapper` stops reading `extraData['name']` and `['image']`. When promoting: decide each field's `merge` rule (`OwnUser.merge` takes the other user's `extraData` whole today), make `toJson` leave out custom keys named like a field, add persistence columns, and point `UserFilterField.shadowBanned` at the field. +- `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. - Until then, a user mapped from `UserResponse` and the same user decoded from v1 JSON carry different `extraData`: the v1 path keeps `blocked_user_ids`, `deleted_at`, `deactivated_at` and `revoke_tokens_issued_before`, the mapper drops them. Equality includes `extraData`, so a `Poll` from a REST call (its `createdBy`, its votes' `user`) and the same poll from an event compare unequal. @@ -65,8 +61,8 @@ name states its subject and behaviour. - [ ] Temporary adapters owned by this group (`DeviceV1JsonConverter`) are deleted and removed from the table in `README.md`. -- [ ] `user_mapper.dart` maps onto the restructured `User`, and its `TODO(openapi-migration)` note is +- [ ] `users_mapper.dart` maps onto the restructured `User`, and its `TODO(openapi-migration)` note is gone. - [ ] A user mapped from `UserResponse` and the same user decoded from v1 / WebSocket JSON compare equal. -- [ ] `user_mapper.dart` has no `_shadowedCustomKeys`: `User.custom` is the wire's `custom`, verbatim, and +- [ ] `users_mapper.dart` has no `_shadowedCustomKeys`: `User.custom` is the wire's `custom`, verbatim, and no typed field is written into it. diff --git a/openapi-migration/11-channels-and-members.md b/openapi-migration/11-channels-and-members.md index 32b9d1d2ae..f641273ec3 100644 --- a/openapi-migration/11-channels-and-members.md +++ b/openapi-migration/11-channels-and-members.md @@ -60,7 +60,7 @@ - **The generated channel and member types map onto today's classes.** `lib/src/repository/mapper/channels_mapper.dart` maps `ChannelResponse`, `ChannelConfigWithInfo`, `ChannelMemberResponse`, `Command` and `ChatPreferences` onto the json_serializable `ChannelModel`, - `ChannelConfig`, `Member`, `Command` and `ChatPreferences`, the way `user_mapper.dart` does for + `ChannelConfig`, `Member`, `Command` and `ChatPreferences`, the way `users_mapper.dart` does for `User`. [15](15-partial-updates.md) uses it first; groups 06, 07 and 14 will reuse it. This group restructures those classes and re-points the mapper. - **Every field `ChannelResponse` and `ChannelMemberResponse` declare is reachable on ours,** as a @@ -71,7 +71,7 @@ `createdAt` / `updatedAt`, which ours do not model. - **The mapper copies `custom` verbatim** (README rule 9). A mapped channel or member still differs from a v1 decode in one way, for group 09 to settle: `truncatedBy`, `createdBy` and each member's user go - through `user_mapper.dart`, which drops the user fields `User` does not model. + through `users_mapper.dart`, which drops the user fields `User` does not model. - **The mapper is tested through the client,** by [15](15-partial-updates.md)'s `updateChannelPartial` tests: the first call that returns a channel through the generated client. diff --git a/openapi-migration/19-user-blocking.md b/openapi-migration/19-user-blocking.md index e8a2bcdc47..133e5b494a 100644 --- a/openapi-migration/19-user-blocking.md +++ b/openapi-migration/19-user-blocking.md @@ -1,6 +1,6 @@ # 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. +**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 `users_mapper.dart`, and nothing persists them. **Size:** 0 hand-written method(s) across 0 file(s) → 3 generated operation(s). diff --git a/openapi-migration/20-user-updates.md b/openapi-migration/20-user-updates.md new file mode 100644 index 0000000000..f37edb7111 --- /dev/null +++ b/openapi-migration/20-user-updates.md @@ -0,0 +1,76 @@ +# 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] `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 6d2ee1abff..4ceec0b6fd 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,9 +31,10 @@ 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 | ☑ | | [21](21-custom-data.md) | Custom Data Check — no model still says `extraData` | — | — | ☐ | -**Coverage:** 53 hand-written methods across 9 files, and all 129 generated operations, each claimed by exactly +**Coverage:** 51 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). @@ -175,7 +176,7 @@ includes a named response that carries only `duration` today, such as `HideChann | `DataSerializable` | `UserGroup`, `UserGroupMember`, `PollOption` (`fromData`, `toData`) | [10](10-messages.md) | | `PollV1JsonConverter` | `Message.poll`, `DraftMessage.poll`, `Event.poll` | WebSocket v2 (no group) | | `PollVoteV1JsonConverter` | `Event.pollVote` | WebSocket v2 (no group) | -| `user_mapper.dart` (kept, re-pointed) | today's `User`, which still reads and writes JSON | [09](09-users.md) | +| `users_mapper.dart` (kept, re-pointed) | today's `User`, which still reads and writes JSON | [09](09-users.md) | | `channels_mapper.dart` (kept, re-pointed) | today's `ChannelModel`, `ChannelConfig` and `Member`, which still read and write JSON | [11](11-channels-and-members.md) | How v1 JSON decodes `User` once it becomes a plain model is decided in [01-foundation](01-foundation.md): until @@ -207,9 +208,12 @@ 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 +- **09** is last. Every group before it maps users through `users_mapper.dart` onto today's `User`; 09 migrates the user endpoints and restructures `User` and `OwnUser` themselves, when every parent that embeds them has moved. - **21** checks the custom data once every group has run: each model's custom data is named `custom` and is the wire's `custom`, with no mapper filtering it and no typed field parked in it. Each group does that for the diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 27c890a239..d0923012c0 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -290,7 +290,7 @@ def match(verb, path): It unwraps `GeneralRepository.createGuest`'s `Result` with `getOrElse`, the one sanctioned unwrap inside the SDK: a `Result` on a connect call could be ignored and hide a failed sign-in. - **`createGuest` lives in `GeneralRepository`**, next to `enrichUrl`. Its mappers are in - `lib/src/repository/mapper/user_mapper.dart`, with the `User` mappers they use. + `lib/src/repository/mapper/users_mapper.dart`, with the `User` mappers they use. - **The envelope is the internal `CreateGuestUserResponse`:** a freezed envelope that only `createGuest` returns. It replaces v10's public `ConnectGuestUserResponse`, whose only public reach was `StreamChatApi.guest`, removed together with `GuestApi`. @@ -313,7 +313,7 @@ def match(verb, path): non-date value). v10's flat response shadowed them, so the same guest connected; confirmed live. - **Not a regression: a custom `ban_expires` that isn't a date** fails the connect in v10 and now alike, because the socket's `me` lets it through into `OwnUser.fromJson`. - - **`User` is not restructured here.** `user_mapper.dart` maps the generated types onto today's `User`, + - **`User` is not restructured here.** `users_mapper.dart` maps the generated types onto today's `User`, following [01-foundation](01-foundation.md). """), done=DONE.replace('- [ ]', '- [x]'), @@ -579,34 +579,38 @@ 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 ' + 'restructures them, last: the mappers in `users_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.', - '**`User.name` and `image` become fields** (README rule 9): `UserResponse` types them, and the constructor ' - 'stops writing them into `extraData`; decide whether `name` keeps its fallback to `id`. `deleted_at`, ' - '`deactivated_at`, `revoke_tokens_issued_before` and `blocked_user_ids`, which `UserResponse` types and v1 ' - 'kept in a plain user\'s `extraData`, become fields or are dropped, never `extraData`. ' - '`UserResponse.toModel()` then copies `custom` verbatim into the field, renamed `custom`, and ' - '`UserRequestMapper` stops reading `extraData[\'name\']` and `[\'image\']`.', - '`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.', + '**`User` promotes its `extraData`-backed values to fields** (README rule 9). `name` and `image`, which ' + '`UserResponse` types, and `deactivatedAt`, `deletedAt` and `shadowBanned`, which ' + '[20](20-user-updates.md) added as getters over `extraData`, become fields, and the constructor stops ' + 'writing them into `extraData`; decide whether `name` keeps its fallback to `id`. ' + '`revoke_tokens_issued_before` and `blocked_user_ids`, which v1 kept in a plain user\'s `extraData`, ' + 'become fields or are dropped, never `extraData`. The user mappers then copy `custom` verbatim into the ' + 'field, renamed `custom`, with no `_shadowedCustomKeys`, and `UserRequestMapper` stops reading ' + '`extraData[\'name\']` and `[\'image\']`. When promoting: decide each field\'s `merge` rule ' + '(`OwnUser.merge` takes the other user\'s `extraData` whole today), make `toJson` leave out custom keys ' + 'named like a field, add persistence columns, and point `UserFilterField.shadowBanned` at the field.', + '`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 ' @@ -619,10 +623,10 @@ def match(verb, path): done=DONE + ( '- [ ] Temporary adapters owned by this group (`DeviceV1JsonConverter`) are deleted and removed from\n' ' the table in `README.md`.\n' - '- [ ] `user_mapper.dart` maps onto the restructured `User`, and its `TODO(openapi-migration)` note is\n' + '- [ ] `users_mapper.dart` maps onto the restructured `User`, and its `TODO(openapi-migration)` note is\n' ' gone.\n' '- [ ] A user mapped from `UserResponse` and the same user decoded from v1 / WebSocket JSON compare equal.\n' - '- [ ] `user_mapper.dart` has no `_shadowedCustomKeys`: `User.custom` is the wire\'s `custom`, verbatim, and\n' + '- [ ] `users_mapper.dart` has no `_shadowedCustomKeys`: `User.custom` is the wire\'s `custom`, verbatim, and\n' ' no typed field is written into it.\n' ), ), @@ -724,7 +728,7 @@ def match(verb, path): - **The generated channel and member types map onto today's classes.** `lib/src/repository/mapper/channels_mapper.dart` maps `ChannelResponse`, `ChannelConfigWithInfo`, `ChannelMemberResponse`, `Command` and `ChatPreferences` onto the json_serializable `ChannelModel`, - `ChannelConfig`, `Member`, `Command` and `ChatPreferences`, the way `user_mapper.dart` does for + `ChannelConfig`, `Member`, `Command` and `ChatPreferences`, the way `users_mapper.dart` does for `User`. [15](15-partial-updates.md) uses it first; groups 06, 07 and 14 will reuse it. This group restructures those classes and re-points the mapper. - **Every field `ChannelResponse` and `ChannelMemberResponse` declare is reachable on ours,** as a @@ -735,7 +739,7 @@ def match(verb, path): `createdAt` / `updatedAt`, which ours do not model. - **The mapper copies `custom` verbatim** (README rule 9). A mapped channel or member still differs from a v1 decode in one way, for group 09 to settle: `truncatedBy`, `createdBy` and each member's user go - through `user_mapper.dart`, which drops the user fields `User` does not model. + through `users_mapper.dart`, which drops the user fields `User` does not model. - **The mapper is tested through the client,** by [15](15-partial-updates.md)'s `updateChannelPartial` tests: the first call that returns a channel through the generated client. """), @@ -1021,7 +1025,7 @@ def match(verb, path): 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.', + 'embed `User` only through the existing `users_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, @@ -1049,6 +1053,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]'), + ), 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 d80c884894..806c4097c0 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -124,6 +124,14 @@ - `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. +- `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. - `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. @@ -133,6 +141,8 @@ - `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`. @@ -157,6 +167,7 @@ - Added optional `reason` and `custom` arguments to `flagMessage` and `flagUser`, recorded with the flag. - 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 `User.deactivatedAt`, `deletedAt` and `shadowBanned`; the constructors accept the new fields. - Added `ChannelModel.muted`, `blocked`, `truncatedBy`, `autoTranslationEnabled` and `autoTranslationLanguage`, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, `futureChannelBanExpires`, `deletedAt` and `topLevelFields`; the constructors and `copyWith` accept the new fields. - Added a `prev` argument to `queryPolls` and `queryPollVotes`, and a `prev` cursor on their responses, for paging backwards. diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 880e301787..102292cb3a 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'; @@ -87,6 +88,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'; @@ -1772,32 +1774,26 @@ class StreamChatClient { String? prev, }) => _pollsRepository.queryPollVotes(pollId, filter: filter, sort: sort, limit: limit, next: next, prev: prev); - /// 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 fef131e992..3e5647a14e 100644 --- a/packages/stream_chat/lib/src/core/api/responses.dart +++ b/packages/stream_chat/lib/src/core/api/responses.dart @@ -157,17 +157,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 fb67c5d102..f9cc331b51 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/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 74efdf3a74..2a15ea152a 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/polls_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/polls_mapper.dart index b5f6c06059..39f0b8f58f 100644 --- a/packages/stream_chat/lib/src/repository/mapper/polls_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/polls_mapper.dart @@ -8,7 +8,7 @@ import '../../core/models/response/poll_vote_response.dart'; import '../../core/models/response/query_poll_votes_response.dart'; import '../../core/models/response/query_polls_response.dart'; import '../../core/models/voting_visibility.dart'; -import 'user_mapper.dart'; +import 'users_mapper.dart'; /// Maps a generated [api.PollResponseData] to a [Poll]. extension PollResponseDataMapper on api.PollResponseData { 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 78% 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 19b0c59e7f..e3469e477b 100644 --- a/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/users_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, ); } @@ -72,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( @@ -182,3 +220,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..1328bf6af8 100644 --- a/packages/stream_chat/lib/src/repository/users_repository.dart +++ b/packages/stream_chat/lib/src/repository/users_repository.dart @@ -1,11 +1,14 @@ 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 'mapper/user_mapper.dart'; +import '../core/models/response/update_users_response.dart'; +import '../core/models/user.dart'; +import 'mapper/users_mapper.dart'; /// Repository dedicated to user operations. class UsersRepository { @@ -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 86de999612..22484c7594 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'; @@ -150,6 +151,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 fa8dd7baff..bfc6968554 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -3583,64 +3583,6 @@ void main() { verifyNoMoreInteractions(fakeChatApi.message); }); - 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/client/poll_fixtures.dart b/packages/stream_chat/test/src/client/poll_fixtures.dart index 41ff94addf..324fa21e1a 100644 --- a/packages/stream_chat/test/src/client/poll_fixtures.dart +++ b/packages/stream_chat/test/src/client/poll_fixtures.dart @@ -41,6 +41,8 @@ final voter = User( language: 'en', teamsRole: const {'jedi': 'admin'}, avgResponseTime: 12, + deactivatedAt: DateTime.utc(2024, 1, 4), + deletedAt: DateTime.utc(2024, 1, 5), extraData: const {'favorite_color': 'blue'}, ); 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', () {