From 965b176d3cccda07175cbb3bf21cbc21617e50e2 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 8 Oct 2026 12:41:47 +0200 Subject: [PATCH 1/9] refactor(llc)!: route user updates through the generated client Co-Authored-By: Claude Opus 5.5 --- .claude/skills/openapi-migration/SKILL.md | 6 +- migrations/v11-migration.md | 58 +++++ openapi-migration/09-users.md | 17 +- openapi-migration/20-user-updates.md | 74 ++++++ openapi-migration/README.md | 8 +- openapi-migration/tool/generate_plan.py | 82 +++++-- packages/stream_chat/CHANGELOG.md | 11 + .../stream_chat/lib/src/client/client.dart | 36 ++- .../lib/src/core/api/requests.dart | 26 -- .../lib/src/core/api/requests.g.dart | 11 - .../lib/src/core/api/responses.dart | 11 - .../lib/src/core/api/responses.g.dart | 8 - .../lib/src/core/api/user_api.dart | 26 -- .../lib/src/core/models/own_user.dart | 6 + .../request/update_user_partial_request.dart | 29 +++ .../update_user_partial_request.freezed.dart | 97 ++++++++ .../response/update_users_response.dart | 26 ++ .../update_users_response.freezed.dart | 86 +++++++ .../stream_chat/lib/src/core/models/user.dart | 37 ++- .../src/repository/mapper/user_mapper.dart | 51 ++++ .../lib/src/repository/users_repository.dart | 21 ++ packages/stream_chat/lib/stream_chat.dart | 2 + packages/stream_chat/test/fixtures/user.json | 5 +- .../client_connect_guest_user_test.dart | 5 + .../test/src/client/client_test.dart | 58 ----- .../client_update_users_partial_test.dart | 151 ++++++++++++ .../src/client/client_update_users_test.dart | 226 ++++++++++++++++++ .../test/src/core/api/responses_test.dart | 16 -- .../test/src/core/api/user_api_test.dart | 68 ------ .../test/src/core/models/user_test.dart | 40 ++++ 30 files changed, 1021 insertions(+), 277 deletions(-) create mode 100644 openapi-migration/20-user-updates.md create mode 100644 packages/stream_chat/lib/src/core/models/request/update_user_partial_request.dart create mode 100644 packages/stream_chat/lib/src/core/models/request/update_user_partial_request.freezed.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/update_users_response.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/update_users_response.freezed.dart create mode 100644 packages/stream_chat/test/src/client/client_update_users_partial_test.dart create mode 100644 packages/stream_chat/test/src/client/client_update_users_test.dart diff --git a/.claude/skills/openapi-migration/SKILL.md b/.claude/skills/openapi-migration/SKILL.md index 0a01cdd002..e3ee0e7bad 100644 --- a/.claude/skills/openapi-migration/SKILL.md +++ b/.claude/skills/openapi-migration/SKILL.md @@ -251,9 +251,9 @@ packages/stream_chat/lib/src/ ``` - **The role decides the folder.** An envelope goes in `response/` (`CreateUserGroupResponse`). A type the caller - passes in to shape a request goes in `request/`, whatever its name: `MessageDelivery`, and also - `PartialUpdateUserRequest`, `PaginationParams` and `ThreadOptions` when their groups migrate them. Every other - model stays in `models/` (`UserGroup`). Copy `lib/src/core/models/request/message_delivery.dart`. + passes in to shape a request goes in `request/`, whatever its name: `MessageDelivery`, `UpdateUserPartialRequest`, + and also `PaginationParams` and `ThreadOptions` when their groups migrate them. Every other model stays in + `models/` (`UserGroup`). Copy `lib/src/core/models/request/message_delivery.dart`. - **Models and envelopes** are `@freezed` classes with a const constructor and `@override final` fields. Copy `lib/src/core/models/user_group.dart` and `lib/src/core/models/response/create_user_group_response.dart`. Envelopes carry `required this.duration`, documented with an example value such as `4.21ms`, plus one field per diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index a1fda30e95..3750dd75c9 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -38,6 +38,7 @@ onto Stream's OpenAPI-generated API client. - [Read Receipts](#read-receipts) - [Unread Counts](#unread-counts) - [User Blocking](#user-blocking) + - [User Updates](#user-updates) - [Migration Checklist](#migration-checklist) - [For AI Agents](#for-ai-agents) - [Contributing to this guide](#contributing-to-this-guide) @@ -88,6 +89,7 @@ from the spec, so don't subclass them or depend on their private constructors. | [**Read Receipts**](#read-receipts) | Marking read, unread and delivered return a `Result` instead of throwing; `ChannelDeliveryReporter`'s callback returns a `Result` | | [**Unread Counts**](#unread-counts) | `getUnreadCount` returns a `Result` instead of throwing; the response and its `UnreadCounts*` models no longer decode JSON and compare by value | | [**User Blocking**](#user-blocking) | `blockUser`, `unblockUser` and `getBlockedUsers` (was `queryBlockedUsers`) return a `Result` instead of throwing; their responses are renamed `BlockUsersResponse` and `GetBlockedUsersResponse`, `unblockUser` answers a new `UnblockUsersResponse`, and `UserBlock`'s fields are all non-nullable | +| [**User Updates**](#user-updates) | `updateUser` and `updateUsers` return a `Result` instead of throwing; `partialUpdateUser(s)` becomes `updateUserPartial` / `updateUsersPartial` and `PartialUpdateUserRequest` becomes `UpdateUserPartialRequest`; `updateUser` no longer sends `role`, `teams` or `teamsRole`, and the returned users no longer carry their private fields in `extraData` | | _(filled in per feature as PRs land)_ | | --- @@ -278,6 +280,19 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `UserBlock extends Equatable`, `UserBlock.props` | `UserBlock` (value `==`, `copyWith`) | `removed` | Equality is unchanged; `props` is gone and `UserBlock` is no longer an `Equatable` | | `UserBlock.blockedUser` (`User?`), `.userId` / `.blockedUserId` (`String?`), `.createdAt` (`DateTime?`) | `User`, `String`, `DateTime` — required in the constructor | `retyped` | The server always sends them; drop any `!`, `?.` or `?? …` | | `StreamChatApi.user.blockUser` / `unblockUser` / `queryBlockedUsers` | `StreamChatClient.blockUser` / `unblockUser` / `getBlockedUsers` | `removed` | The endpoints moved to the generated client | +| `StreamChatClient.updateUser` / `updateUsers` → `Future` | `Future>` | `retyped` | Returns a `Result` instead of throwing | +| `StreamChatClient.partialUpdateUser` | `StreamChatClient.updateUserPartial` | `renamed` | Same arguments | +| `StreamChatClient.partialUpdateUser` → `Future` | `updateUserPartial` → `Future>` | `retyped` | Returns a `Result` instead of throwing | +| `StreamChatClient.partialUpdateUsers` | `StreamChatClient.updateUsersPartial` | `renamed` | Takes `List` | +| `StreamChatClient.partialUpdateUsers` → `Future` | `updateUsersPartial` → `Future>` | `retyped` | Returns a `Result` instead of throwing | +| `PartialUpdateUserRequest` | `UpdateUserPartialRequest` | `renamed` | Same fields | +| `PartialUpdateUserRequest.toJson` | — | `removed` | `UpdateUserPartialRequest` is a plain class | +| `UpdateUsersResponse.fromJson` | — | `removed` | The response is a plain class; construct it directly | +| `PartialUpdateUserRequest.props` | — | `removed` | No longer an `Equatable`; still compares by value, and gains `copyWith` | +| `UpdateUsersResponse()..users = …` | `UpdateUsersResponse(duration: …, users: …)` | `retyped` | A plain class with a const constructor and final fields | +| `UpdateUsersResponse` identity `==` | value `==`, plus `copyWith` | `retyped` | Two instances with the same fields are now equal | +| `UpdateUsersResponse.duration` (`String?`) | `String` | `retyped` | Always present; drop any `!` or `?? ''` | +| `StreamChatApi.user.updateUsers` / `partialUpdateUsers` | `StreamChatClient.updateUsers` / `updateUsersPartial` | `removed` | The endpoints moved to the generated client | | _(more added per feature as PRs land)_ | | | | --- @@ -894,6 +909,49 @@ and gain `copyWith`, and their `duration` is always present. > pagination. The server always sends every `UserBlock` field, so the model no longer makes callers handle nulls > that never arrive. +### User Updates + +**`updateUser` and `updateUsers` return a `Result` instead of throwing, and +`partialUpdateUser` and `partialUpdateUsers` are renamed `updateUserPartial` and `updateUsersPartial`.** +`updateUserPartial` keeps its arguments; `updateUsersPartial` takes `UpdateUserPartialRequest`, renamed from +`PartialUpdateUserRequest`. A `try`/`catch` around them still compiles, but no longer catches a failed call: read +the returned `Result` instead. None of them changes `currentUser`; the `user.updated` event still does. + +```dart +// v10 +try { + await client.partialUpdateUser(userId, set: {'favorite_color': 'green'}); +} on StreamChatException catch (e) { + report(e); +} + +// v11 +final result = await client.updateUserPartial(userId, set: {'favorite_color': 'green'}); +if (result case Failure(:final error)) report(error); +``` + +**`updateUser` sends the user's name, image, language, visibility and custom data, and nothing else.** v10 sent +the whole user, so the user's online status, ban and timestamps, and an own user's devices, mutes and unread counts, +were stored as custom data on every call; that no longer happens. `role`, `teams` and `teamsRole` are no longer +sent, so these calls cannot change them. + +**The returned users no longer carry the fields only they can see in `extraData`.** v10 left every field `User` +does not model in `extraData` as raw JSON; now their devices, mutes, channel mutes, privacy settings, unread counts, blocked +user ids, hidden channels and token revocation time are left out. Read them from `currentUser`. `deactivatedAt`, +`deletedAt` and `shadowBanned` stay in `extraData` and gain typed getters on `User`; each one is filled whenever the +response carries it. + +**`UpdateUsersResponse` no longer decodes from JSON.** It is a plain class with a const constructor and final +fields, compares by value and gains `copyWith`, and its `duration` is always present. `UpdateUserPartialRequest` +no longer encodes to JSON or extends `Equatable`; it still compares by value and gains `copyWith`. + +**`StreamChatApi.user.updateUsers` and `partialUpdateUsers` are removed.** Call `updateUsers` and +`updateUsersPartial` on `StreamChatClient`. + +> **Why:** the endpoints moved onto the generated client, which returns a `Result` for every call. The partial +> updates take the API's names, as the channel and member partial updates already do. The request carries only +> what the user can change, so nothing of the client's state ends up stored as custom data. + ### Moderation **Muting, banning and flagging return a `Result` instead of throwing**, on both `StreamChatClient` diff --git a/openapi-migration/09-users.md b/openapi-migration/09-users.md index f33ead4ebe..6703cfcff6 100644 --- a/openapi-migration/09-users.md +++ b/openapi-migration/09-users.md @@ -1,8 +1,8 @@ # 09 — Users -**Goal:** `User` is the most widely referenced public model in the SDK; this is where keep-vs-adopt costs the most. [18](18-unread-counts.md) split the current user's unread counts out of it, and [19](19-user-blocking.md) blocking users. +**Goal:** `User` is the most widely referenced public model in the SDK; this is where keep-vs-adopt costs the most. [18](18-unread-counts.md) split the current user's unread counts out of it, [19](19-user-blocking.md) blocking users, and [20](20-user-updates.md) updating users. -**Size:** 5 hand-written method(s) across 1 file(s) → 5 generated operation(s). +**Size:** 3 hand-written method(s) across 1 file(s) → 3 generated operation(s). > This whole file is generated by `openapi-migration/tool/generate_plan.py`, prose included. > Edit its `GROUPS` entry and re-run the script — edits made here are lost on the next run. @@ -14,8 +14,6 @@ | File | Method | Returns | | --- | --- | --- | | `user_api.dart` | `queryUsers` | `QueryUsersResponse` | -| `user_api.dart` | `updateUsers` | `UpdateUsersResponse` | -| `user_api.dart` | `partialUpdateUsers` | `UpdateUsersResponse` | | `user_api.dart` | `getActiveLiveLocations` | `GetActiveLiveLocationsResponse` | | `user_api.dart` | `updateLiveLocation` | `Location` | @@ -26,22 +24,21 @@ | `GET` | `/api/v2/users/live_locations` | `getUserLiveLocations` | `SharedLocationsResponse` | | `GET` | `/api/v2/users` | `queryUsers` | `QueryUsersResponse` | | `PUT` | `/api/v2/users/live_locations` | `updateLiveLocation` | `SharedLocationResponse` | -| `POST` | `/api/v2/users` | `updateUsers` | `UpdateUsersResponse` | -| `PATCH` | `/api/v2/users` | `updateUsersPartial` | `UpdateUsersResponse` | ## Decisions to make - `User` and `OwnUser` are public, persisted, and embedded in nearly every other response. This group restructures them, last: the mappers in `user_mapper.dart` already map the generated types onto the current class for every group before it, and stay. See [01-foundation](01-foundation.md). - `PrivacySettings` and the push-preference sub-shapes — decide per type. -- `UserResponse.toModel()` leaves `OwnUser.topLevelFields`, `deleted_at`, `deactivated_at` and `revoke_tokens_issued_before` out of every user's `extraData` (`_shadowedCustomKeys`), where v1 kept the `OwnUser`-only keys in a plain user's `extraData`. Revisit once the mapper serves plain users. -- `UserFilterField.shadowBanned` and `.bypassModeration` read `extraData`, which the generated `UserResponse` has no field to fill. -- `updateUsers` reuses `User.toRequest()`. It is a full upsert, and v10's flattened body filed the user's client state (`online`, `banned`, `created_at` and similar) into the stored custom data on every call, as it did for guests in [04](04-roles-guest-and-app.md). Decide the same way here, for real users rather than fresh guests, and record it in the CHANGELOG. -- The generated `UserRequest` sends explicit `null` for an unset `language` or `invisible`, where v10 left the key out. For a guest create that made no difference; for an upsert of an existing user, confirm live that a `null` does not reset a stored value differently from an omitted key. +- `UserResponse.toModel()` and `FullUserResponse.toModel()` drop custom data named like one of the user's own fields (`_shadowedCustomKeys`): `deactivated_at`, `deleted_at` and `shadow_banned` are refilled from the typed fields, and the `OwnUser`-only keys and `revoke_tokens_issued_before` are left out, where v1 kept them in a plain user's `extraData`. Revisit once the mapper serves plain users. +- **Whether `User` promotes its `extraData`-backed getters to real fields.** `User.deactivatedAt`, `deletedAt` and `shadowBanned`, added in [20](20-user-updates.md) the way `Member` promoted its fields, arrive as root fields but live in `extraData` and are read back through getters; the constructors write them there. Promoting them is a break: their keys leave `extraData`, a key set in `extraData` no longer sets the field, and a `custom` filter or sort field naming one reads null locally. When promoting: (1) decide each field's `merge` rule (`OwnUser.merge` takes the other user's `extraData` whole today); (2) make `toJson` leave out `extraData` keys named like a field; (3) keep the constructor copying `extraData`; (4) add persistence columns; (5) point `UserFilterField.shadowBanned` at the field. Decide together with [11](11-channels-and-members.md)'s `ChannelModel` and `Member` promotions. `revokeTokensIssuedBefore` stays out of `User`. +- `UserFilterField.bypassModeration` reads `extraData`, which no generated user has a field to fill. +- `queryUsers` answers `FullUserResponse`, which [20](20-user-updates.md) already maps to `User`; the caller's own entry could map to `OwnUser` once `Mute`, `ChannelMute` and `PrivacySettings` have response mappers. ## Risks - Every other group depends on the `User` decision. - User data arrives over the WebSocket on nearly every event. +- `ConnectUserDetails.fromOwnUser` flattens the whole `extraData` into the connect payload, so the `deactivated_at`, `deleted_at` and `shadow_banned` entries the [20](20-user-updates.md) getters read go back to the server as custom data (v10 already did this for `shadow_banned`). Strip the user's own keys there when the WebSocket moves to v2. - Landing `UserResponse` -> `User` unblocks the two fields [08](08-moderation-and-blocklists.md) had to drop from `MuteUsersResponse`: the `mutes` the call created and the `ownUser` it left behind. Adding them is additive for anyone reading the response, so revisit them here rather than leaving them dropped for good. ## Definition of done diff --git a/openapi-migration/20-user-updates.md b/openapi-migration/20-user-updates.md new file mode 100644 index 0000000000..0cce36a308 --- /dev/null +++ b/openapi-migration/20-user-updates.md @@ -0,0 +1,74 @@ +# 20 — User Updates + +**Goal:** Move creating, updating and partially updating users ahead of [09](09-users.md): the response maps onto the current `User`, and nothing persists it. + +**Size:** 0 hand-written method(s) across 0 file(s) → 2 generated operation(s). + +> This whole file is generated by `openapi-migration/tool/generate_plan.py`, prose included. +> Edit its `GROUPS` entry and re-run the script — edits made here are lost on the next run. + +## Scope + +### Hand-written today + +| File | Method | Returns | +| --- | --- | --- | + +### Generated operations that cover it + +| Verb | Path | Operation | Response | +| --- | --- | --- | --- | +| `POST` | `/api/v2/users` | `updateUsers` | `UpdateUsersResponse` | +| `PATCH` | `/api/v2/users` | `updateUsersPartial` | `UpdateUsersResponse` | + +## Decisions taken + +- **Split out of [09](09-users.md), ahead of it.** `updateUser`, `updateUsers`, `partialUpdateUser` and + `partialUpdateUsers` read nothing into client state, as in v10; the `user.updated` event does. +- **The v2 routes are the v1 handlers.** `lib/core/api/users/routes.go` mounts `UpdateUsers` and + `UpdateUsersPartial` in the common routes, at the root and under `/api/v2/`; only the JSON encoding + differs. Neither is gated, in beta or deprecated. +- **The upsert sends `User.toRequest()`.** v10 sent the whole user flattened, and v1 stored every key + its request does not declare as custom data: the user's `online`, `banned` and timestamps, and an own + user's devices, mutes and unread counts. v2 drops unknown keys, so that stops, as it did for guests + in [04](04-roles-guest-and-app.md). `User.toRequest()` also leaves out the own-user keys an `OwnUser` + decoded from the connection keeps in `extraData` (`unread_count`, `total_unread_count_by_team`, + `latest_hidden_channels`), which v2 would otherwise store as custom data. `role`, `teams` and + `teams_role` are no longer sent: a client-side token never stores them, and a different role was + refused. +- **Moved off `UserApi`:** `updateUsers` and `partialUpdateUsers`, now `StreamChatClient` methods over + `UsersRepository`. +- **Explicit `null`s are not a regression.** The generated request sends `language: null` and + `invisible: null` where v10 left the keys out; the server reads a null or missing language as empty, + and a null or missing `invisible` as unchanged. +- **The partial updates take the API's names,** `updateUserPartial` and `updateUsersPartial`, with + `UpdateUserPartialRequest` (renamed from `PartialUpdateUserRequest`, freezed, without `toJson`), as + the channel and member partial updates already do. `updateUserPartial` keeps v10's arguments. +- **`UpdateUsersResponse` keeps its name** and maps each `FullUserResponse` to a `User`. No public + full-user type: a client-side caller can only update itself, the server blanks the private fields + for anyone else, and the caller's own are on `currentUser`. `membership_deletion_task_id` is always + empty and stays out. +- **`User` gains `deactivatedAt`, `deletedAt` and `shadowBanned`** as getters over `extraData`, the + way `Member` promoted its fields. Each generated user mapper fills the ones its response carries + (`UserResponse` has no `shadowBanned`), so the values v1 left raw in `extraData` are no longer lost; + persistence needs no change. + +## Risks + + +## Definition of done + +- [x] Every method above either routes through `DefaultApi` or is listed here as deliberately left + hand-written, with the reason. +- [x] Public methods return `Future>`; no `getOrThrow()` inside the SDK. +- [x] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [x] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group + persists anything. +- [x] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. +- [x] CHANGELOG entry under `🛑️ Breaking` for each break; PR title `refactor(llc)!:`. +- [x] Decisions recorded in this file, and the status box ticked in `README.md`. +- [x] Public dartdoc follows [`STYLE_GUIDE.md` § Documentation](../STYLE_GUIDE.md#documentation) + and [`EFFECTIVE_DART_DOC.md`](../EFFECTIVE_DART_DOC.md), including on symbols this group + retyped but whose docs it left alone. +- [x] Tests follow [`TESTING.md`](../TESTING.md): no `group` organizing a file by method, each + name states its subject and behaviour. diff --git a/openapi-migration/README.md b/openapi-migration/README.md index 18a0ca366c..2e2d2ac530 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -20,7 +20,7 @@ generated operations in scope, the decisions that group has to make, its risks, | [06](06-reminders.md) | Message Reminders | 4 | 4 | ☐ | | [07](07-threads-and-drafts.md) | Threads & Drafts | 7 | 7 | ☐ | | [08](08-moderation-and-blocklists.md) | Moderation & Blocklists | 0 | 34 | ☑ | -| [09](09-users.md) | Users | 5 | 5 | ☐ | +| [09](09-users.md) | Users | 3 | 3 | ☐ | | [10](10-messages.md) | Messages & Search | 14 | 12 | ☐ | | [11](11-channels-and-members.md) | Channels, Members & Sync | 13 | 15 | ☐ | | [12](12-uploads-cdn.md) | Uploads (CDN) | 8 | 8 | ☑ | @@ -31,8 +31,9 @@ generated operations in scope, the decisions that group has to make, its risks, | [17](17-read-receipts.md) | Read Receipts — split out of 11 | 0 | 4 | ☑ | | [18](18-unread-counts.md) | Unread Counts — split out of 09 | 0 | 1 | ☑ | | [19](19-user-blocking.md) | User Blocking — split out of 09 | 0 | 3 | ☑ | +| [20](20-user-updates.md) | User Updates — split out of 09 | 0 | 2 | ☑ | -**Coverage:** 66 hand-written methods across 10 files, and all 129 generated operations, each claimed by exactly +**Coverage:** 64 hand-written methods across 10 files, and all 129 generated operations, each claimed by exactly one group. Verified mechanically — see [Keeping this plan honest](#keeping-this-plan-honest). @@ -181,6 +182,9 @@ surfaces before it reaches `Message` and `ChannelState`: - **18** is the current user's unread counts, split out of 09 because it embeds no `User` and nothing persists it. - **19** is blocking and unblocking users and listing the blocked ones, split out of 09 because it embeds `User` only through the existing mappers and nothing persists it. +- **20** is creating, updating and partially updating users, split out of 09 because its response maps onto the + current `User` and nothing persists it. It also promotes `deactivatedAt`, `deletedAt` and `shadowBanned` onto + `User` as getters over `extraData`. - **12** comes late because it needs its own hand-written multipart client and is the highest-traffic path in the SDK. - **09** is last. Every group before it maps users through `user_mapper.dart` onto today's `User`; 09 migrates the diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index dd7d4fe241..36c45449a9 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -516,31 +516,42 @@ def match(verb, path): dict( num='09', slug='users', title='Users', hand=['user_api.dart'], - match=owns('/api/v2/users', unless=('/api/v2/users/block', '/api/v2/users/unblock')), + match=owns('/api/v2/users', unless=('/api/v2/users/block', '/api/v2/users/unblock'), + unless_ops=('POST /api/v2/users', 'PATCH /api/v2/users')), goal='`User` is the most widely referenced public model in the SDK; this is where keep-vs-adopt costs the ' - 'most. [18](18-unread-counts.md) split the current user\'s unread counts out of it, and ' - '[19](19-user-blocking.md) blocking users.', + 'most. [18](18-unread-counts.md) split the current user\'s unread counts out of it, ' + '[19](19-user-blocking.md) blocking users, and [20](20-user-updates.md) updating users.', decisions=[ '`User` and `OwnUser` are public, persisted, and embedded in nearly every other response. This group ' 'restructures them, last: the mappers in `user_mapper.dart` already map the generated types onto the ' 'current class for every group before it, and stay. See [01-foundation](01-foundation.md).', '`PrivacySettings` and the push-preference sub-shapes — decide per type.', - '`UserResponse.toModel()` leaves `OwnUser.topLevelFields`, `deleted_at`, `deactivated_at` and ' - '`revoke_tokens_issued_before` out of every user\'s `extraData` (`_shadowedCustomKeys`), where v1 kept the ' - '`OwnUser`-only keys in a plain user\'s `extraData`. Revisit once the mapper serves plain users.', - '`UserFilterField.shadowBanned` and `.bypassModeration` read `extraData`, which the generated ' - '`UserResponse` has no field to fill.', - '`updateUsers` reuses `User.toRequest()`. It is a full upsert, and v10\'s flattened body filed the ' - 'user\'s client state (`online`, `banned`, `created_at` and similar) into the stored custom data on ' - 'every call, as it did for guests in [04](04-roles-guest-and-app.md). Decide the same way here, for real ' - 'users rather than fresh guests, and record it in the CHANGELOG.', - 'The generated `UserRequest` sends explicit `null` for an unset `language` or `invisible`, where v10 ' - 'left the key out. For a guest create that made no difference; for an upsert of an existing user, ' - 'confirm live that a `null` does not reset a stored value differently from an omitted key.', + '`UserResponse.toModel()` and `FullUserResponse.toModel()` drop custom data named like one of the user\'s ' + 'own fields (`_shadowedCustomKeys`): `deactivated_at`, `deleted_at` and `shadow_banned` are refilled from ' + 'the typed fields, and the `OwnUser`-only keys and `revoke_tokens_issued_before` are left out, where v1 ' + 'kept them in a plain user\'s `extraData`. Revisit once the mapper serves plain users.', + '**Whether `User` promotes its `extraData`-backed getters to real fields.** `User.deactivatedAt`, ' + '`deletedAt` and `shadowBanned`, added in [20](20-user-updates.md) the way `Member` promoted its fields, ' + 'arrive as root fields but live in `extraData` and are read back through getters; the constructors write ' + 'them there. Promoting them is a break: their keys leave `extraData`, a key set in `extraData` no longer ' + 'sets the field, and a `custom` filter or sort field naming one reads null locally. When promoting: ' + '(1) decide each field\'s `merge` rule (`OwnUser.merge` takes the other user\'s `extraData` whole today); ' + '(2) make `toJson` leave out `extraData` keys named like a field; (3) keep the constructor copying ' + '`extraData`; (4) add persistence columns; (5) point `UserFilterField.shadowBanned` at the field. Decide ' + 'together with [11](11-channels-and-members.md)\'s `ChannelModel` and `Member` promotions. ' + '`revokeTokensIssuedBefore` stays out of `User`.', + '`UserFilterField.bypassModeration` reads `extraData`, which no generated user has a field to fill.', + '`queryUsers` answers `FullUserResponse`, which [20](20-user-updates.md) already maps to `User`; the ' + 'caller\'s own entry could map to `OwnUser` once `Mute`, `ChannelMute` and `PrivacySettings` have ' + 'response mappers.', ], risks=[ 'Every other group depends on the `User` decision.', 'User data arrives over the WebSocket on nearly every event.', + '`ConnectUserDetails.fromOwnUser` flattens the whole `extraData` into the connect payload, so the ' + '`deactivated_at`, `deleted_at` and `shadow_banned` entries the [20](20-user-updates.md) getters read go ' + 'back to the server as custom data (v10 already did this for `shadow_banned`). Strip the user\'s own ' + 'keys there when the WebSocket moves to v2.', 'Landing `UserResponse` -> `User` unblocks the two fields [08](08-moderation-and-blocklists.md) ' 'had to drop from `MuteUsersResponse`: the `mutes` the call created and the `ownUser` it left ' 'behind. Adding them is additive for anyone reading the response, so revisit them here rather ' @@ -978,6 +989,47 @@ def match(verb, path): risks=[], done=DONE.replace('- [ ]', '- [x]'), ), + dict( + num='20', slug='user-updates', title='User Updates', + hand=[], + match=only_ops('POST /api/v2/users', 'PATCH /api/v2/users'), + goal='Move creating, updating and partially updating users ahead of [09](09-users.md): the response maps ' + 'onto the current `User`, and nothing persists it.', + decisions=[], + taken=textwrap.dedent("""\ + - **Split out of [09](09-users.md), ahead of it.** `updateUser`, `updateUsers`, `partialUpdateUser` and + `partialUpdateUsers` read nothing into client state, as in v10; the `user.updated` event does. + - **The v2 routes are the v1 handlers.** `lib/core/api/users/routes.go` mounts `UpdateUsers` and + `UpdateUsersPartial` in the common routes, at the root and under `/api/v2/`; only the JSON encoding + differs. Neither is gated, in beta or deprecated. + - **The upsert sends `User.toRequest()`.** v10 sent the whole user flattened, and v1 stored every key + its request does not declare as custom data: the user's `online`, `banned` and timestamps, and an own + user's devices, mutes and unread counts. v2 drops unknown keys, so that stops, as it did for guests + in [04](04-roles-guest-and-app.md). `User.toRequest()` also leaves out the own-user keys an `OwnUser` + decoded from the connection keeps in `extraData` (`unread_count`, `total_unread_count_by_team`, + `latest_hidden_channels`), which v2 would otherwise store as custom data. `role`, `teams` and + `teams_role` are no longer sent: a client-side token never stores them, and a different role was + refused. + - **Moved off `UserApi`:** `updateUsers` and `partialUpdateUsers`, now `StreamChatClient` methods over + `UsersRepository`. + - **Explicit `null`s are not a regression.** The generated request sends `language: null` and + `invisible: null` where v10 left the keys out; the server reads a null or missing language as empty, + and a null or missing `invisible` as unchanged. + - **The partial updates take the API's names,** `updateUserPartial` and `updateUsersPartial`, with + `UpdateUserPartialRequest` (renamed from `PartialUpdateUserRequest`, freezed, without `toJson`), as + the channel and member partial updates already do. `updateUserPartial` keeps v10's arguments. + - **`UpdateUsersResponse` keeps its name** and maps each `FullUserResponse` to a `User`. No public + full-user type: a client-side caller can only update itself, the server blanks the private fields + for anyone else, and the caller's own are on `currentUser`. `membership_deletion_task_id` is always + empty and stays out. + - **`User` gains `deactivatedAt`, `deletedAt` and `shadowBanned`** as getters over `extraData`, the + way `Member` promoted its fields. Each generated user mapper fills the ones its response carries + (`UserResponse` has no `shadowBanned`), so the values v1 left raw in `extraData` are no longer lost; + persistence needs no change. + """), + risks=[], + done=DONE.replace('- [ ]', '- [x]'), + ), ] diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index a402ae2f3b..af0b2e58f6 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -111,11 +111,21 @@ - `UserBlock` no longer decodes from or encodes to JSON and no longer extends `Equatable`. - `UserBlock.blockedUser`, `userId`, `blockedUserId` and `createdAt` are required and non-nullable. - `StreamChatApi.user.blockUser`, `unblockUser` and `queryBlockedUsers` are removed; call `blockUser`, `unblockUser` and `getBlockedUsers` on `StreamChatClient` instead. +- `StreamChatClient.updateUser` and `updateUsers` return a `Result` instead of throwing. +- `StreamChatClient.partialUpdateUser` and `partialUpdateUsers` are renamed `updateUserPartial` and `updateUsersPartial`, and return a `Result` instead of throwing. +- `PartialUpdateUserRequest` is renamed `UpdateUserPartialRequest`. +- `UpdateUserPartialRequest` no longer encodes to JSON or extends `Equatable`, so `props` is removed; it still compares by value and gains `copyWith`. +- `UpdateUsersResponse` no longer decodes from JSON, is immutable, built through a const constructor, compares by value, gains `copyWith`, and its `duration` is a non-nullable `String`. +- The users in an `UpdateUsersResponse` no longer carry their devices, mutes, channel mutes, privacy settings, unread counts, blocked user ids, hidden channels or token revocation time in `extraData`. +- `updateUser` and `updateUsers` no longer send the user's `role`, `teams` and `teamsRole`. +- `StreamChatApi.user.updateUsers` and `partialUpdateUsers` are removed; call `updateUsers` and `updateUsersPartial` on `StreamChatClient` instead. 🐞 Fixed - `connectGuestUser` no longer copies the user's online status, ban, unread counts, devices and push preferences into the created guest's `extraData`. +- `updateUser` and `updateUsers` no longer store the user's online status, ban and timestamps, or an own user's devices, mutes and unread counts, as custom data. + - `queryChannels` no longer gives up watching the channels it loads when it is called before the connection is open. Whether they can be watched is read when the request is sent, which is after the query has waited for the connection. - A message that fails because its attachments did not upload now says which ones and what each reported, instead of `Failed to upload one or more attachments`. @@ -141,6 +151,7 @@ - Added an optional `timeout` to `StreamChatClient.moderation.muteUser` that expires the mute. - Added `StreamChatClient.moderation.muteUsers` and `unmuteUsers`, which mute and unmute several users in one call and report the ids that matched no user. - Added `ChannelModel.truncatedBy`, `autoTranslationEnabled` and `autoTranslationLanguage`, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, `futureChannelBanExpires`, `deletedAt` and `topLevelFields`; the constructors accept the new fields. +- Added `User.deactivatedAt`, `deletedAt` and `shadowBanned`; the constructors accept the new fields. 🔒 Security diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index a78ec4d2e8..3c8891a91c 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -60,6 +60,7 @@ import '../core/models/push_preference.dart'; import '../core/models/push_provider.dart'; import '../core/models/reaction.dart'; import '../core/models/request/message_delivery.dart'; +import '../core/models/request/update_user_partial_request.dart'; import '../core/models/response/add_user_group_members_response.dart'; import '../core/models/response/app_settings_response.dart'; import '../core/models/response/block_users_response.dart'; @@ -82,6 +83,7 @@ import '../core/models/response/unblock_users_response.dart'; import '../core/models/response/update_channel_partial_response.dart'; import '../core/models/response/update_member_partial_response.dart'; import '../core/models/response/update_user_group_response.dart'; +import '../core/models/response/update_users_response.dart'; import '../core/models/role_type.dart'; import '../core/models/thread.dart'; import '../core/models/user.dart'; @@ -1764,32 +1766,26 @@ class StreamChatClient { pagination: pagination, ); - /// Update or Create the given user object. - Future updateUser(User user) => updateUsers([user]); + /// Creates [user], or replaces the data of the existing user with the same id. + /// + /// This cannot change the user's role, teams or team roles. To change only some fields, consider + /// [updateUserPartial]. + Future> updateUser(User user) => updateUsers([user]); - /// Batch update a list of users - Future updateUsers(List users) => _chatApi.user.updateUsers(users); + /// Creates or replaces each of [users], as [updateUser] does for one. + Future> updateUsers(List users) => _usersRepository.updateUsers(users); - /// Partially update the given user with [id]. - /// Use [set] to define values to be set. - /// Use [unset] to define values to be unset. - Future partialUpdateUser( + /// Partially updates the user with [id]: sets the fields in [set] and removes the fields named in [unset], leaving + /// every other field as it is. + Future> updateUserPartial( String id, { Map? set, List? unset, - }) { - final user = PartialUpdateUserRequest( - id: id, - set: set, - unset: unset, - ); - return partialUpdateUsers([user]); - } + }) => updateUsersPartial([UpdateUserPartialRequest(id: id, set: set, unset: unset)]); - /// Batch partial updates the [users]. - Future partialUpdateUsers( - List users, - ) => _chatApi.user.partialUpdateUsers(users); + /// Partially updates several users at once, applying each of [updates] to the user it names. + Future> updateUsersPartial(List updates) => + _usersRepository.updateUsersPartial(updates); final _userBlockLock = Lock(); diff --git a/packages/stream_chat/lib/src/core/api/requests.dart b/packages/stream_chat/lib/src/core/api/requests.dart index 26ea16f9d7..c566daf837 100644 --- a/packages/stream_chat/lib/src/core/api/requests.dart +++ b/packages/stream_chat/lib/src/core/api/requests.dart @@ -141,32 +141,6 @@ class PaginationParams extends Equatable { ]; } -/// Request model for the [client.partialUpdateUser] api call. -@JsonSerializable(createFactory: false) -class PartialUpdateUserRequest extends Equatable { - /// Creates a new PartialUpdateUserRequest instance. - const PartialUpdateUserRequest({ - required this.id, - this.set, - this.unset, - }); - - /// User ID. - final String id; - - /// Fields to set. - final Map? set; - - /// Fields to unset. - final List? unset; - - /// Serialize model to json - Map toJson() => _$PartialUpdateUserRequestToJson(this); - - @override - List get props => [id, set, unset]; -} - /// {@template threadOptions} /// Options for querying threads. /// {@endtemplate} diff --git a/packages/stream_chat/lib/src/core/api/requests.g.dart b/packages/stream_chat/lib/src/core/api/requests.g.dart index 8d2380876a..73d38b0d70 100644 --- a/packages/stream_chat/lib/src/core/api/requests.g.dart +++ b/packages/stream_chat/lib/src/core/api/requests.g.dart @@ -42,17 +42,6 @@ Map _$PaginationParamsToJson(PaginationParams instance) => _$PartialUpdateUserRequestToJson( - PartialUpdateUserRequest instance, -) => { - 'stringify': instance.stringify, - 'hash_code': instance.hashCode, - 'id': instance.id, - 'set': instance.set, - 'unset': instance.unset, - 'props': instance.props, -}; - Map _$ThreadOptionsToJson(ThreadOptions instance) => { 'stringify': instance.stringify, 'hash_code': instance.hashCode, diff --git a/packages/stream_chat/lib/src/core/api/responses.dart b/packages/stream_chat/lib/src/core/api/responses.dart index 81bf723f4e..9eae1c6a3c 100644 --- a/packages/stream_chat/lib/src/core/api/responses.dart +++ b/packages/stream_chat/lib/src/core/api/responses.dart @@ -160,17 +160,6 @@ class SendReactionResponse extends MessageResponse { static SendReactionResponse fromJson(Map json) => _$SendReactionResponseFromJson(json); } -/// Model response for [StreamChatClient.updateUser] api call -@JsonSerializable(createToJson: false) -class UpdateUsersResponse extends _BaseResponse { - /// Updated users - @JsonKey(defaultValue: {}) - late Map users; - - /// Create a new instance from a json - static UpdateUsersResponse fromJson(Map json) => _$UpdateUsersResponseFromJson(json); -} - /// Base Model response for message based api calls. class MessageResponse extends _BaseResponse { /// Message returned by the api call diff --git a/packages/stream_chat/lib/src/core/api/responses.g.dart b/packages/stream_chat/lib/src/core/api/responses.g.dart index 81fe1ac9b9..5b73fa7e37 100644 --- a/packages/stream_chat/lib/src/core/api/responses.g.dart +++ b/packages/stream_chat/lib/src/core/api/responses.g.dart @@ -82,14 +82,6 @@ SendReactionResponse _$SendReactionResponseFromJson( ..message = Message.fromJson(json['message'] as Map) ..reaction = Reaction.fromJson(json['reaction'] as Map); -UpdateUsersResponse _$UpdateUsersResponseFromJson(Map json) => UpdateUsersResponse() - ..duration = json['duration'] as String? - ..users = - (json['users'] as Map?)?.map( - (k, e) => MapEntry(k, User.fromJson(e as Map)), - ) ?? - {}; - UpdateMessageResponse _$UpdateMessageResponseFromJson( Map json, ) => UpdateMessageResponse() diff --git a/packages/stream_chat/lib/src/core/api/user_api.dart b/packages/stream_chat/lib/src/core/api/user_api.dart index 8940d993fa..d5c6e4557a 100644 --- a/packages/stream_chat/lib/src/core/api/user_api.dart +++ b/packages/stream_chat/lib/src/core/api/user_api.dart @@ -38,32 +38,6 @@ class UserApi { return QueryUsersResponse.fromJson(response.data); } - /// Batch update a list of users - Future updateUsers( - List users, - ) async { - final response = await _client.post( - '/users', - data: { - 'users': {for (final user in users) user.id: user}, - }, - ); - return UpdateUsersResponse.fromJson(response.data); - } - - /// Batch partial update of [users]. - Future partialUpdateUsers( - List users, - ) async { - final response = await _client.patch( - '/users', - data: { - 'users': users, - }, - ); - return UpdateUsersResponse.fromJson(response.data); - } - /// Retrieves all the active live locations of the current user. Future getActiveLiveLocations() async { final response = await _client.get( diff --git a/packages/stream_chat/lib/src/core/models/own_user.dart b/packages/stream_chat/lib/src/core/models/own_user.dart index d509f02684..0cf7e541da 100644 --- a/packages/stream_chat/lib/src/core/models/own_user.dart +++ b/packages/stream_chat/lib/src/core/models/own_user.dart @@ -12,6 +12,9 @@ part 'own_user.g.dart'; @JsonSerializable(includeIfNull: false) class OwnUser extends User { /// Constructor used for json serialization. + /// + /// The [deactivatedAt], [deletedAt] and [shadowBanned] arguments, when given, are stored in [extraData], replacing + /// the entry each one reads. OwnUser({ this.devices = const [], this.mutes = const [], @@ -38,6 +41,9 @@ class OwnUser extends User { super.invisible, super.teamsRole, super.avgResponseTime, + super.deactivatedAt, + super.deletedAt, + super.shadowBanned, }); /// Create a new instance from json. diff --git a/packages/stream_chat/lib/src/core/models/request/update_user_partial_request.dart b/packages/stream_chat/lib/src/core/models/request/update_user_partial_request.dart new file mode 100644 index 0000000000..5d953f7207 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/request/update_user_partial_request.dart @@ -0,0 +1,29 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'update_user_partial_request.freezed.dart'; + +/// A partial update of one user: the fields in [set] are set and the fields named in [unset] are removed, leaving +/// every other field as it is. +/// +/// Passed to [StreamChatClient.updateUsersPartial]. +@freezed +class UpdateUserPartialRequest with _$UpdateUserPartialRequest { + /// Creates a partial update of the user with [id]. + const UpdateUserPartialRequest({ + required this.id, + this.set, + this.unset, + }); + + /// The id of the user to update. + @override + final String id; + + /// The fields to set, keyed by name. + @override + final Map? set; + + /// The names of the fields to remove. + @override + final List? unset; +} diff --git a/packages/stream_chat/lib/src/core/models/request/update_user_partial_request.freezed.dart b/packages/stream_chat/lib/src/core/models/request/update_user_partial_request.freezed.dart new file mode 100644 index 0000000000..4923b940f9 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/request/update_user_partial_request.freezed.dart @@ -0,0 +1,97 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'update_user_partial_request.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$UpdateUserPartialRequest { + String get id; + Map? get set; + List? get unset; + + /// Create a copy of UpdateUserPartialRequest + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $UpdateUserPartialRequestCopyWith get copyWith => + _$UpdateUserPartialRequestCopyWithImpl( + this as UpdateUserPartialRequest, + _$identity, + ); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is UpdateUserPartialRequest && + (identical(other.id, id) || other.id == id) && + const DeepCollectionEquality().equals(other.set, set) && + const DeepCollectionEquality().equals(other.unset, unset)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + id, + const DeepCollectionEquality().hash(set), + const DeepCollectionEquality().hash(unset), + ); + + @override + String toString() { + return 'UpdateUserPartialRequest(id: $id, set: $set, unset: $unset)'; + } +} + +/// @nodoc +abstract mixin class $UpdateUserPartialRequestCopyWith<$Res> { + factory $UpdateUserPartialRequestCopyWith( + UpdateUserPartialRequest value, + $Res Function(UpdateUserPartialRequest) _then, + ) = _$UpdateUserPartialRequestCopyWithImpl; + @useResult + $Res call({String id, Map? set, List? unset}); +} + +/// @nodoc +class _$UpdateUserPartialRequestCopyWithImpl<$Res> implements $UpdateUserPartialRequestCopyWith<$Res> { + _$UpdateUserPartialRequestCopyWithImpl(this._self, this._then); + + final UpdateUserPartialRequest _self; + final $Res Function(UpdateUserPartialRequest) _then; + + /// Create a copy of UpdateUserPartialRequest + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? set = freezed, + Object? unset = freezed, + }) { + return _then( + UpdateUserPartialRequest( + id: null == id + ? _self.id + : id // ignore: cast_nullable_to_non_nullable + as String, + set: freezed == set + ? _self.set + : set // ignore: cast_nullable_to_non_nullable + as Map?, + unset: freezed == unset + ? _self.unset + : unset // ignore: cast_nullable_to_non_nullable + as List?, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/response/update_users_response.dart b/packages/stream_chat/lib/src/core/models/response/update_users_response.dart new file mode 100644 index 0000000000..6d7a9bfc31 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/update_users_response.dart @@ -0,0 +1,26 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../user.dart'; + +part 'update_users_response.freezed.dart'; + +/// The users after an update. +/// +/// Returned by [StreamChatClient.updateUser], [StreamChatClient.updateUsers], [StreamChatClient.updateUserPartial] +/// and [StreamChatClient.updateUsersPartial]. +@freezed +class UpdateUsersResponse with _$UpdateUsersResponse { + /// Creates a new [UpdateUsersResponse]. + const UpdateUsersResponse({ + required this.duration, + required this.users, + }); + + /// How long the request took to handle, such as `4.21ms`. + @override + final String duration; + + /// The updated users, keyed by id. + @override + final Map users; +} diff --git a/packages/stream_chat/lib/src/core/models/response/update_users_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/update_users_response.freezed.dart new file mode 100644 index 0000000000..c549d538cf --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/update_users_response.freezed.dart @@ -0,0 +1,86 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'update_users_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$UpdateUsersResponse { + String get duration; + Map get users; + + /// Create a copy of UpdateUsersResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $UpdateUsersResponseCopyWith get copyWith => + _$UpdateUsersResponseCopyWithImpl( + this as UpdateUsersResponse, + _$identity, + ); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is UpdateUsersResponse && + (identical(other.duration, duration) || other.duration == duration) && + const DeepCollectionEquality().equals(other.users, users)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + duration, + const DeepCollectionEquality().hash(users), + ); + + @override + String toString() { + return 'UpdateUsersResponse(duration: $duration, users: $users)'; + } +} + +/// @nodoc +abstract mixin class $UpdateUsersResponseCopyWith<$Res> { + factory $UpdateUsersResponseCopyWith( + UpdateUsersResponse value, + $Res Function(UpdateUsersResponse) _then, + ) = _$UpdateUsersResponseCopyWithImpl; + @useResult + $Res call({String duration, Map users}); +} + +/// @nodoc +class _$UpdateUsersResponseCopyWithImpl<$Res> implements $UpdateUsersResponseCopyWith<$Res> { + _$UpdateUsersResponseCopyWithImpl(this._self, this._then); + + final UpdateUsersResponse _self; + final $Res Function(UpdateUsersResponse) _then; + + /// Create a copy of UpdateUsersResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? duration = null, Object? users = null}) { + return _then( + UpdateUsersResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + users: null == users + ? _self.users + : users // ignore: cast_nullable_to_non_nullable + as Map, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/user.dart b/packages/stream_chat/lib/src/core/models/user.dart index 4719012b2e..d44ec87f76 100644 --- a/packages/stream_chat/lib/src/core/models/user.dart +++ b/packages/stream_chat/lib/src/core/models/user.dart @@ -34,6 +34,9 @@ class User extends Equatable { /// print(user.image == user.extraData['image']); // true /// ``` /// {@endtemplate} + /// + /// The [deactivatedAt], [deletedAt] and [shadowBanned] arguments, when given, are stored in [extraData], replacing + /// the entry each one reads. User({ required this.id, this.role, @@ -51,11 +54,18 @@ class User extends Equatable { this.teamsRole, this.avgResponseTime, Map extraData = const {}, - }) : // For backwards compatibility, set 'name', 'image' in [extraData]. + DateTime? deactivatedAt, + DateTime? deletedAt, + bool? shadowBanned, + }) : // 'name' and 'image' are set in [extraData] for backwards compatibility; the others so they round-trip through + // toJson and fromJson without fields of their own. extraData = { ...extraData, if (name != null) 'name': name, if (image != null) 'image': image, + if (deactivatedAt != null) 'deactivated_at': deactivatedAt.toUtc().toIso8601String(), + if (deletedAt != null) 'deleted_at': deletedAt.toUtc().toIso8601String(), + if (shadowBanned != null) 'shadow_banned': shadowBanned, }; /// Create a new instance from json. @@ -147,6 +157,29 @@ class User extends Equatable { /// Map of custom user extraData. final Map extraData; + /// The date at which the user was deactivated. + /// + /// Null while the user is active, or when not known. + @JsonKey(includeToJson: false, includeFromJson: false) + DateTime? get deactivatedAt => _date('deactivated_at'); + + /// The date at which the user was deleted. + /// + /// Null while the user exists, or when not known. + @JsonKey(includeToJson: false, includeFromJson: false) + DateTime? get deletedAt => _date('deleted_at'); + + /// Whether the user is shadow banned, so that their messages reach no one else without them being told. + /// + /// Null when not known. + @JsonKey(includeToJson: false, includeFromJson: false) + bool? get shadowBanned => extraData['shadow_banned'].safeCast(); + + DateTime? _date(String key) => switch (extraData[key]) { + final String value => DateTime.tryParse(value), + _ => null, + }; + /// List of users to list of userIds. static List? toIds(List? users) => users?.map((u) => u.id).toList(); @@ -302,7 +335,7 @@ class UserFilterField extends FilterField { /// **Supported operators:** `$eq` static final shadowBanned = UserFilterField( 'shadow_banned', - (it) => it.extraData['shadow_banned'].safeCast(), + (it) => it.shadowBanned, ); /// Filters users by whether they bypass moderation. diff --git a/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart index e21488f28d..c3f819b81f 100644 --- a/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart @@ -1,11 +1,13 @@ import '../../../open_api/api.dart' as api; import '../../core/models/own_user.dart'; import '../../core/models/privacy_settings.dart'; +import '../../core/models/request/update_user_partial_request.dart'; import '../../core/models/response/block_users_response.dart'; import '../../core/models/response/create_guest_user_response.dart'; import '../../core/models/response/get_blocked_users_response.dart'; import '../../core/models/response/get_unread_count_response.dart'; import '../../core/models/response/unblock_users_response.dart'; +import '../../core/models/response/update_users_response.dart'; import '../../core/models/unread_counts_channel.dart'; import '../../core/models/unread_counts_channel_type.dart'; import '../../core/models/unread_counts_thread.dart'; @@ -22,7 +24,11 @@ extension UserResponseMapper on api.UserResponse { ...OwnUser.topLevelFields, 'deleted_at', 'deactivated_at', + 'shadow_banned', 'revoke_tokens_issued_before', + 'unread_count', + 'total_unread_count_by_team', + 'latest_hidden_channels', }; /// Converts this response into a [User]. @@ -43,6 +49,36 @@ extension UserResponseMapper on api.UserResponse { teamsRole: teamsRole, avgResponseTime: avgResponseTime, extraData: {...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), + deactivatedAt: deactivatedAt, + deletedAt: deletedAt, + ); +} + +/// Maps a generated [api.FullUserResponse] to a [User]. +extension FullUserResponseMapper on api.FullUserResponse { + /// Converts this response into a [User]. + /// + /// Custom data named like one of the user's own fields is left out of [User.extraData]. + User toModel() => User( + id: id, + role: role, + name: name, + image: image, + createdAt: createdAt, + updatedAt: updatedAt, + lastActive: lastActive, + online: online, + banned: banned, + banExpires: banExpires, + teams: teams, + language: language, + invisible: invisible, + teamsRole: teamsRole, + avgResponseTime: avgResponseTime, + extraData: {...custom}..removeWhere((key, _) => UserResponseMapper._shadowedCustomKeys.contains(key)), + deactivatedAt: deactivatedAt, + deletedAt: deletedAt, + shadowBanned: shadowBanned, ); } @@ -182,3 +218,18 @@ extension BlockedUserResponseMapper on api.BlockedUserResponse { createdAt: createdAt, ); } + +/// Maps a generated [api.UpdateUsersResponse] to an [UpdateUsersResponse]. +extension UpdateUsersResponseMapper on api.UpdateUsersResponse { + /// Converts this response into an [UpdateUsersResponse]. + UpdateUsersResponse toModel() => UpdateUsersResponse( + duration: duration, + users: users.map((id, user) => MapEntry(id, user.toModel())), + ); +} + +/// Maps an [UpdateUserPartialRequest] to the generated [api.UpdateUserPartialRequest]. +extension UpdateUserPartialRequestMapper on UpdateUserPartialRequest { + /// Converts this update into an [api.UpdateUserPartialRequest]. + api.UpdateUserPartialRequest toRequest() => api.UpdateUserPartialRequest(id: id, set: set, unset: unset); +} diff --git a/packages/stream_chat/lib/src/repository/users_repository.dart b/packages/stream_chat/lib/src/repository/users_repository.dart index 8f9bf5e319..236d5cdb14 100644 --- a/packages/stream_chat/lib/src/repository/users_repository.dart +++ b/packages/stream_chat/lib/src/repository/users_repository.dart @@ -1,10 +1,13 @@ import 'package:stream_core/stream_core.dart' show PatternMatching, Result; import '../../open_api/api.dart' as api; +import '../core/models/request/update_user_partial_request.dart'; import '../core/models/response/block_users_response.dart'; import '../core/models/response/get_blocked_users_response.dart'; import '../core/models/response/get_unread_count_response.dart'; import '../core/models/response/unblock_users_response.dart'; +import '../core/models/response/update_users_response.dart'; +import '../core/models/user.dart'; import 'mapper/user_mapper.dart'; /// Repository dedicated to user operations. @@ -37,4 +40,22 @@ class UsersRepository { final result = await _api.getBlockedUsers(); return result.map((response) => response.toModel()); } + + /// Creates or replaces each of [users], as [StreamChatClient.updateUser] does for one. + Future> updateUsers(List users) async { + final result = await _api.updateUsers( + updateUsersRequest: api.UpdateUsersRequest(users: {for (final user in users) user.id: user.toRequest()}), + ); + + return result.map((response) => response.toModel()); + } + + /// Partially updates several users at once, applying each of [updates] to the user it names. + Future> updateUsersPartial(List updates) async { + final result = await _api.updateUsersPartial( + updateUsersPartialRequest: api.UpdateUsersPartialRequest(users: [for (final it in updates) it.toRequest()]), + ); + + return result.map((response) => response.toModel()); + } } diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index 81757467c4..4c726f14a3 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -120,6 +120,7 @@ export 'src/core/models/reaction.dart'; export 'src/core/models/reaction_group.dart'; export 'src/core/models/read.dart'; export 'src/core/models/request/message_delivery.dart'; +export 'src/core/models/request/update_user_partial_request.dart'; export 'src/core/models/response/add_user_group_members_response.dart'; export 'src/core/models/response/app_settings_response.dart'; export 'src/core/models/response/block_users_response.dart'; @@ -145,6 +146,7 @@ export 'src/core/models/response/unmute_users_response.dart'; export 'src/core/models/response/update_channel_partial_response.dart'; export 'src/core/models/response/update_member_partial_response.dart'; export 'src/core/models/response/update_user_group_response.dart'; +export 'src/core/models/response/update_users_response.dart'; export 'src/core/models/role.dart'; export 'src/core/models/role_type.dart'; export 'src/core/models/thread.dart'; diff --git a/packages/stream_chat/test/fixtures/user.json b/packages/stream_chat/test/fixtures/user.json index 0e56fefa0b..e6cd52faac 100644 --- a/packages/stream_chat/test/fixtures/user.json +++ b/packages/stream_chat/test/fixtures/user.json @@ -16,5 +16,8 @@ "language": "en", "invisible": false, "teams_role": {"team-1": "admin", "team-2": "member"}, - "avg_response_time": 120 + "avg_response_time": 120, + "deactivated_at": "2021-08-06T10:39:21.817646Z", + "deleted_at": "2021-08-07T10:39:21.817646Z", + "shadow_banned": true } \ No newline at end of file diff --git a/packages/stream_chat/test/src/client/client_connect_guest_user_test.dart b/packages/stream_chat/test/src/client/client_connect_guest_user_test.dart index c5d5ef6ccd..9471d192fa 100644 --- a/packages/stream_chat/test/src/client/client_connect_guest_user_test.dart +++ b/packages/stream_chat/test/src/client/client_connect_guest_user_test.dart @@ -102,6 +102,7 @@ void main() { 'privacy_settings': 'custom-privacy-settings', 'deleted_at': 'custom-deleted-at', 'deactivated_at': 'custom-deactivated-at', + 'shadow_banned': 'custom-shadow-banned', 'revoke_tokens_issued_before': 'custom-revoke-tokens-issued-before', }, ); @@ -113,6 +114,8 @@ void main() { 'favorite_color': 'green', 'name': 'Created Name', 'image': 'https://example.com/created.png', + 'deactivated_at': '2026-09-04T00:00:00.000Z', + 'deleted_at': '2026-09-05T00:00:00.000Z', }); }, ); @@ -287,4 +290,6 @@ User _createdGuest() => User( createdAt: DateTime.utc(2026, 9, 1), updatedAt: DateTime.utc(2026, 9, 2), lastActive: DateTime.utc(2026, 9, 3), + deactivatedAt: DateTime.utc(2026, 9, 4), + deletedAt: DateTime.utc(2026, 9, 5), ); diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 59da17c622..016f49b006 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -3316,64 +3316,6 @@ void main() { verifyNoMoreInteractions(fakeChatApi.polls); }); - test('`.updateUser`', () async { - final user = User( - id: 'test-user-id', - extraData: const {'name': 'test-user'}, - ); - - when( - () => fakeChatApi.user.updateUsers([user]), - ).thenAnswer((_) async => UpdateUsersResponse()..users = {user.id: user}); - - final res = await client.updateUser(user); - - expect(res, isNotNull); - expect(res.users, {user.id: user}); - - verify(() => fakeChatApi.user.updateUsers([user])).called(1); - verifyNoMoreInteractions(fakeChatApi.user); - }); - - test('`.partialUpdateUser`', () async { - const userId = 'test-user-id'; - - final set = {'color': 'yellow'}; - final unset = []; - - final partialUpdateRequest = PartialUpdateUserRequest( - id: userId, - set: set, - unset: unset, - ); - - final updatedUser = User( - id: userId, - extraData: {'color': set['color']}, - ); - - when(() => fakeChatApi.user.partialUpdateUsers([partialUpdateRequest])).thenAnswer( - (_) async => UpdateUsersResponse() - ..users = { - updatedUser.id: updatedUser, - }, - ); - - final res = await client.partialUpdateUser( - userId, - set: set, - unset: unset, - ); - - expect(res, isNotNull); - expect(res.users, {updatedUser.id: updatedUser}); - - verify( - () => fakeChatApi.user.partialUpdateUsers([partialUpdateRequest]), - ).called(1); - verifyNoMoreInteractions(fakeChatApi.user); - }); - test('`.getActiveLiveLocations`', () async { final locations = [ Location( diff --git a/packages/stream_chat/test/src/client/client_update_users_partial_test.dart b/packages/stream_chat/test/src/client/client_update_users_partial_test.dart new file mode 100644 index 0000000000..cb9df87389 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_update_users_partial_test.dart @@ -0,0 +1,151 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/open_api/api.dart' as api; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; + +void main() { + setUpAll(() => registerFallbackValue(const api.UpdateUsersPartialRequest(users: []))); + + test('StreamChatClient.updateUserPartial sends the set and unset fields and returns the updated users', () async { + const request = api.UpdateUsersPartialRequest( + users: [ + api.UpdateUserPartialRequest(id: 'user-id', set: {'favorite_color': 'green'}, unset: ['nickname']), + ], + ); + final defaultApi = _defaultApiAnswering(Result.success(_updateUsersResponse())); + final client = _client(defaultApi); + + final res = await client.updateUserPartial( + 'user-id', + set: const {'favorite_color': 'green'}, + unset: const ['nickname'], + ); + + expect( + res.getOrNull(), + UpdateUsersResponse(duration: '4.21ms', users: {'user-id': _updatedUser()}), + ); + verify(() => defaultApi.updateUsersPartial(updateUsersPartialRequest: request)).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.updateUsersPartial sends every update and returns the updated users', () async { + const request = api.UpdateUsersPartialRequest( + users: [ + api.UpdateUserPartialRequest(id: 'first', set: {'a': 1}), + api.UpdateUserPartialRequest(id: 'second', unset: ['b']), + ], + ); + final defaultApi = _defaultApiAnswering(Result.success(_updateUsersResponse())); + final client = _client(defaultApi); + + final res = await client.updateUsersPartial(const [ + UpdateUserPartialRequest(id: 'first', set: {'a': 1}), + UpdateUserPartialRequest(id: 'second', unset: ['b']), + ]); + + expect(res.getOrNull(), UpdateUsersResponse(duration: '4.21ms', users: {'user-id': _updatedUser()})); + verify(() => defaultApi.updateUsersPartial(updateUsersPartialRequest: request)).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.updateUserPartial returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final client = _client(_defaultApiAnswering(const Result.failure(error))); + + final res = await client.updateUserPartial('user-id', set: const {'a': 1}); + + expect(res.exceptionOrNull(), error); + }); + + test('StreamChatClient.updateUsersPartial returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final client = _client(_defaultApiAnswering(const Result.failure(error))); + + final res = await client.updateUsersPartial(const [ + UpdateUserPartialRequest(id: 'user-id', unset: ['a']), + ]); + + expect(res.exceptionOrNull(), error); + }); +} + +StreamChatClient _client(api.DefaultApi defaultApi) { + final client = StreamChatClient('test-api-key', defaultApi: defaultApi); + addTearDown(client.dispose); + return client; +} + +MockDefaultApi _defaultApiAnswering(Result result) { + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateUsersPartial(updateUsersPartialRequest: any(named: 'updateUsersPartialRequest')), + ).thenAnswer((_) async => result); + return defaultApi; +} + +// The user [_updateUsersResponse] carries. +User _updatedUser() => User( + id: 'user-id', + role: 'admin', + name: 'Updated Name', + image: 'https://example.com/updated.png', + createdAt: DateTime.utc(2026, 9, 1), + updatedAt: DateTime.utc(2026, 9, 2), + lastActive: DateTime.utc(2026, 9, 3), + online: true, + banned: true, + banExpires: DateTime.utc(2026, 9, 4), + teams: const ['team-a'], + language: 'de', + invisible: false, + teamsRole: const {'team-a': 'member'}, + avgResponseTime: 42, + extraData: const {'favorite_color': 'green'}, + deactivatedAt: DateTime.utc(2026, 9, 5), + deletedAt: DateTime.utc(2026, 9, 6), + shadowBanned: false, +); + +// A response whose user sets every field, including the private ones a [User] leaves out. +api.UpdateUsersResponse _updateUsersResponse({ + Map custom = const {'favorite_color': 'green'}, +}) => api.UpdateUsersResponse( + duration: '4.21ms', + membershipDeletionTaskId: '', + users: { + 'user-id': api.FullUserResponse( + id: 'user-id', + role: 'admin', + name: 'Updated Name', + image: 'https://example.com/updated.png', + createdAt: DateTime.utc(2026, 9, 1), + updatedAt: DateTime.utc(2026, 9, 2), + lastActive: DateTime.utc(2026, 9, 3), + online: true, + banned: true, + banExpires: DateTime.utc(2026, 9, 4), + teams: const ['team-a'], + language: 'de', + invisible: false, + teamsRole: const {'team-a': 'member'}, + avgResponseTime: 42, + custom: custom, + deactivatedAt: DateTime.utc(2026, 9, 5), + deletedAt: DateTime.utc(2026, 9, 6), + shadowBanned: false, + revokeTokensIssuedBefore: DateTime.utc(2026, 9, 7), + blockedUserIds: const ['blocked-user'], + channelMutes: const [], + devices: const [], + mutes: const [], + latestHiddenChannels: const ['messaging:hidden'], + totalUnreadCount: 5, + unreadChannels: 2, + unreadCount: 4, + unreadThreads: 1, + ), + }, +); diff --git a/packages/stream_chat/test/src/client/client_update_users_test.dart b/packages/stream_chat/test/src/client/client_update_users_test.dart new file mode 100644 index 0000000000..e0dc75ea0b --- /dev/null +++ b/packages/stream_chat/test/src/client/client_update_users_test.dart @@ -0,0 +1,226 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/open_api/api.dart' as api; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; + +void main() { + setUpAll(() => registerFallbackValue(const api.UpdateUsersRequest(users: {}))); + + test( + 'StreamChatClient.updateUser sends the name, image, language, visibility and custom data and returns the updated ' + 'users', + () async { + final defaultApi = _defaultApiAnswering(Result.success(_updateUsersResponse())); + final client = _client(defaultApi); + + final res = await client.updateUser( + User( + id: 'user-id', + role: 'admin', + name: 'Jane', + image: 'https://example.com/jane.png', + createdAt: DateTime.utc(2026, 1, 1), + updatedAt: DateTime.utc(2026, 1, 2), + lastActive: DateTime.utc(2026, 1, 3), + online: true, + banned: true, + banExpires: DateTime.utc(2026, 1, 4), + teams: const ['team-a'], + language: 'fr', + invisible: true, + teamsRole: const {'team-a': 'admin'}, + avgResponseTime: 12, + extraData: const {'favorite_color': 'blue'}, + deactivatedAt: DateTime.utc(2026, 1, 5), + deletedAt: DateTime.utc(2026, 1, 6), + shadowBanned: true, + ), + ); + + expect(res.getOrNull(), UpdateUsersResponse(duration: '4.21ms', users: {'user-id': _updatedUser()})); + // `User` equality leaves these two out. + expect(res.getOrNull()!.users['user-id']!.createdAt, DateTime.utc(2026, 9, 1)); + expect(res.getOrNull()!.users['user-id']!.updatedAt, DateTime.utc(2026, 9, 2)); + verify( + () => defaultApi.updateUsers( + updateUsersRequest: const api.UpdateUsersRequest( + users: { + 'user-id': api.UserRequest( + id: 'user-id', + name: 'Jane', + image: 'https://example.com/jane.png', + language: 'fr', + invisible: true, + custom: {'favorite_color': 'blue'}, + ), + }, + ), + ), + ).called(1); + verifyNoMoreInteractions(defaultApi); + }, + ); + + test("StreamChatClient.updateUser sends no custom data named like one of the user's own fields", () async { + final defaultApi = _defaultApiAnswering(Result.success(_updateUsersResponse())); + final client = _client(defaultApi); + + // The fields an own user decoded from the connection keeps in its extra data. + await client.updateUser( + User( + id: 'user-id', + extraData: const { + 'favorite_color': 'blue', + 'unread_count': 3, + 'total_unread_count_by_team': {'team-a': 1}, + 'latest_hidden_channels': ['messaging:hidden'], + 'revoke_tokens_issued_before': '2026-01-01T00:00:00.000Z', + }, + ), + ); + + verify( + () => defaultApi.updateUsers( + updateUsersRequest: const api.UpdateUsersRequest( + users: { + 'user-id': api.UserRequest(id: 'user-id', custom: {'favorite_color': 'blue'}), + }, + ), + ), + ).called(1); + }); + + test('StreamChatClient.updateUser drops custom data named like a user field', () async { + final client = _client( + _defaultApiAnswering( + Result.success( + _updateUsersResponse( + custom: const {'favorite_color': 'green', 'online': false, 'shadow_banned': true, 'unread_count': 3}, + ), + ), + ), + ); + + final res = await client.updateUser(User(id: 'user-id')); + + expect(res.getOrNull()!.users['user-id'], _updatedUser()); + }); + + test('StreamChatClient.updateUsers sends every user keyed by id and returns the updated users', () async { + final defaultApi = _defaultApiAnswering(Result.success(_updateUsersResponse())); + final client = _client(defaultApi); + + final res = await client.updateUsers([User(id: 'first', name: 'First'), User(id: 'second', name: 'Second')]); + + expect(res.getOrNull(), UpdateUsersResponse(duration: '4.21ms', users: {'user-id': _updatedUser()})); + verify( + () => defaultApi.updateUsers( + updateUsersRequest: const api.UpdateUsersRequest( + users: { + 'first': api.UserRequest(id: 'first', name: 'First', custom: {}), + 'second': api.UserRequest(id: 'second', name: 'Second', custom: {}), + }, + ), + ), + ).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.updateUser returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final client = _client(_defaultApiAnswering(const Result.failure(error))); + + final res = await client.updateUser(User(id: 'user-id')); + + expect(res.exceptionOrNull(), error); + }); + + test('StreamChatClient.updateUsers returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final client = _client(_defaultApiAnswering(const Result.failure(error))); + + final res = await client.updateUsers([User(id: 'first'), User(id: 'second')]); + + expect(res.exceptionOrNull(), error); + }); +} + +StreamChatClient _client(api.DefaultApi defaultApi) { + final client = StreamChatClient('test-api-key', defaultApi: defaultApi); + addTearDown(client.dispose); + return client; +} + +MockDefaultApi _defaultApiAnswering(Result result) { + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateUsers(updateUsersRequest: any(named: 'updateUsersRequest')), + ).thenAnswer((_) async => result); + return defaultApi; +} + +// The user [_updateUsersResponse] carries. +User _updatedUser() => User( + id: 'user-id', + role: 'admin', + name: 'Updated Name', + image: 'https://example.com/updated.png', + createdAt: DateTime.utc(2026, 9, 1), + updatedAt: DateTime.utc(2026, 9, 2), + lastActive: DateTime.utc(2026, 9, 3), + online: true, + banned: true, + banExpires: DateTime.utc(2026, 9, 4), + teams: const ['team-a'], + language: 'de', + invisible: false, + teamsRole: const {'team-a': 'member'}, + avgResponseTime: 42, + extraData: const {'favorite_color': 'green'}, + deactivatedAt: DateTime.utc(2026, 9, 5), + deletedAt: DateTime.utc(2026, 9, 6), + shadowBanned: false, +); + +// A response whose user sets every field, including the private ones a [User] leaves out. +api.UpdateUsersResponse _updateUsersResponse({ + Map custom = const {'favorite_color': 'green'}, +}) => api.UpdateUsersResponse( + duration: '4.21ms', + membershipDeletionTaskId: '', + users: { + 'user-id': api.FullUserResponse( + id: 'user-id', + role: 'admin', + name: 'Updated Name', + image: 'https://example.com/updated.png', + createdAt: DateTime.utc(2026, 9, 1), + updatedAt: DateTime.utc(2026, 9, 2), + lastActive: DateTime.utc(2026, 9, 3), + online: true, + banned: true, + banExpires: DateTime.utc(2026, 9, 4), + teams: const ['team-a'], + language: 'de', + invisible: false, + teamsRole: const {'team-a': 'member'}, + avgResponseTime: 42, + custom: custom, + deactivatedAt: DateTime.utc(2026, 9, 5), + deletedAt: DateTime.utc(2026, 9, 6), + shadowBanned: false, + revokeTokensIssuedBefore: DateTime.utc(2026, 9, 7), + blockedUserIds: const ['blocked-user'], + channelMutes: const [], + devices: const [], + mutes: const [], + latestHiddenChannels: const ['messaging:hidden'], + totalUnreadCount: 5, + unreadChannels: 2, + unreadCount: 4, + unreadThreads: 1, + ), + }, +); diff --git a/packages/stream_chat/test/src/core/api/responses_test.dart b/packages/stream_chat/test/src/core/api/responses_test.dart index 3ba17b59d7..64d48e830e 100644 --- a/packages/stream_chat/test/src/core/api/responses_test.dart +++ b/packages/stream_chat/test/src/core/api/responses_test.dart @@ -3471,22 +3471,6 @@ void main() { expect(response.reaction, isA()); }); - test('UpdateUsersResponse', () { - const jsonExample = ''' - {"users": {"bbb19d9a-ee50-45bc-84e5-0584e79d0c9e":{ - "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", - "role": "user", - "created_at": "2020-01-28T22:17:30.826259Z", - "updated_at": "2020-01-28T22:17:31.101222Z", - "banned": false, - "online": false, - "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg", - "name": "Robin Papa" - }},"duration":"0.35ms"}'''; - final response = UpdateUsersResponse.fromJson(json.decode(jsonExample)); - expect(response.users, isA>()); - }); - test('GetMessagesByIdResponse', () { const jsonExample = r''' {"messages":[{ diff --git a/packages/stream_chat/test/src/core/api/user_api_test.dart b/packages/stream_chat/test/src/core/api/user_api_test.dart index f9c1997e9f..d685444c31 100644 --- a/packages/stream_chat/test/src/core/api/user_api_test.dart +++ b/packages/stream_chat/test/src/core/api/user_api_test.dart @@ -69,74 +69,6 @@ void main() { verifyNoMoreInteractions(client); }); - test('updateUsers', () async { - final users = List.generate(3, (index) => User(id: 'test-user-id-$index')); - - const path = '/users'; - - final updatedUsers = {for (final user in users) user.id: user}; - - when( - () => client.post( - path, - data: { - 'users': updatedUsers, - }, - ), - ).thenAnswer( - (_) async => - successResponse(path, data: {'users': updatedUsers.map((key, value) => MapEntry(key, value.toJson()))}), - ); - - final res = await userApi.updateUsers(users); - - expect(res, isNotNull); - expect(res.users.length, updatedUsers.length); - - verify(() => client.post(path, data: any(named: 'data'))).called(1); - verifyNoMoreInteractions(client); - }); - - test('partialUpdateUsers', () async { - const user = PartialUpdateUserRequest( - id: 'test-user-id', - set: {'color': 'yellow'}, - ); - - const path = '/users'; - - final updatedUser = {user.id: User(id: user.id, extraData: user.set!)}; - - when( - () => client.patch( - path, - data: { - 'users': [user], - }, - ), - ).thenAnswer( - (_) async => successResponse( - path, - data: {'users': updatedUser.map((key, value) => MapEntry(key, value.toJson()))}, - ), - ); - - final res = await userApi.partialUpdateUsers([user]); - - expect(res, isNotNull); - expect(res.users.length, updatedUser.length); - - verify( - () => client.patch( - path, - data: { - 'users': [user], - }, - ), - ).called(1); - verifyNoMoreInteractions(client); - }); - test('getActiveLiveLocations', () async { const path = '/users/live_locations'; diff --git a/packages/stream_chat/test/src/core/models/user_test.dart b/packages/stream_chat/test/src/core/models/user_test.dart index b725056693..e018faeac9 100644 --- a/packages/stream_chat/test/src/core/models/user_test.dart +++ b/packages/stream_chat/test/src/core/models/user_test.dart @@ -45,6 +45,46 @@ void main() { expect(user.invisible, false); expect(user.teamsRole, teamsRole); expect(user.avgResponseTime, avgResponseTime); + expect(user.deactivatedAt, DateTime.parse('2021-08-06T10:39:21.817646Z')); + expect(user.deletedAt, DateTime.parse('2021-08-07T10:39:21.817646Z')); + expect(user.shadowBanned, isTrue); + }); + + test('User keeps deactivatedAt, deletedAt and shadowBanned in extraData', () { + final user = User( + id: id, + deactivatedAt: DateTime.utc(2021), + deletedAt: DateTime.utc(2020), + shadowBanned: true, + extraData: const {'color': 'red'}, + ); + + expect(user.extraData, { + 'color': 'red', + 'deactivated_at': DateTime.utc(2021).toIso8601String(), + 'deleted_at': DateTime.utc(2020).toIso8601String(), + 'shadow_banned': true, + }); + }); + + test('User replaces the extraData entries that deactivatedAt, deletedAt and shadowBanned read', () { + final user = User( + id: id, + deactivatedAt: DateTime.utc(2021), + deletedAt: DateTime.utc(2020), + shadowBanned: true, + extraData: const {'deactivated_at': 'stale', 'deleted_at': 'stale', 'shadow_banned': false}, + ); + + expect(user.deactivatedAt, DateTime.utc(2021)); + expect(user.deletedAt, DateTime.utc(2020)); + expect(user.shadowBanned, isTrue); + }); + + test('User.deletedAt returns null for an extraData value that is not a date', () { + final user = User(id: id, extraData: const {'deleted_at': 'yesterday'}); + + expect(user.deletedAt, isNull); }); test('should serialize to json correctly', () { From bb65acd8c37d227311b9c9e97efe40eeaa4efce9 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 9 Oct 2026 16:35:27 +0200 Subject: [PATCH 2/9] refactor(llc): rename the user mapper file to users_mapper.dart Co-Authored-By: Claude Opus 5.5 --- packages/stream_chat/lib/src/repository/general_repository.dart | 2 +- .../stream_chat/lib/src/repository/mapper/channels_mapper.dart | 2 +- .../repository/mapper/{user_mapper.dart => users_mapper.dart} | 2 ++ packages/stream_chat/lib/src/repository/users_repository.dart | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) rename packages/stream_chat/lib/src/repository/mapper/{user_mapper.dart => users_mapper.dart} (98%) diff --git a/packages/stream_chat/lib/src/repository/general_repository.dart b/packages/stream_chat/lib/src/repository/general_repository.dart index 82ab18eceb..4e7e4cc0dc 100644 --- a/packages/stream_chat/lib/src/repository/general_repository.dart +++ b/packages/stream_chat/lib/src/repository/general_repository.dart @@ -5,7 +5,7 @@ import '../core/models/response/create_guest_user_response.dart'; import '../core/models/response/og_attachment_response.dart'; import '../core/models/user.dart'; import 'mapper/general_mapper.dart'; -import 'mapper/user_mapper.dart'; +import 'mapper/users_mapper.dart'; /// Repository dedicated to operations that belong to no single feature. class GeneralRepository { diff --git a/packages/stream_chat/lib/src/repository/mapper/channels_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/channels_mapper.dart index 87bdfee655..dc99bc6e11 100644 --- a/packages/stream_chat/lib/src/repository/mapper/channels_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/channels_mapper.dart @@ -15,7 +15,7 @@ import '../../core/models/response/mark_read_response.dart'; import '../../core/models/response/show_channel_response.dart'; import '../../core/models/response/update_channel_partial_response.dart'; import '../../core/models/response/update_member_partial_response.dart'; -import 'user_mapper.dart'; +import 'users_mapper.dart'; // TODO(openapi-migration): re-point these mappers in group 11. diff --git a/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/users_mapper.dart similarity index 98% rename from packages/stream_chat/lib/src/repository/mapper/user_mapper.dart rename to packages/stream_chat/lib/src/repository/mapper/users_mapper.dart index cb9b609beb..e3469e477b 100644 --- a/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/users_mapper.dart @@ -108,6 +108,8 @@ extension UserRequestMapper on User { } /// Maps [PrivacySettings] to the generated [api.PrivacySettingsResponse]. +/// +/// The generated client names the settings a request sends [api.PrivacySettingsResponse], too. extension PrivacySettingsRequestMapper on PrivacySettings { /// Converts these settings into an [api.PrivacySettingsResponse]. api.PrivacySettingsResponse toRequest() => api.PrivacySettingsResponse( diff --git a/packages/stream_chat/lib/src/repository/users_repository.dart b/packages/stream_chat/lib/src/repository/users_repository.dart index 236d5cdb14..1328bf6af8 100644 --- a/packages/stream_chat/lib/src/repository/users_repository.dart +++ b/packages/stream_chat/lib/src/repository/users_repository.dart @@ -8,7 +8,7 @@ import '../core/models/response/get_unread_count_response.dart'; import '../core/models/response/unblock_users_response.dart'; import '../core/models/response/update_users_response.dart'; import '../core/models/user.dart'; -import 'mapper/user_mapper.dart'; +import 'mapper/users_mapper.dart'; /// Repository dedicated to user operations. class UsersRepository { From 1a37638ceb525e1699fb59d25e3f2a7d0ba751b2 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 16 Oct 2026 13:13:16 +0200 Subject: [PATCH 3/9] refactor(llc, persistence, ui)!: name user custom data custom and give user fields of their own Co-Authored-By: Claude Opus 5.5 --- .claude/skills/openapi-migration/SKILL.md | 7 +- .../test/reactions/reactions_test.dart | 2 +- migrations/v11-migration.md | 76 ++- openapi-migration/09-users.md | 7 +- openapi-migration/20-user-updates.md | 21 +- openapi-migration/README.md | 15 +- openapi-migration/tool/generate_plan.py | 43 +- packages/stream_chat/CHANGELOG.md | 11 +- .../stream_chat/lib/src/client/client.dart | 3 + .../lib/src/core/models/draft_message.dart | 2 +- .../lib/src/core/models/member.dart | 10 +- .../lib/src/core/models/message.dart | 2 +- .../lib/src/core/models/own_user.dart | 115 ++-- .../lib/src/core/models/own_user.g.dart | 24 +- .../stream_chat/lib/src/core/models/user.dart | 175 +++--- .../lib/src/core/models/user.g.dart | 16 +- .../src/repository/mapper/users_mapper.dart | 37 +- .../lib/src/ws/connect_user_details.dart | 14 +- .../stream_chat/lib/src/ws/events/event.dart | 22 +- packages/stream_chat/test/fixtures/user.json | 3 +- .../client_connect_guest_user_test.dart | 27 +- .../test/src/client/client_test.dart | 43 ++ .../client_update_users_partial_test.dart | 3 +- .../src/client/client_update_users_test.dart | 64 ++- .../test/src/client/poll_fixtures.dart | 3 +- .../test/src/core/models/event_test.dart | 3 +- .../test/src/core/models/own_user_test.dart | 271 +++------- .../test/src/core/models/reaction_test.dart | 2 + .../test/src/core/models/read_test.dart | 8 +- .../test/src/core/models/user_test.dart | 281 +++++----- .../test/src/ws/connect_request_test.dart | 12 + packages/stream_chat_flutter/CHANGELOG.md | 1 + .../example/lib/debug/members.dart | 2 +- .../example/lib/debug/mutes.dart | 4 +- .../src/autocomplete/user_mention_search.dart | 9 +- .../lib/src/channel/channel_name.dart | 6 +- .../lib/src/channel/stream_channel_name.dart | 4 +- .../components/avatar/stream_user_avatar.dart | 2 +- .../message_composer_input_header.dart | 2 +- .../lib/src/localization/translations.dart | 6 +- .../stream_media_gallery_preview.dart | 2 +- .../stream_media_gallery_preview_header.dart | 2 +- .../lib/src/mention/mention.dart | 6 +- .../lib/src/mention/mention_item.dart | 2 +- .../stream_message_composer.dart | 2 +- .../components/stream_message_footer.dart | 2 +- .../components/stream_message_leading.dart | 2 +- .../message_widget/stream_message_item.dart | 4 +- .../message_widget/stream_quoted_message.dart | 8 +- .../src/poll/stream_poll_comments_sheet.dart | 2 +- .../detail/reaction_detail_sheet.dart | 2 +- .../stream_message_search_list_tile.dart | 2 +- .../stream_poll_vote_list_tile.dart | 2 +- .../stream_thread_list_tile.dart | 2 +- .../stream_user_grid_tile.dart | 2 +- .../stream_user_list_tile.dart | 2 +- .../lib/src/user/user_mention_tile.dart | 2 +- .../lib/src/utils/extensions.dart | 16 +- .../lib/src/utils/helpers.dart | 2 +- .../src/utils/message_preview_formatter.dart | 8 +- .../test/src/avatars/user_avatar_test.dart | 24 + .../example/lib/main.dart | 2 +- .../stream_chat_localizations/CHANGELOG.md | 6 + .../example/lib/add_new_lang.dart | 6 +- .../lib/src/stream_chat_localizations_ca.dart | 6 +- .../lib/src/stream_chat_localizations_de.dart | 6 +- .../lib/src/stream_chat_localizations_en.dart | 6 +- .../lib/src/stream_chat_localizations_es.dart | 6 +- .../lib/src/stream_chat_localizations_fr.dart | 6 +- .../lib/src/stream_chat_localizations_hi.dart | 6 +- .../lib/src/stream_chat_localizations_it.dart | 6 +- .../lib/src/stream_chat_localizations_ja.dart | 6 +- .../lib/src/stream_chat_localizations_ko.dart | 6 +- .../lib/src/stream_chat_localizations_no.dart | 6 +- .../lib/src/stream_chat_localizations_pt.dart | 6 +- packages/stream_chat_persistence/CHANGELOG.md | 1 + .../lib/src/db/drift_chat_database.dart | 2 +- .../lib/src/db/drift_chat_database.g.dart | 504 ++++++++++++++++-- .../lib/src/entity/users.dart | 24 +- .../lib/src/mapper/user_mapper.dart | 16 +- .../test/src/mapper/user_mapper_test.dart | 32 +- sample_app/benchmark/benchmark_page.dart | 41 +- .../lib/pages/advanced_options_page.dart | 4 +- sample_app/lib/pages/channel_list_page.dart | 2 +- sample_app/lib/pages/chat_info_screen.dart | 2 +- sample_app/lib/pages/choose_user_page.dart | 2 +- .../lib/pages/group_chat_details_screen.dart | 2 +- sample_app/lib/pages/new_chat_screen.dart | 2 +- .../lib/pages/new_group_chat_screen.dart | 2 +- sample_app/lib/utils/app_config.dart | 51 +- sample_app/lib/widgets/add_members_sheet.dart | 2 +- sample_app/lib/widgets/all_members_sheet.dart | 4 +- .../lib/widgets/message_info_sheet.dart | 2 +- 93 files changed, 1385 insertions(+), 844 deletions(-) diff --git a/.claude/skills/openapi-migration/SKILL.md b/.claude/skills/openapi-migration/SKILL.md index e6344ac8ab..d924fcadd5 100644 --- a/.claude/skills/openapi-migration/SKILL.md +++ b/.claude/skills/openapi-migration/SKILL.md @@ -346,10 +346,9 @@ Three data-shape traps while mapping: - **The model's `custom` is the wire's `custom`, verbatim** (README rule 9). A migrated model names it `custom` — `extraData` on one whose group has not run — and the mapper copies the generated map into it and sends it back as `custom`; never filter either. A typed wire field our model lacks is added as a field, with - approval, or left out — never parked in `extraData` behind a getter, as `User.name` still is until group 09 - promotes it. A v1 decoder splits flat JSON by a key list — `topLevelFields` while the model still has JSON, a - private list beside the converter once it does not — and a v1 writer leaves a custom entry named like a key out - so it cannot overwrite the key. + approval, or left out — never parked in `extraData` behind a getter. A v1 decoder splits flat JSON by a key + list — `topLevelFields` while the model still has JSON, a private list beside the converter once it does not — + and a v1 writer leaves a custom entry named like a key out so it cannot overwrite the key. - **Units on anything that takes a `Duration`.** The wire carries a bare integer and the field name rarely says in what. Read the Go payload type: `commonpayloads.DurationInMinutes` and `DurationMilliseconds` sit on sibling endpoints in the same feature — `ban.timeout` and `mute.timeout` are minutes, `muteChannel.expiration` is diff --git a/docs/docs_screenshots/test/reactions/reactions_test.dart b/docs/docs_screenshots/test/reactions/reactions_test.dart index 37f71fef9a..4ebc7b5773 100644 --- a/docs/docs_screenshots/test/reactions/reactions_test.dart +++ b/docs/docs_screenshots/test/reactions/reactions_test.dart @@ -242,7 +242,7 @@ void main() { if (user == null) return const SizedBox.shrink(); return StreamListTile( leading: StreamUserAvatar(size: StreamAvatarSize.md, user: user), - title: Text(user.name), + title: Text(user.nameOrId), trailing: StreamEmoji( size: StreamEmojiSize.md, emoji: StreamChatConfiguration.of(context).reactionIconResolver.resolve(reaction.type), diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index 2477069996..2ec417b329 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -41,6 +41,7 @@ onto Stream's OpenAPI-generated API client. - [User Blocking](#user-blocking) - [User Updates](#user-updates) - [Channel and Member Custom Data](#channel-and-member-custom-data) + - [User Custom Data](#user-custom-data) - [Migration Checklist](#migration-checklist) - [For AI Agents](#for-ai-agents) - [Contributing to this guide](#contributing-to-this-guide) @@ -94,6 +95,7 @@ from the spec, so don't subclass them or depend on their private constructors. | [**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 | +| [**User Custom Data**](#user-custom-data) | `extraData` is `custom` on `User` and `OwnUser`, and holds the custom data verbatim; `name` is nullable and `nameOrId` falls back to the id; the user fields it carried are fields | | _(filled in per feature as PRs land)_ | | --- @@ -142,7 +144,7 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `SortOption(comparator:)` | — | `removed` | Declare a field whose value projects onto something orderable, or sort the list yourself | | `DraftSortKey`'s `extraData` fallback | — | `removed` | The server rejects a custom sort field on drafts | | `PollVoteSortKey.answerText` | — | `removed` | The API rejects a sort on `answer_text` | -| a raw-string sort on `language` | `UserSortField.language` | `added` | Declared now, so prefer it over `UserSortField.custom('language')`, which reads `extraData` and never sees a typed property | +| a raw-string sort on `language` | `UserSortField.language` | `added` | Declared now, so prefer it over `UserSortField.custom('language')`, which reads the custom data and never sees a typed property | | a raw-string sort on `updated_at` for members | `MemberSortField.updatedAt` | `added` | Same: prefer the declared field over `.custom` | | a raw-string sort on `text` / `type` / `parent_id` / `reply_count` / `pinned` | `MessageSearchSortField.text` / `.type` / `.parentId` / `.replyCount` / `.pinned` | `added` | The fields the JS client already exposed | | _(new)_ | `ChannelSortField.cid` | `added` | Both iOS and Android sort channels by `cid` | @@ -324,6 +326,15 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `member.extraData['notifications_muted']` / `['status']` / `['ban_from_future_channels']` / `['future_channel_ban_expires']` / `['deleted_at']` | `Member.notificationsMuted` / `status` / `banFromFutureChannels` / `futureChannelBanExpires` / `deletedAt` | `retyped` | Fields now, not custom data; `copyWith(custom:)` no longer sets them | | `ChannelModel.custom` / `Member.custom` entry named like one of the model's own fields | the same entry, as custom data | `retyped` | Kept as the API returns it; `toJson` leaves it out so it cannot overwrite the field | | `member.extraData['role']` | `Member.channelRole` | `removed` | The server deprecated `role` for `channel_role` | +| `User.extraData` / `OwnUser.extraData` | `User.custom` / `OwnUser.custom` | `renamed` | The wire's name for it; also the constructor and `copyWith` arguments | +| `User.name` (`String`, the id when the user has none) | `User.nameOrId` | `retyped` | `User.name` is a `String?` that is null when the user has none. Use `nameOrId` wherever a user is named by text, and `name` where a missing name matters, such as initials | +| `User.name` / `User.image` blank (`''`) | `null` | `retyped` | A blank name or image is stored as null | +| `user.extraData['name']` / `['image']` / `['deactivated_at']` / `['deleted_at']` / `['shadow_banned']` | `User.name` / `image` / `deactivatedAt` / `deletedAt` / `shadowBanned` | `retyped` | Fields now, not custom data; `copyWith(custom:)` no longer sets them | +| `user.extraData['revoke_tokens_issued_before']` | — | `removed` | Not modelled on a user, and no longer kept in its custom data | +| `OwnUser.blockedUserIds` | `User.blockedUserIds` | `moved` | Still on `OwnUser`. Empty for anyone but the current user | +| `ownUser.extraData['unread_count']` / `['total_unread_count_by_team']` / `['latest_hidden_channels']` | `OwnUser.unreadCount` / `totalUnreadCountByTeam` / `latestHiddenChannels` | `added` | Fields now, not custom data | +| `OwnUser.fromUser(user)` reading devices, mutes, unread counts, push preferences and privacy settings out of `user.extraData` | — | `removed` | A `User` no longer carries them; they are left at their defaults | +| `User.custom` entry named like one of the user's own fields | the same entry, as custom data | `retyped` | Kept as the API returns it; `toJson` leaves it out so it cannot overwrite the field | | _(more added per feature as PRs land)_ | | | | --- @@ -516,7 +527,7 @@ counterpart: `code` is now a `StreamErrorCode` (an extension type over `int`, wi ## Offline Cache If you use `stream_chat_persistence`, the local database is **rebuilt from empty** the first time your app runs -on v11. The Drift schema version moves from v10's `1000 + n` (`1035` in v10.5.0) to `1106`, and the upgrade strategy +on v11. The Drift schema version moves from v10's `1000 + n` (`1035` in v10.5.0) to `1107`, and the upgrade strategy drops and recreates every table rather than migrating rows. Everything held on disk is discarded: channels, messages, members, reads, drafts, locations, polls, poll votes @@ -853,6 +864,59 @@ stays in `custom` and leaves the field alone; `toJson` leaves it out, so it cann > **Why:** the API calls it `custom`, and every migrated model takes that name. The flags arrive as fields of their > own, so keeping them in the custom data let a custom entry with the same name overwrite them. +### User Custom Data + +**`extraData` is renamed `custom` on `User` and `OwnUser`**, along with the constructor and `copyWith` arguments. + +```dart +// v10 +final color = user.extraData['favorite_color']; + +// v11 +final color = user.custom['favorite_color']; +``` + +**`User.name` is nullable and no longer falls back to the id.** It is null when the user has no name; a blank name +or image is stored as null. `User.nameOrId` answers the name, or the id when there is none, which is what v10's +`name` answered. Use it wherever you name a user by text — a title, a label, a mention — and keep `name` where a +missing name matters, such as avatar initials or a sort: + +```dart +// v10 +Text(user.name); + +// v11 +Text(user.nameOrId); +``` + +A string interpolation still compiles with the nullable `name`, so check them by hand: `'${user.name} is typing'` +prints `null is typing` for a user with no name. That includes a custom `Translations` or `StreamChatLocalizations` +subclass overriding `typingIndicatorText` or `pinnedByUserText`. + +**The user fields are fields, not custom data.** `User.image` reads the same and `name` changes as described above; +`deactivatedAt`, `deletedAt` and `shadowBanned` are new fields, `blockedUserIds` moves up from `OwnUser` to `User`, +and `OwnUser.unreadCount`, `totalUnreadCountByTeam` and `latestHiddenChannels` are new fields. `custom` no longer holds any of their keys, nor +the ones only an own user carries, and an entry with one of those names in `custom` or in `copyWith(custom:)` no +longer sets the field: + +```dart +// v10 +final renamed = user.copyWith(extraData: {...user.extraData, 'name': 'Jane'}); + +// v11 +final renamed = user.copyWith(name: 'Jane'); +``` + +**`OwnUser.fromUser` copies the user's fields and leaves the rest at their defaults.** v10 read an own user's +devices, mutes, unread counts, push preferences and privacy settings back out of the user's `extraData`; a `User` +no longer carries them. + +**`custom` is the custom data exactly as the API returns it.** An entry named like one of the user's own fields +stays in `custom` and leaves the field alone; `toJson` leaves it out, so it cannot overwrite the field. + +> **Why:** the API calls it `custom`, and every migrated model takes that name. A name that answered the id could +> not tell an unnamed user from a named one, so initials and sorts read the id as a name. + ### Read Receipts **Marking a channel or thread read or unread, and sending delivery receipts, return a `Result` instead of @@ -1008,10 +1072,10 @@ were stored as custom data on every call; that no longer happens. `role`, `teams 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. +does not model in `extraData` as raw JSON; now their devices, mutes, channel mutes, privacy settings, unread counts, hidden +channels and token revocation time are left out. Read them from `currentUser`. `deactivatedAt`, `deletedAt`, +`shadowBanned` and `blockedUserIds` are fields on `User` (see [User Custom Data](#user-custom-data)); 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` diff --git a/openapi-migration/09-users.md b/openapi-migration/09-users.md index 43a8b74670..d7e039d3d1 100644 --- a/openapi-migration/09-users.md +++ b/openapi-migration/09-users.md @@ -29,17 +29,14 @@ - `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` 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. +- `UserFilterField.bypassModeration` reads `custom`, 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. ## Definition of done @@ -64,5 +61,5 @@ - [ ] `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. -- [ ] `users_mapper.dart` has no `_shadowedCustomKeys`: `User.custom` is the wire's `custom`, verbatim, and +- [x] `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/20-user-updates.md b/openapi-migration/20-user-updates.md index f37edb7111..dd799ca77e 100644 --- a/openapi-migration/20-user-updates.md +++ b/openapi-migration/20-user-updates.md @@ -48,10 +48,23 @@ 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. +- **`User` and `OwnUser` promote the fields v10 parked in `extraData`, and rename it `custom`** + (README rule 9), since these calls are the first to map a user. `User.name`, `image`, + `deactivatedAt`, `deletedAt` and `shadowBanned` are fields with persistence columns; + `blockedUserIds` moves up from `OwnUser`, and `OwnUser` gains `unreadCount`, + `totalUnreadCountByTeam` and `latestHiddenChannels`. `revoke_tokens_issued_before` is left out. + Each generated user mapper fills the ones its response carries (`UserResponse` has no + `shadowBanned`). +- **`User.name` is nullable and has no fallback to `id`;** `User.nameOrId` has it. A blank name or + image is stored as null. The UI names a user by `nameOrId` everywhere except avatar initials, which + show the placeholder icon for an unnamed user, and filters and sorts, which read the raw `name`. +- **`custom` is the wire's, verbatim,** and the v1 writers leave out a custom entry named like a key, + including the keys only an own user carries, so it cannot overwrite the field. `OwnUser.fromUser` + copies the user's fields and no longer reads an own user's fields out of the custom data. +- **`OwnUser.merge` takes the other user's fields,** as before; the `user.updated` handler also keeps + the three new unread fields, which the event leaves out. A `user.updated` event's user is decoded + again as an `OwnUser`, so the privacy settings it carries for the current user replace theirs, as in + v10 — a temporary adapter (README) for the WebSocket v2 move to replace. ## Risks diff --git a/openapi-migration/README.md b/openapi-migration/README.md index 4ceec0b6fd..490e795df5 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -162,8 +162,8 @@ includes a named response that carries only `duration` today, such as `HideChann directions.** A response mapper copies the generated `custom` map into it and a request mapper sends it back as `custom`; neither filters it. A model whose group has not run still says `extraData`. A field the generated response types is never parked in `extraData` behind a getter: it becomes a field on the model, as - the owning group's break, or stays out. `User.name` is parked today, and group 09 promotes it; group 15 - promoted `ChannelModel`'s and `Member`'s. `topLevelFields` and `Serializer` serve only the v1 decoders, which + the owning group's break, or stays out. Group 15 promoted `ChannelModel`'s and `Member`'s, and group 20 + `User`'s and `OwnUser`'s. `topLevelFields` and `Serializer` serve only the v1 decoders, which still split flat JSON by a key list, and go with the WebSocket v2 move; a model that loses its JSON drops `topLevelFields` and its converter owns the list. A v1 writer leaves a custom entry named like a key out so it cannot overwrite the key. @@ -176,9 +176,16 @@ 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) | +| `Event.fromJson`'s `user.updated` re-decode | `Event.user`, decoded as a `User` and again as an `OwnUser` | WebSocket v2 (no group) | | `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) | +The `user.updated` re-decode keeps the privacy settings the current user's copy of the event carries, which `User` +does not model, so the handler can apply them as v10 did. When the WebSocket moves to v2, map the generated +`UserUpdatedEvent.user` (`UserResponsePrivacyFields`) onto the current user once, and make the handler apply only +what that type carries: it never fills `blocked_user_ids` and has no shadow ban or ban expiry, which the handler +wipes or resets today, as v10 did. + How v1 JSON decodes `User` once it becomes a plain model is decided in [01-foundation](01-foundation.md): until group 09 restructures it, v1 payloads keep decoding through `User.fromJson`. @@ -209,8 +216,8 @@ surfaces before it reaches `Message` and `ChannelState`: - **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`. + current `User`. As the first group to map a user, it promoted `User`'s and `OwnUser`'s parked fields and renamed + their custom data `custom` (rule 9). - **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 `users_mapper.dart` onto today's `User`; 09 migrates the diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index d0923012c0..5f0f7df491 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -589,17 +589,7 @@ def match(verb, path): '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` 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.', + '`UserFilterField.bypassModeration` reads `custom`, 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.', @@ -607,18 +597,10 @@ def match(verb, path): 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.', ], done=DONE + ( '- [ ] Temporary adapters owned by this group (`DeviceV1JsonConverter`) are deleted and removed from\n' @@ -626,7 +608,7 @@ def match(verb, path): '- [ ] `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' - '- [ ] `users_mapper.dart` has no `_shadowedCustomKeys`: `User.custom` is the wire\'s `custom`, verbatim, and\n' + '- [x] `users_mapper.dart` has no `_shadowedCustomKeys`: `User.custom` is the wire\'s `custom`, verbatim, and\n' ' no typed field is written into it.\n' ), ), @@ -1086,10 +1068,23 @@ def match(verb, path): 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. + - **`User` and `OwnUser` promote the fields v10 parked in `extraData`, and rename it `custom`** + (README rule 9), since these calls are the first to map a user. `User.name`, `image`, + `deactivatedAt`, `deletedAt` and `shadowBanned` are fields with persistence columns; + `blockedUserIds` moves up from `OwnUser`, and `OwnUser` gains `unreadCount`, + `totalUnreadCountByTeam` and `latestHiddenChannels`. `revoke_tokens_issued_before` is left out. + Each generated user mapper fills the ones its response carries (`UserResponse` has no + `shadowBanned`). + - **`User.name` is nullable and has no fallback to `id`;** `User.nameOrId` has it. A blank name or + image is stored as null. The UI names a user by `nameOrId` everywhere except avatar initials, which + show the placeholder icon for an unnamed user, and filters and sorts, which read the raw `name`. + - **`custom` is the wire's, verbatim,** and the v1 writers leave out a custom entry named like a key, + including the keys only an own user carries, so it cannot overwrite the field. `OwnUser.fromUser` + copies the user's fields and no longer reads an own user's fields out of the custom data. + - **`OwnUser.merge` takes the other user's fields,** as before; the `user.updated` handler also keeps + the three new unread fields, which the event leaves out. A `user.updated` event's user is decoded + again as an `OwnUser`, so the privacy settings it carries for the current user replace theirs, as in + v10 — a temporary adapter (README) for the WebSocket v2 move to replace. """), risks=[], done=DONE.replace('- [ ]', '- [x]'), diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 806c4097c0..9322e1eae9 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -129,17 +129,22 @@ - `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`. +- The users in an `UpdateUsersResponse` no longer carry their devices, mutes, channel mutes, privacy settings, unread counts, hidden channels or token revocation time in their custom data. - `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. - `Member.custom` no longer holds `role`; read `channelRole`. +- `User.extraData` is renamed `custom`, and so is the `extraData` argument of the `User` and `OwnUser` constructors and `copyWith`. +- `User.name` is nullable and no longer answers the user's id when they have no name; read `User.nameOrId` for that. A blank name or image is stored as null. +- `User.name`, `image`, `deactivatedAt`, `deletedAt` and `shadowBanned` are fields instead of entries in the custom data, so `custom` no longer holds them or the keys only an own user carries, and an entry with one of those names in `custom` or in `copyWith(custom:)` no longer sets the field. +- `User.custom` holds the custom data exactly as the API returns it, and `toJson` leaves out an entry named like one of the user's own fields. +- `OwnUser.fromUser` no longer reads devices, mutes, unread counts, push preferences or privacy settings out of the user's custom data; they are left at their defaults. 🐞 Fixed -- `connectGuestUser` no longer copies the user's online status, ban, unread counts, devices and push preferences into the created guest's `extraData`. +- `connectGuestUser` no longer copies the user's online status, ban, unread counts, devices and push preferences into the created guest's custom data. - `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. @@ -167,7 +172,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 `User.deactivatedAt`, `deletedAt`, `shadowBanned`, `blockedUserIds` and `nameOrId`, and `OwnUser.unreadCount`, `totalUnreadCountByTeam` and `latestHiddenChannels`; the constructors and `copyWith` 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 102292cb3a..5d4c36baef 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -2655,6 +2655,9 @@ class ClientState { unreadChannels: currentUser?.unreadChannels, unreadThreads: currentUser?.unreadThreads, pushPreferences: currentUser?.pushPreferences, + unreadCount: currentUser?.unreadCount, + totalUnreadCountByTeam: currentUser?.totalUnreadCountByTeam, + latestHiddenChannels: currentUser?.latestHiddenChannels, ); } diff --git a/packages/stream_chat/lib/src/core/models/draft_message.dart b/packages/stream_chat/lib/src/core/models/draft_message.dart index 01f370abcc..f818cd3d03 100644 --- a/packages/stream_chat/lib/src/core/models/draft_message.dart +++ b/packages/stream_chat/lib/src/core/models/draft_message.dart @@ -253,7 +253,7 @@ extension on DraftMessage { final updatedMentionedUsers = [...mentionedUsers]; for (final user in mentionedUsers.toSet()) { if (messageTextToSend.contains('@${user.id}')) continue; - if (messageTextToSend.contains('@${user.name}')) continue; + if (messageTextToSend.contains('@${user.nameOrId}')) continue; updatedMentionedUsers.remove(user); } diff --git a/packages/stream_chat/lib/src/core/models/member.dart b/packages/stream_chat/lib/src/core/models/member.dart index 6095afd3d9..c7d0649e25 100644 --- a/packages/stream_chat/lib/src/core/models/member.dart +++ b/packages/stream_chat/lib/src/core/models/member.dart @@ -334,7 +334,7 @@ class MemberFilterField extends FilterField { /// **Supported operators:** `$eq`, `$in`, `$q`, `$autocomplete` static final userEmail = MemberFilterField( 'user.email', - (it) => it.user?.extraData['email'].safeCast(), + (it) => it.user?.custom['email'].safeCast(), ); /// Filters members by whether their user is deactivated. @@ -342,7 +342,7 @@ class MemberFilterField extends FilterField { /// **Supported operators:** `$eq` static final userDeactivated = MemberFilterField( 'user.nd_deactivated', - (it) => it.user?.extraData['nd_deactivated'].safeCast(), + (it) => it.user?.custom['nd_deactivated'].safeCast(), ); /// Filters members by whether they muted notifications for the channel. @@ -439,11 +439,7 @@ class MemberSortField extends SortField { /// Slower than the other member sorts. static final name = MemberSortField( 'name', - // Reads the raw name rather than `User.name`, which falls back to the id. - (it) { - final name = it.user?.extraData['name']; - return name.safeCast()?.let(normalizeStringForSort); - }, + (it) => it.user?.name?.let(normalizeStringForSort), ); /// Sorts members by the channel role. diff --git a/packages/stream_chat/lib/src/core/models/message.dart b/packages/stream_chat/lib/src/core/models/message.dart index 974e8bf704..13c1436e73 100644 --- a/packages/stream_chat/lib/src/core/models/message.dart +++ b/packages/stream_chat/lib/src/core/models/message.dart @@ -1121,7 +1121,7 @@ extension on Message { final updatedMentionedUsers = [...mentionedUsers]; for (final user in mentionedUsers.toSet()) { if (mentionsToken(user.id)) continue; - if (mentionsToken(user.name)) continue; + if (mentionsToken(user.nameOrId)) continue; updatedMentionedUsers.remove(user); } 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 0cf7e541da..55f0cb93cc 100644 --- a/packages/stream_chat/lib/src/core/models/own_user.dart +++ b/packages/stream_chat/lib/src/core/models/own_user.dart @@ -12,9 +12,6 @@ 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 [], @@ -22,9 +19,12 @@ class OwnUser extends User { this.unreadChannels = 0, this.channelMutes = const [], this.unreadThreads = 0, - this.blockedUserIds = const [], + super.blockedUserIds, this.pushPreferences, this.privacySettings, + this.unreadCount = 0, + this.totalUnreadCountByTeam, + this.latestHiddenChannels, required super.id, super.role, super.name, @@ -33,7 +33,7 @@ class OwnUser extends User { super.updatedAt, super.lastActive, super.online, - super.extraData, + super.custom, super.banned, super.banExpires, super.teams, @@ -51,8 +51,35 @@ class OwnUser extends User { Serializer.moveToExtraDataFromRoot(json, topLevelFields), ); - /// Create a new instance from [User] object. - factory OwnUser.fromUser(User user) => OwnUser.fromJson(user.toJson()); + /// Creates an [OwnUser] from [user]. + /// + /// If [user] is already an [OwnUser], it is returned unchanged; otherwise the + /// fields only an own user has are left at their defaults. + factory OwnUser.fromUser(User user) => switch (user) { + final OwnUser ownUser => ownUser, + _ => OwnUser( + id: user.id, + role: user.role, + name: user.name, + image: user.image, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + lastActive: user.lastActive, + online: user.online, + custom: user.custom, + banned: user.banned, + banExpires: user.banExpires, + teams: user.teams, + language: user.language, + invisible: user.invisible, + teamsRole: user.teamsRole, + avgResponseTime: user.avgResponseTime, + deactivatedAt: user.deactivatedAt, + deletedAt: user.deletedAt, + shadowBanned: user.shadowBanned, + blockedUserIds: user.blockedUserIds, + ), + }; /// Creates a copy of [OwnUser] with specified attributes overridden. @override @@ -65,7 +92,7 @@ class OwnUser extends User { DateTime? updatedAt, DateTime? lastActive, bool? online, - Map? extraData, + Map? custom, bool? banned, DateTime? banExpires, List? teams, @@ -82,22 +109,24 @@ class OwnUser extends User { int? avgResponseTime, PushPreference? pushPreferences, PrivacySettings? privacySettings, + DateTime? deactivatedAt, + DateTime? deletedAt, + bool? shadowBanned, + int? unreadCount, + Map? totalUnreadCountByTeam, + List? latestHiddenChannels, }) => OwnUser( id: id ?? this.id, role: role ?? this.role, - name: - name ?? - extraData?['name'] as String? ?? - // Using extraData value in order to not use id as name. - this.extraData['name'] as String?, - image: image ?? extraData?['image'] as String? ?? this.image, + name: name ?? this.name, + image: image ?? this.image, banned: banned ?? this.banned, banExpires: banExpires ?? this.banExpires, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, lastActive: lastActive ?? this.lastActive, online: online ?? this.online, - extraData: extraData ?? this.extraData, + custom: custom ?? this.custom, teams: teams ?? this.teams, channelMutes: channelMutes ?? this.channelMutes, devices: devices ?? this.devices, @@ -112,6 +141,12 @@ class OwnUser extends User { avgResponseTime: avgResponseTime ?? this.avgResponseTime, pushPreferences: pushPreferences ?? this.pushPreferences, privacySettings: privacySettings ?? this.privacySettings, + deactivatedAt: deactivatedAt ?? this.deactivatedAt, + deletedAt: deletedAt ?? this.deletedAt, + shadowBanned: shadowBanned ?? this.shadowBanned, + unreadCount: unreadCount ?? this.unreadCount, + totalUnreadCountByTeam: totalUnreadCountByTeam ?? this.totalUnreadCountByTeam, + latestHiddenChannels: latestHiddenChannels ?? this.latestHiddenChannels, ); /// Returns a new [OwnUser] that is a combination of this ownUser @@ -121,14 +156,13 @@ class OwnUser extends User { return copyWith( id: other.id, role: other.role, - // Using extraData value in order to not use id as name. - name: other.extraData['name'] as String?, + name: other.name, image: other.image, banned: other.banned, channelMutes: other.channelMutes, createdAt: other.createdAt, devices: other.devices, - extraData: other.extraData, + custom: other.custom, lastActive: other.lastActive, mutes: other.mutes, online: other.online, @@ -144,6 +178,12 @@ class OwnUser extends User { avgResponseTime: other.avgResponseTime, pushPreferences: other.pushPreferences, privacySettings: other.privacySettings, + deactivatedAt: other.deactivatedAt, + deletedAt: other.deletedAt, + shadowBanned: other.shadowBanned, + unreadCount: other.unreadCount, + totalUnreadCountByTeam: other.totalUnreadCountByTeam, + latestHiddenChannels: other.latestHiddenChannels, ); } @@ -166,36 +206,37 @@ class OwnUser extends User { /// Total unread threads by the user. final int unreadThreads; - /// List of user ids that are blocked by the user. - final List blockedUserIds; - /// Push preferences for the user if set. final PushPreference? pushPreferences; /// Privacy settings for the user if set. final PrivacySettings? privacySettings; + /// The user's unread message count as of the last time the current user was + /// loaded. + /// + /// Unlike [totalUnreadCount], it is not updated by events that carry only a + /// new count; consider [totalUnreadCount] for a live value. + final int unreadCount; + + /// Total unread messages by the user in each of their teams, keyed by team. + final Map? totalUnreadCountByTeam; + + /// The cids of the channels the user hid most recently. + final List? latestHiddenChannels; + /// Convert instance to json. + /// + /// Writes the entries of [custom] at the root, leaving out any named like one + /// of [topLevelFields]. @override Map toJson() { - return Serializer.moveFromExtraDataToRoot(_$OwnUserToJson(this)); + return Serializer.moveFromExtraDataToRoot(_$OwnUserToJson(this), topLevelFields: topLevelFields); } - /// Known top level fields. - /// - /// Useful for [Serializer] methods. - static const topLevelFields = [ - 'devices', - 'mutes', - 'total_unread_count', - 'unread_channels', - 'channel_mutes', - 'unread_threads', - 'blocked_user_ids', - 'push_preferences', - 'privacy_settings', - ...User.topLevelFields, - ]; + /// The JSON keys [OwnUser.fromJson] reads into named fields, or leaves out; + /// every other key is kept in [custom]. + static const topLevelFields = User.topLevelFields; } /// Extension methods for [OwnUser] related to privacy settings. diff --git a/packages/stream_chat/lib/src/core/models/own_user.g.dart b/packages/stream_chat/lib/src/core/models/own_user.g.dart index 7346fa9a00..d1eedaf6ca 100644 --- a/packages/stream_chat/lib/src/core/models/own_user.g.dart +++ b/packages/stream_chat/lib/src/core/models/own_user.g.dart @@ -26,13 +26,20 @@ OwnUser _$OwnUserFromJson(Map json) => OwnUser( privacySettings: json['privacy_settings'] == null ? null : PrivacySettings.fromJson(json['privacy_settings'] as Map), + unreadCount: (json['unread_count'] as num?)?.toInt() ?? 0, + totalUnreadCountByTeam: (json['total_unread_count_by_team'] as Map?)?.map( + (k, e) => MapEntry(k, (e as num).toInt()), + ), + latestHiddenChannels: (json['latest_hidden_channels'] as List?)?.map((e) => e as String).toList(), id: json['id'] as String, role: json['role'] as String?, + name: json['name'] as String?, + image: json['image'] as String?, createdAt: json['created_at'] == null ? null : DateTime.parse(json['created_at'] as String), updatedAt: json['updated_at'] == null ? null : DateTime.parse(json['updated_at'] as String), lastActive: json['last_active'] == null ? null : DateTime.parse(json['last_active'] as String), online: json['online'] as bool? ?? false, - extraData: json['extra_data'] as Map? ?? const {}, + custom: json['extra_data'] as Map? ?? const {}, banned: json['banned'] as bool? ?? false, banExpires: json['ban_expires'] == null ? null : DateTime.parse(json['ban_expires'] as String), teams: (json['teams'] as List?)?.map((e) => e as String).toList() ?? const [], @@ -40,10 +47,15 @@ OwnUser _$OwnUserFromJson(Map json) => OwnUser( invisible: json['invisible'] as bool?, teamsRole: (json['teams_role'] as Map?)?.map((k, e) => MapEntry(k, e as String)), avgResponseTime: (json['avg_response_time'] as num?)?.toInt(), + deactivatedAt: json['deactivated_at'] == null ? null : DateTime.parse(json['deactivated_at'] as String), + deletedAt: json['deleted_at'] == null ? null : DateTime.parse(json['deleted_at'] as String), + shadowBanned: json['shadow_banned'] as bool?, ); Map _$OwnUserToJson(OwnUser instance) => { 'id': instance.id, + 'name': ?instance.name, + 'image': ?instance.image, 'role': ?instance.role, 'teams': instance.teams, 'created_at': ?instance.createdAt?.toIso8601String(), @@ -56,14 +68,20 @@ Map _$OwnUserToJson(OwnUser instance) => { 'invisible': ?instance.invisible, 'teams_role': ?instance.teamsRole, 'avg_response_time': ?instance.avgResponseTime, - 'extra_data': instance.extraData, + 'extra_data': instance.custom, + 'deactivated_at': ?instance.deactivatedAt?.toIso8601String(), + 'deleted_at': ?instance.deletedAt?.toIso8601String(), + 'shadow_banned': ?instance.shadowBanned, + 'blocked_user_ids': instance.blockedUserIds, 'devices': instance.devices.map(const DeviceV1JsonConverter().toJson).toList(), 'mutes': instance.mutes.map((e) => e.toJson()).toList(), 'channel_mutes': instance.channelMutes.map((e) => e.toJson()).toList(), 'total_unread_count': instance.totalUnreadCount, 'unread_channels': instance.unreadChannels, 'unread_threads': instance.unreadThreads, - 'blocked_user_ids': instance.blockedUserIds, 'push_preferences': ?instance.pushPreferences?.toJson(), 'privacy_settings': ?instance.privacySettings?.toJson(), + 'unread_count': instance.unreadCount, + 'total_unread_count_by_team': ?instance.totalUnreadCountByTeam, + 'latest_hidden_channels': ?instance.latestHiddenChannels, }; diff --git a/packages/stream_chat/lib/src/core/models/user.dart b/packages/stream_chat/lib/src/core/models/user.dart index d44ec87f76..f375c402fe 100644 --- a/packages/stream_chat/lib/src/core/models/user.dart +++ b/packages/stream_chat/lib/src/core/models/user.dart @@ -13,30 +13,7 @@ part 'user.g.dart'; class User extends Equatable { /// Creates a new user. /// - /// {@template name} - /// If an [name] is provided it will be set on [extraData] with a `key` - /// of 'name'. - /// - /// For example: - /// ```dart - /// final user = User(id: 'id', name: 'Sahil Kumar'); - /// print(user.name == user.extraData['name']); // true - /// ``` - /// {@endtemplate} - /// - /// {@template image} - /// If an [image] is provided it will be set on [extraData] with a `key` - /// of 'image'. - /// - /// For example: - /// ```dart - /// final user = User(id: 'id', image: 'https://getstream.io/image.png'); - /// 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. + /// A blank [name] or [image] is stored as null. User({ required this.id, this.role, @@ -53,30 +30,25 @@ class User extends Equatable { this.invisible, this.teamsRole, this.avgResponseTime, - Map extraData = const {}, - 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, - }; + Map custom = const {}, + this.deactivatedAt, + this.deletedAt, + this.shadowBanned, + this.blockedUserIds = const [], + }) : name = _nonBlank(name), + image = _nonBlank(image), + custom = {...custom}; /// Create a new instance from json. factory User.fromJson(Map json) => _$UserFromJson(Serializer.moveToExtraDataFromRoot(json, topLevelFields)); - /// Known top level fields. - /// - /// Useful for [Serializer] methods. + /// The JSON keys [User.fromJson] reads into named fields, or leaves out; + /// every other key is kept in [custom]. static const topLevelFields = [ 'id', + 'name', + 'image', 'role', 'created_at', 'updated_at', @@ -89,32 +61,46 @@ class User extends Equatable { 'invisible', 'teams_role', 'avg_response_time', + 'deactivated_at', + 'deleted_at', + 'shadow_banned', + 'blocked_user_ids', + // Not modelled on a user; listed so it never lands in [custom]. + 'revoke_tokens_issued_before', + // Only an own user carries these ([OwnUser] reads them); a plain user + // leaves them out. + 'devices', + 'mutes', + 'channel_mutes', + 'total_unread_count', + 'unread_channels', + 'unread_threads', + 'unread_count', + 'total_unread_count_by_team', + 'latest_hidden_channels', + 'push_preferences', + 'privacy_settings', ]; /// User id. final String id; - /// Shortcut for user name. + /// The user's name. /// - /// {@macro name} - @JsonKey(includeToJson: false, includeFromJson: false) - String get name { - final name = extraData['name'].safeCast(); - if (name != null && name.isNotEmpty) return name; + /// Null when the user has none. + final String? name; - return id; - } - - /// Shortcut for user image. + /// The user's [name], or their [id] when they have none. /// - /// {@macro image} + /// Suited to naming a user by text, such as in a mention; [name] tells an + /// unnamed user apart. @JsonKey(includeToJson: false, includeFromJson: false) - String? get image { - final image = extraData['image'].safeCast(); - if (image != null && image.isNotEmpty) return image; + String get nameOrId => name ?? id; - return null; - } + /// The URL of the user's image. + /// + /// Null when the user has none. + final String? image; /// User role. final String? role; @@ -154,38 +140,42 @@ class User extends Equatable { /// The average response time of the user in seconds. final int? avgResponseTime; - /// Map of custom user extraData. - final Map extraData; + /// This user's custom data: every key not in [topLevelFields]. + @JsonKey(name: 'extra_data') + final Map custom; /// 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'); + final DateTime? deactivatedAt; /// 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'); + final DateTime? deletedAt; /// 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(); + final bool? shadowBanned; + + /// The ids of the users this user blocked. + /// + /// Empty for anyone but the current user. + final List blockedUserIds; - DateTime? _date(String key) => switch (extraData[key]) { - final String value => DateTime.tryParse(value), - _ => null, - }; + static String? _nonBlank(String? value) => (value == null || value.trim().isEmpty) ? null : value; /// List of users to list of userIds. static List? toIds(List? users) => users?.map((u) => u.id).toList(); /// Serialize to json. + /// + /// Writes the entries of [custom] at the root, leaving out any named like one + /// of [topLevelFields]. Map toJson() => Serializer.moveFromExtraDataToRoot( _$UserToJson(this), + topLevelFields: topLevelFields, ); /// Creates a copy of [User] with specified attributes overridden. @@ -198,7 +188,7 @@ class User extends Equatable { DateTime? updatedAt, DateTime? lastActive, bool? online, - Map? extraData, + Map? custom, bool? banned, DateTime? banExpires, List? teams, @@ -206,20 +196,20 @@ class User extends Equatable { bool? invisible, Map? teamsRole, int? avgResponseTime, + DateTime? deactivatedAt, + DateTime? deletedAt, + bool? shadowBanned, + List? blockedUserIds, }) => User( id: id ?? this.id, role: role ?? this.role, - name: - name ?? - extraData?['name'] as String? ?? - // Using extraData value in order to not use id as name. - this.extraData['name'] as String?, - image: image ?? extraData?['image'] as String? ?? this.image, + name: name ?? this.name, + image: image ?? this.image, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, lastActive: lastActive ?? this.lastActive, online: online ?? this.online, - extraData: extraData ?? this.extraData, + custom: custom ?? this.custom, banned: banned ?? this.banned, banExpires: banExpires ?? this.banExpires, teams: teams ?? this.teams, @@ -227,15 +217,21 @@ class User extends Equatable { invisible: invisible ?? this.invisible, teamsRole: teamsRole ?? this.teamsRole, avgResponseTime: avgResponseTime ?? this.avgResponseTime, + deactivatedAt: deactivatedAt ?? this.deactivatedAt, + deletedAt: deletedAt ?? this.deletedAt, + shadowBanned: shadowBanned ?? this.shadowBanned, + blockedUserIds: blockedUserIds ?? this.blockedUserIds, ); @override List get props => [ id, role, + name, + image, lastActive, online, - extraData, + custom, banned, banExpires, teams, @@ -243,6 +239,10 @@ class User extends Equatable { invisible, teamsRole, avgResponseTime, + deactivatedAt, + deletedAt, + shadowBanned, + blockedUserIds, ]; } @@ -264,14 +264,14 @@ class UserFilterField extends FilterField { /// off an instance with [value]. UserFilterField(super.remote, super.value); - /// Creates a field the SDK does not model, read from [User.extraData]. + /// Creates a field the SDK does not model, read from [User.custom]. /// /// A user query compares a custom field for equality only. Anything else is /// rejected. /// /// **Supported operators:** `$eq`, `$in` factory UserFilterField.custom(String remote) { - return UserFilterField(remote, (it) => it.extraData[remote]); + return UserFilterField(remote, (it) => it.custom[remote]); } /// Filters users by their id. @@ -295,7 +295,7 @@ class UserFilterField extends FilterField { /// **Supported operators:** `$eq`, `$autocomplete` static final username = UserFilterField( 'username', - (it) => it.extraData['username'].safeCast(), + (it) => it.custom['username'].safeCast(), ); /// Filters users by their email address. @@ -303,7 +303,7 @@ class UserFilterField extends FilterField { /// **Supported operators:** `$eq`, `$in` static final email = UserFilterField( 'email', - (it) => it.extraData['email'].safeCast(), + (it) => it.custom['email'].safeCast(), ); /// Filters users by their role. @@ -343,7 +343,7 @@ class UserFilterField extends FilterField { /// **Supported operators:** `$eq` static final bypassModeration = UserFilterField( 'bypass_moderation', - (it) => it.extraData['bypass_moderation'].safeCast(), + (it) => it.custom['bypass_moderation'].safeCast(), ); /// Filters users by when they were last online. @@ -422,11 +422,11 @@ class UserSortField extends SortField { /// For a name the SDK has not modelled; prefer the fields declared here. UserSortField(super.remote, super.localValue); - /// Creates a field the SDK does not model, read from [User.extraData]. + /// Creates a field the SDK does not model, read from [User.custom]. /// /// Declared only where the API accepts a custom sort field. factory UserSortField.custom(String remote) { - return UserSortField(remote, (it) => it.extraData[remote]); + return UserSortField(remote, (it) => it.custom[remote]); } /// Sorts users by their ID. @@ -455,10 +455,7 @@ class UserSortField extends SortField { /// sorted locally matches the order a query returns. static final name = UserSortField( 'name', - // Deliberately not `User.name`, which answers the id when a user has no - // name — sorting by that locally would scatter unnamed users among the - // named ones, where a query keeps them together. - (it) => it.extraData['name'].safeCast()?.let(normalizeStringForSort), + (it) => it.name?.let(normalizeStringForSort), ); /// Sorts users by their role. diff --git a/packages/stream_chat/lib/src/core/models/user.g.dart b/packages/stream_chat/lib/src/core/models/user.g.dart index a9195e5a6d..fcea41d1c8 100644 --- a/packages/stream_chat/lib/src/core/models/user.g.dart +++ b/packages/stream_chat/lib/src/core/models/user.g.dart @@ -9,6 +9,8 @@ part of 'user.dart'; User _$UserFromJson(Map json) => User( id: json['id'] as String, role: json['role'] as String?, + name: json['name'] as String?, + image: json['image'] as String?, createdAt: json['created_at'] == null ? null : DateTime.parse(json['created_at'] as String), updatedAt: json['updated_at'] == null ? null : DateTime.parse(json['updated_at'] as String), lastActive: json['last_active'] == null ? null : DateTime.parse(json['last_active'] as String), @@ -22,11 +24,17 @@ User _$UserFromJson(Map json) => User( (k, e) => MapEntry(k, e as String), ), avgResponseTime: (json['avg_response_time'] as num?)?.toInt(), - extraData: json['extra_data'] as Map? ?? const {}, + custom: json['extra_data'] as Map? ?? const {}, + deactivatedAt: json['deactivated_at'] == null ? null : DateTime.parse(json['deactivated_at'] as String), + deletedAt: json['deleted_at'] == null ? null : DateTime.parse(json['deleted_at'] as String), + shadowBanned: json['shadow_banned'] as bool?, + blockedUserIds: (json['blocked_user_ids'] as List?)?.map((e) => e as String).toList() ?? const [], ); Map _$UserToJson(User instance) => { 'id': instance.id, + 'name': ?instance.name, + 'image': ?instance.image, 'role': ?instance.role, 'teams': instance.teams, 'created_at': ?instance.createdAt?.toIso8601String(), @@ -39,5 +47,9 @@ Map _$UserToJson(User instance) => { 'invisible': ?instance.invisible, 'teams_role': ?instance.teamsRole, 'avg_response_time': ?instance.avgResponseTime, - 'extra_data': instance.extraData, + 'extra_data': instance.custom, + 'deactivated_at': ?instance.deactivatedAt?.toIso8601String(), + 'deleted_at': ?instance.deletedAt?.toIso8601String(), + 'shadow_banned': ?instance.shadowBanned, + 'blocked_user_ids': instance.blockedUserIds, }; diff --git a/packages/stream_chat/lib/src/repository/mapper/users_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/users_mapper.dart index e3469e477b..7d4e3c730b 100644 --- a/packages/stream_chat/lib/src/repository/mapper/users_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/users_mapper.dart @@ -13,27 +13,12 @@ import '../../core/models/unread_counts_channel_type.dart'; import '../../core/models/unread_counts_thread.dart'; import '../../core/models/user.dart'; import '../../core/models/user_block.dart'; -import '../../core/util/extension.dart'; // TODO(openapi-migration): re-point these mappers in group 09. /// Maps a generated [api.UserResponse] to a [User]. extension UserResponseMapper on api.UserResponse { - // Custom keys named like one of the user's own fields, including the ones [User] does not model. - static const _shadowedCustomKeys = { - ...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]. - /// - /// Custom data named like one of the user's own fields is left out of [User.extraData]. User toModel() => User( id: id, role: role, @@ -48,17 +33,16 @@ extension UserResponseMapper on api.UserResponse { language: language, teamsRole: teamsRole, avgResponseTime: avgResponseTime, - extraData: {...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), + custom: custom, deactivatedAt: deactivatedAt, deletedAt: deletedAt, + blockedUserIds: blockedUserIds, ); } /// 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, @@ -75,10 +59,11 @@ extension FullUserResponseMapper on api.FullUserResponse { invisible: invisible, teamsRole: teamsRole, avgResponseTime: avgResponseTime, - extraData: {...custom}..removeWhere((key, _) => UserResponseMapper._shadowedCustomKeys.contains(key)), + custom: custom, deactivatedAt: deactivatedAt, deletedAt: deletedAt, shadowBanned: shadowBanned, + blockedUserIds: blockedUserIds, ); } @@ -86,24 +71,18 @@ extension FullUserResponseMapper on api.FullUserResponse { extension UserRequestMapper on User { /// Converts this user into an [api.UserRequest]. /// - /// A user without a name or image converts to a request without one, rather than one named after [id]. The - /// privacy settings of an [OwnUser] are carried over. - /// - /// Custom data named like one of the user's own fields is left out. + /// The privacy settings of an [OwnUser] are carried over. api.UserRequest toRequest() => api.UserRequest( id: id, - name: extraData['name'].safeCast(), - image: extraData['image'].safeCast(), + name: name, + image: image, language: language, invisible: invisible, privacySettings: switch (this) { OwnUser(:final privacySettings?) => privacySettings.toRequest(), _ => null, }, - custom: {...extraData} - ..remove('name') - ..remove('image') - ..removeWhere((key, _) => UserResponseMapper._shadowedCustomKeys.contains(key)), + custom: custom, ); } diff --git a/packages/stream_chat/lib/src/ws/connect_user_details.dart b/packages/stream_chat/lib/src/ws/connect_user_details.dart index f6fe52ac2a..1b3b760772 100644 --- a/packages/stream_chat/lib/src/ws/connect_user_details.dart +++ b/packages/stream_chat/lib/src/ws/connect_user_details.dart @@ -1,7 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; import '../core/models/own_user.dart'; import '../core/models/privacy_settings.dart'; -import '../core/util/extension.dart'; import '../core/util/serializer.dart'; part 'connect_user_details.g.dart'; @@ -36,13 +35,12 @@ class ConnectUserDetails { factory ConnectUserDetails.fromOwnUser(OwnUser user) { return ConnectUserDetails( id: user.id, - // Using extraData value in order to not use id as name. - name: user.extraData['name'].safeCast(), + name: user.name, image: user.image, language: user.language, invisible: user.invisible, privacySettings: user.privacySettings, - extraData: user.extraData, + extraData: user.custom, ); } @@ -68,7 +66,13 @@ class ConnectUserDetails { final Map? extraData; /// Serialize to json. + /// + /// Writes the entries of [extraData] at the root, leaving out any named like + /// one of [OwnUser.topLevelFields]. Map toJson() { - return Serializer.moveFromExtraDataToRoot(_$ConnectUserDetailsToJson(this)); + return Serializer.moveFromExtraDataToRoot( + _$ConnectUserDetailsToJson(this), + topLevelFields: OwnUser.topLevelFields, + ); } } diff --git a/packages/stream_chat/lib/src/ws/events/event.dart b/packages/stream_chat/lib/src/ws/events/event.dart index d58edba04e..9b4f390430 100644 --- a/packages/stream_chat/lib/src/ws/events/event.dart +++ b/packages/stream_chat/lib/src/ws/events/event.dart @@ -62,12 +62,19 @@ class Event extends WsEvent { }) : createdAt = createdAt?.toUtc() ?? DateTime.now().toUtc(); /// Create a new instance from a json - factory Event.fromJson(Map json) => _$EventFromJson( - Serializer.moveToExtraDataFromRoot( - json, - topLevelFields, - ), - ); + factory Event.fromJson(Map json) { + final event = _$EventFromJson( + Serializer.moveToExtraDataFromRoot( + json, + topLevelFields, + ), + ); + + return switch ((event.type, json['user'])) { + (EventType.userUpdated, final Map user) => event.copyWith(user: OwnUser.fromJson(user)), + _ => event, + }; + } /// The type of the event /// [EventType] contains some predefined constant types @@ -98,6 +105,9 @@ class Event extends WsEvent { final OwnUser? me; /// User object of the current user + /// + /// An [OwnUser] on a [EventType.userUpdated] event, so it keeps the user's + /// privacy settings. final User? user; /// The message sent with the event diff --git a/packages/stream_chat/test/fixtures/user.json b/packages/stream_chat/test/fixtures/user.json index e6cd52faac..baa536bda6 100644 --- a/packages/stream_chat/test/fixtures/user.json +++ b/packages/stream_chat/test/fixtures/user.json @@ -19,5 +19,6 @@ "avg_response_time": 120, "deactivated_at": "2021-08-06T10:39:21.817646Z", "deleted_at": "2021-08-07T10:39:21.817646Z", - "shadow_banned": true + "shadow_banned": true, + "blocked_user_ids": ["blocked-1"] } \ 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 9471d192fa..f99e3b03a3 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 @@ -92,11 +92,12 @@ void main() { }); test( - "StreamChatClient.connectGuestUser keeps custom fields named like the user's own fields out of the created guest", + "StreamChatClient.connectGuestUser keeps custom fields named like the user's own fields apart from them", () async { final response = _createGuestResponse( custom: const { 'favorite_color': 'green', + 'name': 'custom-name', 'online': 'custom-online', 'blocked_user_ids': 'custom-blocked-user-ids', 'privacy_settings': 'custom-privacy-settings', @@ -110,13 +111,22 @@ void main() { final guest = await client.connectGuestUser(_fullyPopulatedUser(), connectWebSocket: false); - expect(guest.extraData, { + expect(guest.custom, { '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', + 'name': 'custom-name', + 'online': 'custom-online', + 'blocked_user_ids': 'custom-blocked-user-ids', + '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', }); + expect(guest.name, 'Created Name'); + expect(guest.online, isTrue); + expect(guest.blockedUserIds, ['blocked-user']); + expect(guest.deactivatedAt, DateTime.utc(2026, 9, 4)); + expect(guest.deletedAt, DateTime.utc(2026, 9, 5)); }, ); @@ -216,7 +226,7 @@ OwnUser _fullyPopulatedUser() => OwnUser( invisible: true, teamsRole: const {'team-x': 'admin'}, avgResponseTime: 7, - extraData: const {'favorite_color': 'blue'}, + custom: const {'favorite_color': 'blue'}, devices: const [Device(id: 'device-1', pushProvider: PushProvider.firebase)], mutes: [ Mute( @@ -286,10 +296,11 @@ User _createdGuest() => User( teams: const ['team-a'], teamsRole: const {'team-a': 'member'}, avgResponseTime: 42, - extraData: const {'favorite_color': 'green'}, + custom: const {'favorite_color': 'green'}, 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), + blockedUserIds: const ['blocked-user'], ); diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index bfc6968554..3dc06f2219 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -1875,6 +1875,49 @@ void main() { expect(pushPreferences?.disabledUntil, pushPreference.disabledUntil); }); + test('a user.updated event for the current user replaces their privacy settings', () async { + client.state.currentUser = client.state.currentUser!.copyWith( + privacySettings: const PrivacySettings(typingIndicators: TypingIndicators(enabled: true)), + ); + + client.handleEvent( + Event.fromJson(const { + 'type': EventType.userUpdated, + 'user': { + 'id': userId, + 'privacy_settings': { + 'typing_indicators': {'enabled': false}, + }, + }, + }), + ); + await Future.delayed(Duration.zero); + + expect(client.state.currentUser!.privacySettings?.typingIndicators?.enabled, isFalse); + }); + + test('a user.updated event for the current user keeps the fields only an own user has', () async { + client.state.currentUser = client.state.currentUser!.copyWith( + unreadCount: 3, + totalUnreadCountByTeam: {'team-1': 3}, + latestHiddenChannels: ['messaging:hidden'], + ); + + client.handleEvent( + Event.fromJson(const { + 'type': EventType.userUpdated, + 'user': {'id': userId, 'name': 'Renamed'}, + }), + ); + await Future.delayed(Duration.zero); + + final currentUser = client.state.currentUser!; + expect(currentUser.name, 'Renamed'); + expect(currentUser.unreadCount, 3); + expect(currentUser.totalUnreadCountByTeam, {'team-1': 3}); + expect(currentUser.latestHiddenChannels, ['messaging:hidden']); + }); + test('StreamChatClient.listUserGroups sends the pagination arguments and returns the listed groups', () async { const limit = 10; const idGt = 'cursor-group-id'; 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 index cb9df87389..d4790969a3 100644 --- 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 @@ -103,10 +103,11 @@ User _updatedUser() => User( invisible: false, teamsRole: const {'team-a': 'member'}, avgResponseTime: 42, - extraData: const {'favorite_color': 'green'}, + custom: const {'favorite_color': 'green'}, deactivatedAt: DateTime.utc(2026, 9, 5), deletedAt: DateTime.utc(2026, 9, 6), shadowBanned: false, + blockedUserIds: const ['blocked-user'], ); // A response whose user sets every field, including the private ones a [User] leaves out. 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 index e0dc75ea0b..4b88349e09 100644 --- a/packages/stream_chat/test/src/client/client_update_users_test.dart +++ b/packages/stream_chat/test/src/client/client_update_users_test.dart @@ -32,7 +32,7 @@ void main() { invisible: true, teamsRole: const {'team-a': 'admin'}, avgResponseTime: 12, - extraData: const {'favorite_color': 'blue'}, + custom: const {'favorite_color': 'blue'}, deactivatedAt: DateTime.utc(2026, 1, 5), deletedAt: DateTime.utc(2026, 1, 6), shadowBanned: true, @@ -63,49 +63,62 @@ void main() { }, ); - 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); + test( + 'StreamChatClient.updateUser sends none of the fields an own user decodes from the connection as custom data', + () 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 { + await client.updateUser( + OwnUser.fromJson(const { + 'id': 'user-id', '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 sends custom data named like a user field as custom data', () async { + final defaultApi = _defaultApiAnswering(Result.success(_updateUsersResponse())); + final client = _client(defaultApi); + + await client.updateUser(User(id: 'user-id', name: 'Jane', custom: const {'name': 'custom'})); verify( () => defaultApi.updateUsers( updateUsersRequest: const api.UpdateUsersRequest( users: { - 'user-id': api.UserRequest(id: 'user-id', custom: {'favorite_color': 'blue'}), + 'user-id': api.UserRequest(id: 'user-id', name: 'Jane', custom: {'name': 'custom'}), }, ), ), ).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}, - ), - ), - ), - ); + test('StreamChatClient.updateUser keeps custom data named like a user field apart from the field', () async { + const custom = {'favorite_color': 'green', 'online': false, 'shadow_banned': true, 'unread_count': 3}; + final client = _client(_defaultApiAnswering(Result.success(_updateUsersResponse(custom: custom)))); final res = await client.updateUser(User(id: 'user-id')); - expect(res.getOrNull()!.users['user-id'], _updatedUser()); + final user = res.getOrNull()!.users['user-id']!; + expect(user.custom, custom); + expect(user.online, isTrue); + expect(user.shadowBanned, isFalse); }); test('StreamChatClient.updateUsers sends every user keyed by id and returns the updated users', () async { @@ -178,10 +191,11 @@ User _updatedUser() => User( invisible: false, teamsRole: const {'team-a': 'member'}, avgResponseTime: 42, - extraData: const {'favorite_color': 'green'}, + custom: const {'favorite_color': 'green'}, deactivatedAt: DateTime.utc(2026, 9, 5), deletedAt: DateTime.utc(2026, 9, 6), shadowBanned: false, + blockedUserIds: const ['blocked-user'], ); // A response whose user sets every field, including the private ones a [User] leaves out. diff --git a/packages/stream_chat/test/src/client/poll_fixtures.dart b/packages/stream_chat/test/src/client/poll_fixtures.dart index 324fa21e1a..71d2acb7b7 100644 --- a/packages/stream_chat/test/src/client/poll_fixtures.dart +++ b/packages/stream_chat/test/src/client/poll_fixtures.dart @@ -43,7 +43,8 @@ final voter = User( avgResponseTime: 12, deactivatedAt: DateTime.utc(2024, 1, 4), deletedAt: DateTime.utc(2024, 1, 5), - extraData: const {'favorite_color': 'blue'}, + custom: const {'favorite_color': 'blue'}, + blockedUserIds: const ['blocked-user'], ); final generatedVote = api.PollVoteResponseData( diff --git a/packages/stream_chat/test/src/core/models/event_test.dart b/packages/stream_chat/test/src/core/models/event_test.dart index e15a01ca5e..f9e6ad2964 100644 --- a/packages/stream_chat/test/src/core/models/event_test.dart +++ b/packages/stream_chat/test/src/core/models/event_test.dart @@ -123,9 +123,10 @@ void main() { 'total_unread_count': 0, 'unread_channels': 0, 'unread_threads': 0, + 'unread_count': 0, 'blocked_user_ids': [], }, - 'user': {'id': 'id', 'teams': [], 'online': false, 'banned': false}, + 'user': {'id': 'id', 'teams': [], 'online': false, 'banned': false, 'blocked_user_ids': []}, 'total_unread_count': 1, 'unread_channels': 1, 'online': true, diff --git a/packages/stream_chat/test/src/core/models/own_user_test.dart b/packages/stream_chat/test/src/core/models/own_user_test.dart index 1a88fb439a..9e55f7475b 100644 --- a/packages/stream_chat/test/src/core/models/own_user_test.dart +++ b/packages/stream_chat/test/src/core/models/own_user_test.dart @@ -40,9 +40,8 @@ void main() { expect(ownUser.totalUnreadCount, 0); expect(ownUser.unreadChannels, 0); expect(ownUser.image, 'https://placehold.jp/150x150.png'); - expect(ownUser.extraData['image'], 'https://placehold.jp/150x150.png'); - expect(ownUser.extraData['name'], 'Proud darkness'); - expect(ownUser.extraData['username'], 'Rioland'); + expect(ownUser.name, 'Proud darkness'); + expect(ownUser.custom, {'username': 'Rioland'}); }); test('OwnUser.toJson writes each device under its id and push_provider keys', () { @@ -95,16 +94,26 @@ void main() { final user = User.fromJson(jsonFixture('user.json')); final ownUser = OwnUser.fromUser(user); - expect(ownUser.id, user.id); expect(ownUser.id, user.id); expect(ownUser.role, user.role); + expect(ownUser.name, user.name); + expect(ownUser.image, user.image); expect(ownUser.createdAt, user.createdAt); expect(ownUser.updatedAt, user.updatedAt); expect(ownUser.lastActive, user.lastActive); expect(ownUser.online, user.online); expect(ownUser.banned, user.banned); - expect(ownUser.extraData, user.extraData); - expect(ownUser.image, user.image); + expect(ownUser.banExpires, user.banExpires); + expect(ownUser.teams, user.teams); + expect(ownUser.language, user.language); + expect(ownUser.invisible, user.invisible); + expect(ownUser.teamsRole, user.teamsRole); + expect(ownUser.avgResponseTime, user.avgResponseTime); + expect(ownUser.custom, user.custom); + expect(ownUser.deactivatedAt, user.deactivatedAt); + expect(ownUser.deletedAt, user.deletedAt); + expect(ownUser.shadowBanned, user.shadowBanned); + expect(ownUser.blockedUserIds, user.blockedUserIds); }); test('copyWith', () { @@ -122,7 +131,7 @@ void main() { expect(newUser.updatedAt, user.updatedAt); expect(newUser.lastActive, user.lastActive); expect(newUser.online, user.online); - expect(newUser.extraData, user.extraData); + expect(newUser.custom, user.custom); expect(newUser.banned, user.banned); expect(newUser.teams, user.teams); expect(newUser.language, user.language); @@ -131,9 +140,10 @@ void main() { newUser = user.copyWith( id: 'test', role: 'test', + name: 'test', image: 'https://getstream.io/image-new', - extraData: { - 'name': 'test', + custom: { + 'color': 'red', }, devices: devices, mutes: mutes, @@ -147,20 +157,19 @@ void main() { banned: true, teams: ['team1', 'team2'], language: 'fr', + unreadCount: 7, + totalUnreadCountByTeam: {'team1': 7}, + latestHiddenChannels: ['messaging:hidden'], + deactivatedAt: DateTime.utc(2022), + deletedAt: DateTime.utc(2023), + shadowBanned: true, ); expect(newUser.id, 'test'); expect(newUser.role, 'test'); expect(newUser.name, 'test'); expect(newUser.image, 'https://getstream.io/image-new'); - expect( - newUser.extraData, - { - 'name': 'test', - 'image': 'https://getstream.io/image-new', - }, - reason: 'Should get image from user.image', - ); + expect(newUser.custom, {'color': 'red'}); expect(newUser.devices, devices); expect(newUser.mutes, mutes); expect(newUser.totalUnreadCount, 10); @@ -173,6 +182,12 @@ void main() { expect(newUser.banned, true); expect(newUser.teams, ['team1', 'team2']); expect(newUser.language, 'fr'); + expect(newUser.unreadCount, 7); + expect(newUser.totalUnreadCountByTeam, {'team1': 7}); + expect(newUser.latestHiddenChannels, ['messaging:hidden']); + expect(newUser.deactivatedAt, DateTime.utc(2022)); + expect(newUser.deletedAt, DateTime.utc(2023)); + expect(newUser.shadowBanned, isTrue); }); test('merge', () { @@ -181,8 +196,9 @@ void main() { OwnUser( id: 'test', role: 'test', - extraData: const { - 'name': 'test', + name: 'test', + custom: const { + 'color': 'red', }, image: 'https://getstream.io/image-new', devices: devices, @@ -197,6 +213,12 @@ void main() { banned: true, teams: const ['team1', 'team2'], language: 'fr', + unreadCount: 7, + totalUnreadCountByTeam: const {'team1': 7}, + latestHiddenChannels: const ['messaging:hidden'], + deactivatedAt: DateTime.utc(2022), + deletedAt: DateTime.utc(2023), + shadowBanned: true, ), ); @@ -204,14 +226,7 @@ void main() { expect(newUser.role, 'test'); expect(newUser.name, 'test'); expect(newUser.image, 'https://getstream.io/image-new'); - expect( - newUser.extraData, - { - 'name': 'test', - 'image': 'https://getstream.io/image-new', - }, - reason: 'Should get image from user.image', - ); + expect(newUser.custom, {'color': 'red'}); expect(newUser.devices, devices); expect(newUser.mutes, mutes); expect(newUser.totalUnreadCount, 10); @@ -224,28 +239,20 @@ void main() { expect(newUser.banned, true); expect(newUser.teams, ['team1', 'team2']); expect(newUser.language, 'fr'); + expect(newUser.unreadCount, 7); + expect(newUser.totalUnreadCountByTeam, {'team1': 7}); + expect(newUser.latestHiddenChannels, ['messaging:hidden']); + expect(newUser.deactivatedAt, DateTime.utc(2022)); + expect(newUser.deletedAt, DateTime.utc(2023)); + expect(newUser.shadowBanned, isTrue); }); - test( - 'fromUser should not override name with id if not available in extraData', - () { - final user = User(id: 'test-id'); - expect(user.id, 'test-id'); - expect(user.name, 'test-id'); - - final encodedUser = user.toJson(); - expect(encodedUser['id'], 'test-id'); - expect(encodedUser['name'], null); - - final ownUser = OwnUser.fromUser(user); - expect(user.id, 'test-id'); - expect(user.name, 'test-id'); + test('OwnUser.fromUser keeps a user with no name nameless', () { + final ownUser = OwnUser.fromUser(User(id: 'test-id')); - final encodedOwnUser = ownUser.toJson(); - expect(encodedOwnUser['id'], 'test-id'); - expect(encodedOwnUser['name'], null); - }, - ); + expect(ownUser.name, isNull); + expect(ownUser.toJson().containsKey('name'), isFalse); + }); test('should parse json with privacy settings correctly', () { final json = { @@ -302,131 +309,7 @@ void main() { expect(updatedUser.privacySettings?.readReceipts?.enabled, false); }); - test('fromUser should extract privacy settings from extraData', () { - // Create user from JSON to properly deserialize privacy_settings - final userJson = { - 'id': 'test-user', - 'privacy_settings': { - 'typing_indicators': {'enabled': false}, - 'read_receipts': {'enabled': true}, - }, - }; - - final user = User.fromJson(userJson); - final ownUser = OwnUser.fromUser(user); - - expect(ownUser.privacySettings, isNotNull); - expect(ownUser.privacySettings?.typingIndicators?.enabled, false); - expect(ownUser.privacySettings?.readReceipts?.enabled, true); - }); - - test('fromUser should extract push preferences from extraData', () { - final userJson = { - 'id': 'test-user', - 'push_preferences': { - 'call_level': 'all', - 'chat_level': 'mentions', - 'disabled_until': '2025-12-31T00:00:00.000Z', - }, - }; - - final user = User.fromJson(userJson); - final ownUser = OwnUser.fromUser(user); - - expect(ownUser.pushPreferences, isNotNull); - expect(ownUser.pushPreferences?.callLevel, CallLevel.all); - expect(ownUser.pushPreferences?.chatLevel, ChatLevel.mentions); - expect(ownUser.pushPreferences?.disabledUntil, isNotNull); - }); - - test('fromUser should extract devices from extraData', () { - final userJson = { - 'id': 'test-user', - 'devices': [ - { - 'id': 'device-1', - 'push_provider': 'firebase', - 'created_at': '2023-01-01T00:00:00.000Z', - 'user_id': 'test-user', - }, - ], - }; - - final user = User.fromJson(userJson); - final ownUser = OwnUser.fromUser(user); - - expect(ownUser.devices, hasLength(1)); - expect(ownUser.devices.first.id, 'device-1'); - expect(ownUser.devices.first.pushProvider, 'firebase'); - }); - - test('fromUser should extract mutes from extraData', () { - final userJson = { - 'id': 'test-user', - 'mutes': [ - { - 'user': { - 'id': 'test-user', - }, - 'target': { - 'id': 'muted-user', - }, - 'created_at': '2023-01-01T00:00:00.000Z', - 'updated_at': '2023-01-01T00:00:00.000Z', - }, - ], - }; - - final user = User.fromJson(userJson); - final ownUser = OwnUser.fromUser(user); - - expect(ownUser.mutes, hasLength(1)); - expect(ownUser.mutes.first.user.id, 'test-user'); - expect(ownUser.mutes.first.target.id, 'muted-user'); - }); - - test('fromUser should extract channel mutes from extraData', () { - final userJson = { - 'id': 'test-user', - 'channel_mutes': [ - { - 'user': { - 'id': 'test-user', - }, - 'channel': { - 'cid': 'messaging:test-channel', - }, - 'created_at': '2023-01-01T00:00:00.000Z', - 'updated_at': '2023-01-01T00:00:00.000Z', - }, - ], - }; - - final user = User.fromJson(userJson); - final ownUser = OwnUser.fromUser(user); - - expect(ownUser.channelMutes, hasLength(1)); - expect(ownUser.channelMutes.first.user.id, 'test-user'); - expect(ownUser.channelMutes.first.channel.cid, 'messaging:test-channel'); - }); - - test('fromUser should extract unread counts from extraData', () { - final userJson = { - 'id': 'test-user', - 'total_unread_count': 42, - 'unread_channels': 5, - 'unread_threads': 3, - }; - - final user = User.fromJson(userJson); - final ownUser = OwnUser.fromUser(user); - - expect(ownUser.totalUnreadCount, 42); - expect(ownUser.unreadChannels, 5); - expect(ownUser.unreadThreads, 3); - }); - - test('fromUser should extract blocked user ids from extraData', () { + test('fromUser should copy the blocked user ids', () { final userJson = { 'id': 'test-user', 'blocked_user_ids': ['blocked-1', 'blocked-2', 'blocked-3'], @@ -441,10 +324,15 @@ void main() { expect(ownUser.blockedUserIds, contains('blocked-3')); }); - test('fromUser should handle missing OwnUser-specific fields', () { + test('fromUser leaves the own-user fields at their defaults, even when the user JSON carried them', () { final userJson = { 'id': 'test-user', 'name': 'Test User', + 'total_unread_count': 42, + 'unread_count': 42, + 'privacy_settings': { + 'typing_indicators': {'enabled': false}, + }, }; final user = User.fromJson(userJson); @@ -459,6 +347,9 @@ void main() { expect(ownUser.blockedUserIds, isEmpty); expect(ownUser.pushPreferences, isNull); expect(ownUser.privacySettings, isNull); + expect(ownUser.unreadCount, 0); + expect(ownUser.totalUnreadCountByTeam, isNull); + expect(ownUser.latestHiddenChannels, isNull); }); test('fromUser should work with OwnUser as input', () { @@ -467,7 +358,7 @@ void main() { name: 'Test User', role: 'admin', image: 'https://example.com/avatar.png', - extraData: const { + custom: const { 'company': 'Stream', 'department': 'Engineering', 'custom_field': 'custom_value', @@ -499,9 +390,9 @@ void main() { expect(convertedOwnUser.name, originalOwnUser.name); expect(convertedOwnUser.role, originalOwnUser.role); expect(convertedOwnUser.image, originalOwnUser.image); - expect(convertedOwnUser.extraData['company'], 'Stream'); - expect(convertedOwnUser.extraData['department'], 'Engineering'); - expect(convertedOwnUser.extraData['custom_field'], 'custom_value'); + expect(convertedOwnUser.custom['company'], 'Stream'); + expect(convertedOwnUser.custom['department'], 'Engineering'); + expect(convertedOwnUser.custom['custom_field'], 'custom_value'); expect(convertedOwnUser.devices.length, 1); expect(convertedOwnUser.devices.first.id, 'device-1'); expect(convertedOwnUser.devices.first.pushProvider, 'firebase'); @@ -518,15 +409,14 @@ void main() { }); test( - 'fromUser should work with JSON round-trip ' - '(OwnUser -> JSON -> User -> OwnUser)', + 'OwnUser.fromJson reads back what OwnUser.toJson wrote', () { final originalOwnUser = OwnUser( id: 'test-user', name: 'Test User', role: 'moderator', image: 'https://example.com/profile.jpg', - extraData: const { + custom: const { 'title': 'Senior Developer', 'location': 'Amsterdam', 'is_verified': true, @@ -554,16 +444,12 @@ void main() { typingIndicators: TypingIndicators(enabled: true), readReceipts: ReadReceipts(enabled: false), ), + unreadCount: 25, + totalUnreadCountByTeam: const {'team-1': 25}, + latestHiddenChannels: const ['messaging:hidden'], ); - // Step 1: OwnUser -> JSON - final json = originalOwnUser.toJson(); - - // Step 2: JSON -> User - final user = User.fromJson(json); - - // Step 3: User -> OwnUser - final reconstructedOwnUser = OwnUser.fromUser(user); + final reconstructedOwnUser = OwnUser.fromJson(originalOwnUser.toJson()); // Verify all fields are preserved through the round-trip expect(reconstructedOwnUser.id, originalOwnUser.id); @@ -571,10 +457,10 @@ void main() { expect(reconstructedOwnUser.role, originalOwnUser.role); expect(reconstructedOwnUser.image, originalOwnUser.image); - // Verify extraData - expect(reconstructedOwnUser.extraData['title'], 'Senior Developer'); - expect(reconstructedOwnUser.extraData['location'], 'Amsterdam'); - expect(reconstructedOwnUser.extraData['is_verified'], true); + // Verify custom data + expect(reconstructedOwnUser.custom['title'], 'Senior Developer'); + expect(reconstructedOwnUser.custom['location'], 'Amsterdam'); + expect(reconstructedOwnUser.custom['is_verified'], true); // Verify OwnUser-specific fields expect(reconstructedOwnUser.devices.length, 2); @@ -590,6 +476,9 @@ void main() { expect(reconstructedOwnUser.blockedUserIds, contains('blocked-1')); expect(reconstructedOwnUser.blockedUserIds, contains('blocked-2')); expect(reconstructedOwnUser.blockedUserIds, contains('blocked-3')); + expect(reconstructedOwnUser.unreadCount, 25); + expect(reconstructedOwnUser.totalUnreadCountByTeam, {'team-1': 25}); + expect(reconstructedOwnUser.latestHiddenChannels, ['messaging:hidden']); // Verify push preferences final pushPrefs = reconstructedOwnUser.pushPreferences; diff --git a/packages/stream_chat/test/src/core/models/reaction_test.dart b/packages/stream_chat/test/src/core/models/reaction_test.dart index 0bb3a0a49d..e2501d0e60 100644 --- a/packages/stream_chat/test/src/core/models/reaction_test.dart +++ b/packages/stream_chat/test/src/core/models/reaction_test.dart @@ -24,6 +24,7 @@ void main() { 'banned': false, 'image': 'https://randomuser.me/api/portraits/women/45.jpg', 'name': 'Daisy Morgan', + 'blocked_user_ids': [], }, ); expect(reaction.score, 1); @@ -77,6 +78,7 @@ void main() { 'banned': false, 'image': 'https://randomuser.me/api/portraits/women/45.jpg', 'name': 'Daisy Morgan', + 'blocked_user_ids': [], }, ); expect(newReaction.score, 1); diff --git a/packages/stream_chat/test/src/core/models/read_test.dart b/packages/stream_chat/test/src/core/models/read_test.dart index 4a538b51ab..f87c38258a 100644 --- a/packages/stream_chat/test/src/core/models/read_test.dart +++ b/packages/stream_chat/test/src/core/models/read_test.dart @@ -33,7 +33,13 @@ void main() { ); expect(read.toJson(), { - 'user': {'id': 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e', 'teams': [], 'online': false, 'banned': false}, + 'user': { + 'id': 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e', + 'teams': [], + 'online': false, + 'banned': false, + 'blocked_user_ids': [], + }, 'last_read': '2020-01-28T22:17:30.966485Z', 'unread_messages': 10, 'last_read_message_id': '8cc1301d-2d47-4305-945a-cd8e19b736d6', 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 e018faeac9..8f256b8719 100644 --- a/packages/stream_chat/test/src/core/models/user_test.dart +++ b/packages/stream_chat/test/src/core/models/user_test.dart @@ -30,11 +30,12 @@ void main() { expect(user.role, role); expect(user.name, name); expect(user.image, image); - expect(user.extraData['image'], image); - expect(user.extraData['extraDataStringTest'], extraDataStringTest); - expect(user.extraData['extraDataIntTest'], extraDataIntTest); - expect(user.extraData['extraDataDoubleTest'], extraDataDoubleTest); - expect(user.extraData['extraDataBoolTest'], extraDataBoolTest); + expect(user.custom, { + 'extraDataStringTest': extraDataStringTest, + 'extraDataIntTest': extraDataIntTest, + 'extraDataDoubleTest': extraDataDoubleTest, + 'extraDataBoolTest': extraDataBoolTest, + }); expect(user.online, online); expect(user.banned, banned); expect(user.teams, teams); @@ -48,43 +49,94 @@ void main() { 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); + expect(user.blockedUserIds, ['blocked-1']); }); - test('User keeps deactivatedAt, deletedAt and shadowBanned in extraData', () { + test('User writes its fields to JSON and reads them back', () { final user = User( id: id, + name: name, + image: image, deactivatedAt: DateTime.utc(2021), deletedAt: DateTime.utc(2020), shadowBanned: true, - extraData: const {'color': 'red'}, + blockedUserIds: const ['blocked-1'], + custom: const {'color': 'red'}, ); - expect(user.extraData, { + expect(User.fromJson(user.toJson()), user); + }); + + test('User.fromJson leaves the keys only an own user carries out of custom', () { + final user = User.fromJson(const { + 'id': id, + 'devices': [], + 'mutes': [], + 'channel_mutes': [], + 'total_unread_count': 1, + 'unread_channels': 1, + 'unread_threads': 1, + 'unread_count': 1, + 'total_unread_count_by_team': {'team-1': 1}, + 'latest_hidden_channels': ['messaging:hidden'], + 'push_preferences': {'chat_level': 'all'}, + 'privacy_settings': {}, + 'revoke_tokens_issued_before': '2021-08-06T10:39:21.817646Z', 'color': 'red', - 'deactivated_at': DateTime.utc(2021).toIso8601String(), - 'deleted_at': DateTime.utc(2020).toIso8601String(), - 'shadow_banned': true, }); + + expect(user.custom, {'color': 'red'}); }); - test('User replaces the extraData entries that deactivatedAt, deletedAt and shadowBanned read', () { + test('Users that differ only in a promoted field are not equal', () { + User user({ + String? name, + String? image, + DateTime? deactivatedAt, + DateTime? deletedAt, + bool? shadowBanned, + List blockedUserIds = const [], + }) => User( + id: id, + name: name, + image: image, + deactivatedAt: deactivatedAt, + deletedAt: deletedAt, + shadowBanned: shadowBanned, + blockedUserIds: blockedUserIds, + ); + + expect(user(name: 'a'), isNot(user(name: 'b'))); + expect(user(image: 'a'), isNot(user(image: 'b'))); + expect(user(deactivatedAt: DateTime.utc(2020)), isNot(user(deactivatedAt: DateTime.utc(2021)))); + expect(user(deletedAt: DateTime.utc(2020)), isNot(user(deletedAt: DateTime.utc(2021)))); + expect(user(shadowBanned: true), isNot(user(shadowBanned: false))); + expect(user(blockedUserIds: ['a']), isNot(user(blockedUserIds: ['b']))); + }); + + test('User keeps custom data named like a field apart from the field', () { 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}, + name: name, + custom: const {'name': 'custom', 'color': 'red'}, ); - expect(user.deactivatedAt, DateTime.utc(2021)); - expect(user.deletedAt, DateTime.utc(2020)); - expect(user.shadowBanned, isTrue); + expect(user.name, name); + expect(user.custom, {'name': 'custom', 'color': 'red'}); }); - test('User.deletedAt returns null for an extraData value that is not a date', () { - final user = User(id: id, extraData: const {'deleted_at': 'yesterday'}); + test('User.toJson leaves out custom data named like a field', () { + final user = User( + id: id, + name: name, + custom: const {'name': 'custom', 'devices': 'custom', 'color': 'red'}, + ); + + final json = user.toJson(); - expect(user.deletedAt, isNull); + expect(json['name'], name); + expect(json.containsKey('devices'), isFalse); + expect(json['color'], 'red'); }); test('should serialize to json correctly', () { @@ -93,7 +145,7 @@ void main() { role: role, name: name, image: image, - extraData: const { + custom: const { 'extraDataStringTest': extraDataStringTest, 'extraDataIntTest': extraDataIntTest, 'extraDataDoubleTest': extraDataDoubleTest, @@ -130,6 +182,7 @@ void main() { 'invisible': true, 'teams_role': teamsRole, 'avg_response_time': avgResponseTime, + 'blocked_user_ids': [], }); }); @@ -151,6 +204,10 @@ void main() { expect(newUser.invisible, user.invisible); expect(newUser.teamsRole, user.teamsRole); expect(newUser.avgResponseTime, user.avgResponseTime); + expect(newUser.deactivatedAt, user.deactivatedAt); + expect(newUser.deletedAt, user.deletedAt); + expect(newUser.shadowBanned, user.shadowBanned); + expect(newUser.blockedUserIds, user.blockedUserIds); newUser = user.copyWith( id: 'test', @@ -167,13 +224,16 @@ void main() { invisible: true, teamsRole: {'new-team1': 'admin', 'new-team2': 'member'}, avgResponseTime: 60, + deactivatedAt: DateTime.utc(2022), + deletedAt: DateTime.utc(2023), + shadowBanned: false, + blockedUserIds: ['blocked-2'], ); expect(newUser.id, 'test'); expect(newUser.role, 'test'); expect(newUser.name, 'test'); expect(newUser.image, 'https://stream.io/new-image'); - expect(newUser.extraData['image'], 'https://stream.io/new-image'); expect(newUser.online, false); expect(newUser.banned, false); expect(newUser.teams, ['new-team1', 'new-team2']); @@ -184,70 +244,10 @@ void main() { expect(newUser.invisible, true); expect(newUser.teamsRole, {'new-team1': 'admin', 'new-team2': 'member'}); expect(newUser.avgResponseTime, 60); - }); - - test('name property and extraData manipulation', () { - final user = User(id: id, name: name); - - expect(user.name, name); - expect(user.extraData['name'], name); - - const nameOne = 'Name One'; - var newUser = user.copyWith( - extraData: {'name': nameOne}, - ); - - expect(newUser.extraData['name'], nameOne); - expect(newUser.name, nameOne); - - const nameTwo = 'Name Two'; - newUser = user.copyWith( - name: nameTwo, - ); - - expect(newUser.extraData['name'], nameTwo); - expect(newUser.name, nameTwo); - - const nameThree = 'Name Three'; - newUser = user.copyWith( - name: nameThree, - extraData: {'name': nameThree}, - ); - - expect(newUser.extraData['name'], nameThree); - expect(newUser.name, nameThree); - }); - - test('image property and extraData manipulation', () { - final user = User(id: id, image: image); - - expect(user.image, image); - expect(user.extraData['image'], image); - - const imageURLOne = 'https://stream.io/image-one'; - var newUser = user.copyWith( - extraData: {'image': imageURLOne}, - ); - - expect(newUser.extraData['image'], imageURLOne); - expect(newUser.image, imageURLOne); - - const imageURLTwo = 'https://stream.io/image-two'; - newUser = user.copyWith( - image: imageURLTwo, - ); - - expect(newUser.extraData['image'], imageURLTwo); - expect(newUser.image, imageURLTwo); - - const imageURLThree = 'https://stream.io/image-three'; - newUser = user.copyWith( - image: imageURLThree, - extraData: {'image': imageURLThree}, - ); - - expect(newUser.extraData['image'], imageURLThree); - expect(newUser.image, imageURLThree); + expect(newUser.deactivatedAt, DateTime.utc(2022)); + expect(newUser.deletedAt, DateTime.utc(2023)); + expect(newUser.shadowBanned, isFalse); + expect(newUser.blockedUserIds, ['blocked-2']); }); test('default values, constructor', () { @@ -255,9 +255,9 @@ void main() { expect(user.id, id); expect(user.role, null); - expect(user.name, id, reason: 'if a name is not supplied, default to id'); + expect(user.name, isNull); expect(user.image, null); - expect(user.extraData, const {}); + expect(user.custom, const {}); expect(user.online, false); expect(user.banned, false); expect(user.teams, []); @@ -266,6 +266,10 @@ void main() { expect(user.updatedAt, null); expect(user.teamsRole, null); expect(user.avgResponseTime, null); + expect(user.deactivatedAt, null); + expect(user.deletedAt, null); + expect(user.shadowBanned, null); + expect(user.blockedUserIds, isEmpty); }); test('default values, parse json', () { @@ -273,9 +277,9 @@ void main() { expect(user.id, id); expect(user.role, null); - expect(user.name, id, reason: 'if a name is not supplied, default to id'); + expect(user.name, isNull); expect(user.image, null); - expect(user.extraData, const {}); + expect(user.custom, const {}); expect(user.online, false); expect(user.banned, false); expect(user.teams, []); @@ -284,6 +288,10 @@ void main() { expect(user.updatedAt, null); expect(user.teamsRole, null); expect(user.avgResponseTime, null); + expect(user.deactivatedAt, null); + expect(user.deletedAt, null); + expect(user.shadowBanned, null); + expect(user.blockedUserIds, isEmpty); }); group('UserSortField', () { @@ -306,9 +314,8 @@ void main() { }); test('name orders nothing for a user with no name', () { - // `User.name` answers the id when a user has no name. Sorting by that - // would order unnamed users among the named ones, where the API sorts - // them together by an empty `name` column. + // Sorting unnamed users by their id would order them among the named + // ones, where the API sorts them together by an empty `name` column. expectOrdersNothing( UserSortField.name, createTestUser(id: 'alice'), @@ -340,11 +347,11 @@ void main() { ); }); - test('a custom field orders by the user extra data', () { + test('a custom field orders by the user custom data', () { expectOrders( UserSortField.custom('score'), - createTestUser(id: 'a', extraData: const {'score': 10}), - createTestUser(id: 'b', extraData: const {'score': 90}), + createTestUser(id: 'a', custom: const {'score': 10}), + createTestUser(id: 'b', custom: const {'score': 90}), ); }); @@ -356,75 +363,31 @@ void main() { }); }); - test('.name should fetch from extraData if available', () { - final user = User( - id: 'test-user', - extraData: const {'name': 'Test User'}, - ); - - expect(user.name, 'Test User'); - }); - - test('.name should return id if extraData value is empty', () { - final user = User( - id: 'test-user', - extraData: const {'name': ''}, - ); - - expect(user.name, 'test-user'); - }); - - test('.name should return id if not available in extraData', () { - final user = User( - id: 'test-user', - extraData: const {}, - ); - - expect(user.name, 'test-user'); - }); - - test('.name should return id if extraData value is not String', () { - final user = User( - id: 'test-user', - extraData: const {'name': true}, - ); - - expect(user.name, 'test-user'); - }); - - test('.image should fetch from extraData if available', () { - final user = User( - id: 'test-user', - extraData: const {'image': 'https://example.com/image.png'}, - ); + test('User.nameOrId returns the name', () { + final user = User(id: id, name: name); - expect(user.image, 'https://example.com/image.png'); + expect(user.nameOrId, name); }); - test('.image should return null if not available in extraData', () { - final user = User( - id: 'test-user', - extraData: const {}, - ); + test('User.nameOrId returns the id for a user with no name', () { + final user = User(id: id); - expect(user.image, isNull); + expect(user.nameOrId, id); }); - test('.image should return null if extraData value is not String', () { - final user = User( - id: 'test-user', - extraData: const {'image': true}, - ); + test('User stores a blank name or image as null', () { + for (final blank in ['', ' ']) { + final user = User(id: id, name: blank, image: blank); - expect(user.image, isNull); + expect(user.name, isNull, reason: 'name "$blank"'); + expect(user.image, isNull, reason: 'image "$blank"'); + } }); - test('.image should return null if extraData value is empty', () { - final user = User( - id: 'test-user', - extraData: const {'image': ''}, - ); + test('User.fromJson reads a blank name or image as null', () { + final user = User.fromJson(const {'id': id, 'name': ' ', 'image': ''}); + expect(user.name, isNull); expect(user.image, isNull); }); }); @@ -437,7 +400,7 @@ User createTestUser({ String? role, bool? banned, DateTime? lastActive, - Map? extraData, + Map? custom, Map? teamsRole, int? avgResponseTime, }) { @@ -447,7 +410,7 @@ User createTestUser({ role: role, banned: banned ?? false, lastActive: lastActive, - extraData: extraData ?? {}, + custom: custom ?? {}, teamsRole: teamsRole, avgResponseTime: avgResponseTime, ); diff --git a/packages/stream_chat/test/src/ws/connect_request_test.dart b/packages/stream_chat/test/src/ws/connect_request_test.dart index 68b4877f5b..c232d263f2 100644 --- a/packages/stream_chat/test/src/ws/connect_request_test.dart +++ b/packages/stream_chat/test/src/ws/connect_request_test.dart @@ -57,6 +57,18 @@ void main() { ); }); + test('ConnectRequest leaves out custom data named like one of the user details', () { + final user = OwnUser( + id: 'test-user-id', + name: 'Test User', + custom: const {'name': 'custom', 'privacy_settings': 'custom', 'color': 'red'}, + ); + + final options = _request().build(user: user, token: _token(), includeUserDetails: true); + + expect(_payload(options)['user_details'], {'id': 'test-user-id', 'name': 'Test User', 'color': 'red'}); + }); + test('ConnectRequest describes the app as it describes itself now', () { final environment = _environment(); final request = ConnectRequest.forApi( diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index bedf14ec67..2bb48cffe0 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -8,6 +8,7 @@ 🔄 Changed - The default channel error state keys its copy off the failure kind rather than a transport enum, so "slow connection" now covers every timeout and "no internet" covers every other failure to reach the server. +- `StreamUserAvatar` shows its placeholder icon for a user with no name, instead of initials taken from their id. ## Upcoming diff --git a/packages/stream_chat_flutter/example/lib/debug/members.dart b/packages/stream_chat_flutter/example/lib/debug/members.dart index d0214e00d6..f7237fa302 100644 --- a/packages/stream_chat_flutter/example/lib/debug/members.dart +++ b/packages/stream_chat_flutter/example/lib/debug/members.dart @@ -39,7 +39,7 @@ class DebugMembers extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text(member.user?.name ?? '?'), + Text(member.user?.nameOrId ?? '?'), Text('ID: ${member.user?.id ?? '?'}'), Text('Ban: ${member.banned ? 'T' : 'F'}'), Text('ShBan: ${member.shadowBanned ? 'T' : 'F'}'), diff --git a/packages/stream_chat_flutter/example/lib/debug/mutes.dart b/packages/stream_chat_flutter/example/lib/debug/mutes.dart index fe54b06e18..0b47f9d801 100644 --- a/packages/stream_chat_flutter/example/lib/debug/mutes.dart +++ b/packages/stream_chat_flutter/example/lib/debug/mutes.dart @@ -36,9 +36,9 @@ class DebugMutes extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text('By: ${mute.user.name} (${mute.user.id})'), + Text('By: ${mute.user.nameOrId} (${mute.user.id})'), Text( - 'Who: ${mute.target.name} (${mute.target.id})', + 'Who: ${mute.target.nameOrId} (${mute.target.id})', ), Text('Exp: ${mute.expires}'), ], diff --git a/packages/stream_chat_flutter/lib/src/autocomplete/user_mention_search.dart b/packages/stream_chat_flutter/lib/src/autocomplete/user_mention_search.dart index ae661de787..b2d670afb3 100644 --- a/packages/stream_chat_flutter/lib/src/autocomplete/user_mention_search.dart +++ b/packages/stream_chat_flutter/lib/src/autocomplete/user_mention_search.dart @@ -7,8 +7,8 @@ import '../../stream_chat_flutter.dart'; /// /// Every word in the normalized [query] except the last must fully equal a /// word in the normalized user name. The last query word is matched as a -/// prefix of any name word. When [User.name] is blank, [User.id] is used in -/// its place. +/// prefix of any name word. A user without a name is matched by [User.id] +/// instead. /// /// Normalization strips diacritics and lowercases both sides. Results are /// sorted alphabetically by the normalized matchable string. @@ -17,10 +17,7 @@ List searchUsersForMention(Iterable users, String query) { final queryTokens = _tokenize(_normalize(query)); final entries = users - .map((user) { - final raw = user.name.isNotEmpty ? user.name : user.id; - return (user, _normalize(raw)); - }) + .map((user) => (user, _normalize(user.nameOrId))) .where((entry) => _matchesMentionQuery(queryTokens, entry.$2)) .toList() ..sort((a, b) => a.$2.compareTo(b.$2)); diff --git a/packages/stream_chat_flutter/lib/src/channel/channel_name.dart b/packages/stream_chat_flutter/lib/src/channel/channel_name.dart index 3755f83fb5..0edd955d94 100644 --- a/packages/stream_chat_flutter/lib/src/channel/channel_name.dart +++ b/packages/stream_chat_flutter/lib/src/channel/channel_name.dart @@ -72,7 +72,7 @@ class _NameGenerator extends StatelessWidget { if (otherMembers.length == 1) { final user = otherMembers.first.user; if (user != null) { - channelName = user.name; + channelName = user.nameOrId; } } else { final maxWidth = constraints.maxWidth; @@ -80,7 +80,7 @@ class _NameGenerator extends StatelessWidget { var currentChars = 0; final currentMembers = []; otherMembers.forEach((element) { - final newLength = currentChars + (element.user?.name.length ?? 0); + final newLength = currentChars + (element.user?.nameOrId.length ?? 0); if (newLength < maxChars) { currentChars = newLength; currentMembers.add(element); @@ -89,7 +89,7 @@ class _NameGenerator extends StatelessWidget { final exceedingMembers = otherMembers.length - currentMembers.length; channelName = - '${currentMembers.map((e) => e.user?.name).join(', ')} ' + '${currentMembers.map((e) => e.user?.nameOrId).join(', ')} ' '${exceedingMembers > 0 ? '+ $exceedingMembers' : ''}'; } } diff --git a/packages/stream_chat_flutter/lib/src/channel/stream_channel_name.dart b/packages/stream_chat_flutter/lib/src/channel/stream_channel_name.dart index 54fc4f3eca..0482b64e65 100644 --- a/packages/stream_chat_flutter/lib/src/channel/stream_channel_name.dart +++ b/packages/stream_chat_flutter/lib/src/channel/stream_channel_name.dart @@ -55,7 +55,7 @@ class StreamChannelName extends StatelessWidget { if (otherMembers.length == 1) { final user = otherMembers.first.user; if (user != null) { - channelName = user.name; + channelName = user.nameOrId; } } else { final maxWidth = constraints.maxWidth; @@ -81,7 +81,7 @@ class StreamChannelName extends StatelessWidget { String _getChannelName({required List currentMembers, required List members}) { final exceedingMembers = members.length - currentMembers.length; - return '${currentMembers.map((e) => e.user?.name).join(', ')} ' + return '${currentMembers.map((e) => e.user?.nameOrId).join(', ')} ' '${exceedingMembers > 0 ? '+ $exceedingMembers' : ''}'; } diff --git a/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart b/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart index 245136ee34..3e315abbd1 100644 --- a/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart +++ b/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart @@ -183,7 +183,7 @@ class _StreamUserAvatarPlaceholder extends StatelessWidget { @override Widget build(BuildContext context) { - final userInitials = user.name.initials; + final userInitials = user.name?.initials; final Widget content; if (userInitials != null && userInitials.isNotEmpty) { content = switch (size) { diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_header.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_header.dart index 6936cae362..bb8d865535 100644 --- a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_header.dart +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_header.dart @@ -216,7 +216,7 @@ class _QuotedMessageInHeader extends StatelessWidget { final translations = context.translations; final title = switch (isIncoming) { - true => translations.replyToUserLabel(quotedMessage.user?.name ?? ''), + true => translations.replyToUserLabel(quotedMessage.user?.nameOrId ?? ''), false => translations.youText, }; diff --git a/packages/stream_chat_flutter/lib/src/localization/translations.dart b/packages/stream_chat_flutter/lib/src/localization/translations.dart index 1eab6da3ab..96aa9e28d8 100644 --- a/packages/stream_chat_flutter/lib/src/localization/translations.dart +++ b/packages/stream_chat_flutter/lib/src/localization/translations.dart @@ -863,9 +863,9 @@ class DefaultTranslations implements Translations { if (users.isEmpty) return ''; final first = users.first; if (users.length == 1) { - return '${first.name} is typing'; + return '${first.nameOrId} is typing'; } - return '${first.name} and ${users.length - 1} more are typing'; + return '${first.nameOrId} and ${users.length - 1} more are typing'; } @override @@ -893,7 +893,7 @@ class DefaultTranslations implements Translations { }) { final pinnedByCurrentUser = currentUser.id == pinnedBy.id; if (pinnedByCurrentUser) return 'Pinned by You'; - return 'Pinned by ${pinnedBy.name}'; + return 'Pinned by ${pinnedBy.nameOrId}'; } @override diff --git a/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview.dart b/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview.dart index 8f90b75b1e..b48c3ab30c 100644 --- a/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview.dart +++ b/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview.dart @@ -223,7 +223,7 @@ class _DefaultStreamMediaGalleryPreviewState extends State .user; @override - String get display => user.name; + String get display => user.nameOrId; } /// A `@channel` broadcast mention. diff --git a/packages/stream_chat_flutter/lib/src/mention/mention_item.dart b/packages/stream_chat_flutter/lib/src/mention/mention_item.dart index 2e5413a047..1d79d72824 100644 --- a/packages/stream_chat_flutter/lib/src/mention/mention_item.dart +++ b/packages/stream_chat_flutter/lib/src/mention/mention_item.dart @@ -246,7 +246,7 @@ class _MentionUserItem extends StatelessWidget { Widget build(BuildContext context) { return _MentionItem( leading: StreamUserAvatar(size: .md, user: user), - title: user.name, + title: user.nameOrId, onTap: onTap, ); } diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart index a2db6d1e0e..8ad3b2317e 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart @@ -974,7 +974,7 @@ class DefaultStreamMessageComposerState extends State null, - _ => user.name, + _ => user.nameOrId, }, ); if (props.onTap case final onTap?) { diff --git a/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart index 85d94e039d..bc50b599d9 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart @@ -1442,9 +1442,9 @@ class _MessageRowSemanticsState extends State<_MessageRowSemantics> { // // With no name there is nothing to attribute, so the body stands alone — // the message text, or "Message deleted" for a deleted message, which is - // already what the formatter returns for one. [User.name] falls back to + // already what the formatter returns for one. [User.nameOrId] falls back to // the user id, so this is close to unreachable. - final senderName = sender.name.trim(); + final senderName = sender.nameOrId.trim(); if (senderName.isEmpty) return body; return switch (isDeleted) { diff --git a/packages/stream_chat_flutter/lib/src/message_widget/stream_quoted_message.dart b/packages/stream_chat_flutter/lib/src/message_widget/stream_quoted_message.dart index 4f19937a89..b5413b00b4 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/stream_quoted_message.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/stream_quoted_message.dart @@ -160,7 +160,7 @@ class DefaultStreamQuotedMessage extends StatelessWidget { if (isCurrentUser(replier)) { if (repliedToOwnMessage) return a11y.outgoingReplyToOwnMessageLabel; - final authorName = quotedAuthor.name.trim(); + final authorName = quotedAuthor.nameOrId.trim(); if (authorName.isEmpty) return null; return a11y.outgoingReplyToMessageLabel(authorName: authorName); @@ -168,14 +168,14 @@ class DefaultStreamQuotedMessage extends StatelessWidget { // With no name there is nothing to attribute the reply to, so the preview // falls back to announcing the author's name as shown. - final replierName = replier.name.trim(); + final replierName = replier.nameOrId.trim(); if (replierName.isEmpty) return null; if (repliedToOwnMessage) { return a11y.incomingReplyToOwnMessageLabel(replierName: replierName); } - final authorName = quotedAuthor.name.trim(); + final authorName = quotedAuthor.nameOrId.trim(); if (authorName.isEmpty) return null; return a11y.incomingReplyToMessageLabel(replierName: replierName, authorName: authorName); @@ -217,7 +217,7 @@ class DefaultStreamQuotedMessage extends StatelessWidget { // whom instead turns the preview into a sentence; it merges with the // body preview below into one phrase. child: Text( - quotedMessage.user?.name ?? '', + quotedMessage.user?.nameOrId ?? '', semanticsLabel: _semanticsLabel(context), ), ); diff --git a/packages/stream_chat_flutter/lib/src/poll/stream_poll_comments_sheet.dart b/packages/stream_chat_flutter/lib/src/poll/stream_poll_comments_sheet.dart index 8e739ddd0e..ddb1e1263c 100644 --- a/packages/stream_chat_flutter/lib/src/poll/stream_poll_comments_sheet.dart +++ b/packages/stream_chat_flutter/lib/src/poll/stream_poll_comments_sheet.dart @@ -255,7 +255,7 @@ class _PollCommentCard extends StatelessWidget { ), Flexible( child: Text( - user.name, + user.nameOrId, maxLines: 1, overflow: TextOverflow.ellipsis, style: textTheme.captionEmphasis.copyWith(color: colorScheme.textPrimary), diff --git a/packages/stream_chat_flutter/lib/src/reactions/detail/reaction_detail_sheet.dart b/packages/stream_chat_flutter/lib/src/reactions/detail/reaction_detail_sheet.dart index a397d04435..920d2c6a48 100644 --- a/packages/stream_chat_flutter/lib/src/reactions/detail/reaction_detail_sheet.dart +++ b/packages/stream_chat_flutter/lib/src/reactions/detail/reaction_detail_sheet.dart @@ -209,7 +209,7 @@ class _ReactionDetailSheetState extends State { return StreamListTile( leading: StreamUserAvatar(size: .md, user: user, showOnlineIndicator: false), - title: Text(user.name), + title: Text(user.nameOrId), subtitle: isOwnReaction ? Text(context.translations.tapToRemoveReactionLabel) : null, trailing: StreamEmoji(size: .md, emoji: resolver.resolve(reaction.type)), onTap: switch (isOwnReaction) { diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_list_tile.dart index 22ed18a10c..f22cd00212 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_list_tile.dart @@ -178,7 +178,7 @@ class MessageSearchListTileTitle extends StatelessWidget { TextSpan( children: [ TextSpan( - text: user.id == StreamChat.of(context).currentUser?.id ? context.translations.youText : user.name, + text: user.id == StreamChat.of(context).currentUser?.id ? context.translations.youText : user.nameOrId, ), if (channelName != null) ...[ TextSpan( diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_tile.dart index 692e687d56..21084a6d57 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_tile.dart @@ -99,7 +99,7 @@ class StreamPollVoteListTile extends StatelessWidget { ), Expanded( child: Text( - user.name, + user.nameOrId, maxLines: 1, overflow: TextOverflow.ellipsis, style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart index 150b21d9a3..75619d7acd 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart @@ -102,7 +102,7 @@ class _DefaultStreamThreadListTile extends StatelessWidget { final latestActivityAt = thread.lastMessageAt ?? latestReply?.createdAt ?? thread.updatedAt; final avatarUser = parentMessage?.user ?? latestReply?.user ?? thread.createdBy; final channelName = - channel?.formatName(currentUser: currentUser) ?? avatarUser?.name ?? context.translations.noTitleText; + channel?.formatName(currentUser: currentUser) ?? avatarUser?.nameOrId ?? context.translations.noTitleText; final participantUsers = thread.threadParticipants.map((it) => it.user).nonNulls.toList(growable: false); final listItem = Padding( diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_grid_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_grid_tile.dart index 2b39d00b25..9ea2db8dd8 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_grid_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_grid_tile.dart @@ -63,7 +63,7 @@ class StreamUserGridTile extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Text( - user.name, + user.nameOrId, textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_list_tile.dart index 5eaeabd296..9eb14fd1e4 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_list_tile.dart @@ -129,7 +129,7 @@ class StreamUserListTile extends StatelessWidget { final title = this.title ?? Text( - user.name, + user.nameOrId, style: context.streamTextTheme.bodyEmphasis, ); diff --git a/packages/stream_chat_flutter/lib/src/user/user_mention_tile.dart b/packages/stream_chat_flutter/lib/src/user/user_mention_tile.dart index bb8b3a25e7..f2ee6ae985 100644 --- a/packages/stream_chat_flutter/lib/src/user/user_mention_tile.dart +++ b/packages/stream_chat_flutter/lib/src/user/user_mention_tile.dart @@ -54,7 +54,7 @@ class StreamUserMentionTile extends StatelessWidget { children: [ title ?? Text( - user.name, + user.nameOrId, maxLines: 1, overflow: TextOverflow.ellipsis, style: context.streamTextTheme.bodyEmphasis, diff --git a/packages/stream_chat_flutter/lib/src/utils/extensions.dart b/packages/stream_chat_flutter/lib/src/utils/extensions.dart index ac8ecec3c7..1deaab0667 100644 --- a/packages/stream_chat_flutter/lib/src/utils/extensions.dart +++ b/packages/stream_chat_flutter/lib/src/utils/extensions.dart @@ -306,7 +306,7 @@ extension UserListX on List { for (final user in this) { final normalizedId = normalize(user.id); - final normalizedUserName = normalize(user.name); + final normalizedUserName = normalize(user.nameOrId); final lDistance = normalizedUserName.levenshteinDistance(normalizedQuery); final containsId = normalizedId.contains(normalizedQuery); final containsName = normalizedUserName.contains(normalizedQuery); @@ -318,7 +318,7 @@ extension UserListX on List { final entries = matchingUsers.entries.toList(growable: false) ..sort((prev, curr) { bool containsQuery(User user) => - normalize(user.id).contains(normalizedQuery) || normalize(user.name).contains(normalizedQuery); + normalize(user.id).contains(normalizedQuery) || normalize(user.nameOrId).contains(normalizedQuery); final containsInPrev = containsQuery(prev.key); final containsInCurr = containsQuery(curr.key); @@ -397,10 +397,10 @@ extension MessageX on Message { // before enhanced mentions existed) so existing consumer code stays // compatible. for (final user in mentionedUsers.toSet()) { - final replacement = wrap(user.name, .user, user.id); + final replacement = wrap(user.nameOrId, .user, user.id); current = applyReplace(current, user.id, replacement); - if (user.name != user.id) { - current = applyReplace(current, user.name, replacement); + if (user.nameOrId != user.id) { + current = applyReplace(current, user.nameOrId, replacement); } } @@ -483,7 +483,7 @@ extension MessageX on Message { if (messageTextToSend == null) return this; for (final user in mentionedUsers.toSet()) { - final userName = user.name; + final userName = user.nameOrId; messageTextToSend = messageTextToSend!.replaceAll( '@$userName', '@${user.id}', @@ -696,12 +696,12 @@ extension ChannelModelX on ChannelModel { final otherMembers = members.where((it) => it.userId != currentUser?.id); // If there are no other members, we return the name of the current user. - if (otherMembers.isEmpty) return currentUser?.name; + if (otherMembers.isEmpty) return currentUser?.nameOrId; // Otherwise, we return the names of the first `maxMembers` members sorted // alphabetically, followed by the number of remaining members if there are // more than `maxMembers` members. - final memberNames = otherMembers.map((it) => it.user?.name).whereType().take(maxMembers).sorted(); + final memberNames = otherMembers.map((it) => it.user?.nameOrId).whereType().take(maxMembers).sorted(); return switch (otherMembers.length <= maxMembers) { true => memberNames.join(', '), diff --git a/packages/stream_chat_flutter/lib/src/utils/helpers.dart b/packages/stream_chat_flutter/lib/src/utils/helpers.dart index 9dbae68463..7a83477eef 100644 --- a/packages/stream_chat_flutter/lib/src/utils/helpers.dart +++ b/packages/stream_chat_flutter/lib/src/utils/helpers.dart @@ -211,7 +211,7 @@ Future showInfoBottomSheet( } /// Get random png with initials -String getRandomPicUrl(User user) => 'https://getstream.io/random_png/?id=${user.id}&name=${user.name}'; +String getRandomPicUrl(User user) => 'https://getstream.io/random_png/?id=${user.id}&name=${user.nameOrId}'; /// Get websiteName from [hostName] String? getWebsiteName(String hostName) { diff --git a/packages/stream_chat_flutter/lib/src/utils/message_preview_formatter.dart b/packages/stream_chat_flutter/lib/src/utils/message_preview_formatter.dart index 61ba45237d..f3b8f32629 100644 --- a/packages/stream_chat_flutter/lib/src/utils/message_preview_formatter.dart +++ b/packages/stream_chat_flutter/lib/src/utils/message_preview_formatter.dart @@ -407,7 +407,7 @@ class StreamMessagePreviewFormatter implements AccessibleMessagePreviewFormatter User? messageAuthor, TextSpan messageBody, ) { - final authorName = messageAuthor?.name.trim().nullIfEmpty; + final authorName = messageAuthor?.nameOrId.trim().nullIfEmpty; if (authorName == null) return formatDirectMessage(context, messageBody); // Use only the first name to keep the prefix compact in narrow @@ -575,7 +575,7 @@ class StreamMessagePreviewFormatter implements AccessibleMessagePreviewFormatter // members share a first name. When the sender has no derivable name, // skip the prefix entirely — a bare "Message, ..." token carries no // information. - final authorName = message.user?.name.trim().nullIfEmpty; + final authorName = message.user?.nameOrId.trim().nullIfEmpty; if (authorName == null) return body; return '${a11y.incomingMessagePreviewLabel(senderName: authorName)}\n$body'; @@ -798,8 +798,8 @@ WidgetSpan _iconSpan( TextSpan _applyMentions(TextSpan span, List mentionedUsers) { if (mentionedUsers.isEmpty) return span; - final regex = RegExp(mentionedUsers.map((it) => '@${RegExp.escape(it.name)}').join('|')); - bool isMention(String s) => mentionedUsers.any((it) => '@${it.name}' == s); + final regex = RegExp(mentionedUsers.map((it) => '@${RegExp.escape(it.nameOrId)}').join('|')); + bool isMention(String s) => mentionedUsers.any((it) => '@${it.nameOrId}' == s); InlineSpan visit(InlineSpan node) { if (node is! TextSpan) return node; diff --git a/packages/stream_chat_flutter/test/src/avatars/user_avatar_test.dart b/packages/stream_chat_flutter/test/src/avatars/user_avatar_test.dart index d3df563511..b4945d0776 100644 --- a/packages/stream_chat_flutter/test/src/avatars/user_avatar_test.dart +++ b/packages/stream_chat_flutter/test/src/avatars/user_avatar_test.dart @@ -198,4 +198,28 @@ void main() { }); } }); + + testWidgets('a user with no name shows the placeholder icon rather than initials of their id', (tester) async { + final user = MockUser(); + when(() => user.id).thenReturn('ada'); + when(() => user.name).thenReturn(null); + when(() => user.online).thenReturn(false); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + themeData: StreamChatThemeData(), + child: Scaffold( + body: Center( + child: StreamUserAvatar(user: user, size: .lg), + ), + ), + ), + ), + ); + + expect(find.byType(Icon), findsOneWidget); + expect(find.text('A'), findsNothing); + }); } diff --git a/packages/stream_chat_flutter_core/example/lib/main.dart b/packages/stream_chat_flutter_core/example/lib/main.dart index f924485b07..eedcb5b2fd 100644 --- a/packages/stream_chat_flutter_core/example/lib/main.dart +++ b/packages/stream_chat_flutter_core/example/lib/main.dart @@ -222,7 +222,7 @@ class _MessageScreenState extends State { stream: channel.state?.typingEventsStream.map((it) => it.keys), builder: (context, snapshot) { if (snapshot.hasData && snapshot.data!.isNotEmpty) { - return Text('${snapshot.data!.first.name} is typing...'); + return Text('${snapshot.data!.first.nameOrId} is typing...'); } return const SizedBox(); }, diff --git a/packages/stream_chat_localizations/CHANGELOG.md b/packages/stream_chat_localizations/CHANGELOG.md index 7c9d3c3bcc..a77a443626 100644 --- a/packages/stream_chat_localizations/CHANGELOG.md +++ b/packages/stream_chat_localizations/CHANGELOG.md @@ -1,3 +1,9 @@ +## Upcoming Beta + +🔄 Changed + +- The typing and pinned-by texts still name a user with no name by their id. + ## 10.5.0 ✅ Added diff --git a/packages/stream_chat_localizations/example/lib/add_new_lang.dart b/packages/stream_chat_localizations/example/lib/add_new_lang.dart index 8f53eea23d..d0dae8d9a3 100644 --- a/packages/stream_chat_localizations/example/lib/add_new_lang.dart +++ b/packages/stream_chat_localizations/example/lib/add_new_lang.dart @@ -56,9 +56,9 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { if (users.isEmpty) return ''; final first = users.first; if (users.length == 1) { - return '${first.name} is typing'; + return '${first.nameOrId} is typing'; } - return '${first.name} and ${users.length - 1} more are typing'; + return '${first.nameOrId} and ${users.length - 1} more are typing'; } @override @@ -86,7 +86,7 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { }) { final pinnedByCurrentUser = currentUser.id == pinnedBy.id; if (pinnedByCurrentUser) return 'Pinned by You'; - return 'Pinned by ${pinnedBy.name}'; + return 'Pinned by ${pinnedBy.nameOrId}'; } @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart index ca60284977..cf3b5f63b5 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart @@ -36,9 +36,9 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { if (users.isEmpty) return ''; final first = users.first; if (users.length == 1) { - return '${first.name} està escrivint'; + return '${first.nameOrId} està escrivint'; } - return '${first.name} y ${users.length - 1} estan escrivint'; + return '${first.nameOrId} y ${users.length - 1} estan escrivint'; } @override @@ -66,7 +66,7 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { }) { final pinnedByCurrentUser = currentUser.id == pinnedBy.id; if (pinnedByCurrentUser) return 'Fixat per tu'; - return 'Fixat per ${pinnedBy.name}'; + return 'Fixat per ${pinnedBy.nameOrId}'; } @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart index 9227465dd3..1e2f3164b4 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart @@ -36,9 +36,9 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { if (users.isEmpty) return ''; final first = users.first; if (users.length == 1) { - return '${first.name} tippt'; + return '${first.nameOrId} tippt'; } - return '${first.name} und ${users.length - 1} weitere tippen'; + return '${first.nameOrId} und ${users.length - 1} weitere tippen'; } @override @@ -66,7 +66,7 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { }) { final pinnedByCurrentUser = currentUser.id == pinnedBy.id; if (pinnedByCurrentUser) return 'Angeheftet von Dir'; - return 'Angeheftet von ${pinnedBy.name}'; + return 'Angeheftet von ${pinnedBy.nameOrId}'; } @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart index ef6b748fe1..3b6f8b9e94 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart @@ -36,9 +36,9 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { if (users.isEmpty) return ''; final first = users.first; if (users.length == 1) { - return '${first.name} is typing'; + return '${first.nameOrId} is typing'; } - return '${first.name} and ${users.length - 1} more are typing'; + return '${first.nameOrId} and ${users.length - 1} more are typing'; } @override @@ -66,7 +66,7 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { }) { final pinnedByCurrentUser = currentUser.id == pinnedBy.id; if (pinnedByCurrentUser) return 'Pinned by You'; - return 'Pinned by ${pinnedBy.name}'; + return 'Pinned by ${pinnedBy.nameOrId}'; } @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart index 903185e922..21dba0a058 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart @@ -36,9 +36,9 @@ class StreamChatLocalizationsEs extends GlobalStreamChatLocalizations { if (users.isEmpty) return ''; final first = users.first; if (users.length == 1) { - return '${first.name} está escribiendo'; + return '${first.nameOrId} está escribiendo'; } - return '${first.name} y ${users.length - 1} están escribiendo'; + return '${first.nameOrId} y ${users.length - 1} están escribiendo'; } @override @@ -66,7 +66,7 @@ class StreamChatLocalizationsEs extends GlobalStreamChatLocalizations { }) { final pinnedByCurrentUser = currentUser.id == pinnedBy.id; if (pinnedByCurrentUser) return 'Fijado por ti'; - return 'Fijado por ${pinnedBy.name}'; + return 'Fijado por ${pinnedBy.nameOrId}'; } @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart index da295653a5..3ba8236828 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart @@ -36,9 +36,9 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations { if (users.isEmpty) return ''; final first = users.first; if (users.length == 1) { - return "${first.name} est en train d'écrire"; + return "${first.nameOrId} est en train d'écrire"; } - return "${first.name} et ${users.length - 1} sont entrain d'écrire"; + return "${first.nameOrId} et ${users.length - 1} sont entrain d'écrire"; } @override @@ -66,7 +66,7 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations { }) { final pinnedByCurrentUser = currentUser.id == pinnedBy.id; if (pinnedByCurrentUser) return 'Épinglé par vous'; - return 'Épinglé par ${pinnedBy.name}'; + return 'Épinglé par ${pinnedBy.nameOrId}'; } @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart index 2df8413926..a987ef0323 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart @@ -36,9 +36,9 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { if (users.isEmpty) return ''; final first = users.first; if (users.length == 1) { - return '${first.name} टाइप कर रहा है'; + return '${first.nameOrId} टाइप कर रहा है'; } - return '${first.name} और ${users.length - 1} और टाइप कर रहे हैं'; + return '${first.nameOrId} और ${users.length - 1} और टाइप कर रहे हैं'; } @override @@ -66,7 +66,7 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { }) { final pinnedByCurrentUser = currentUser.id == pinnedBy.id; if (pinnedByCurrentUser) return 'आपके द्वारा पिन किया गया'; - return '${pinnedBy.name} द्वारा पिन किया गया'; + return '${pinnedBy.nameOrId} द्वारा पिन किया गया'; } @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart index 3c34dc65bf..130a306fec 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart @@ -36,9 +36,9 @@ class StreamChatLocalizationsIt extends GlobalStreamChatLocalizations { if (users.isEmpty) return ''; final first = users.first; if (users.length == 1) { - return '${first.name} sta scrivendo'; + return '${first.nameOrId} sta scrivendo'; } - return '${first.name} e altri ${users.length - 1} stanno scrivendo'; + return '${first.nameOrId} e altri ${users.length - 1} stanno scrivendo'; } @override @@ -71,7 +71,7 @@ class StreamChatLocalizationsIt extends GlobalStreamChatLocalizations { }) { final pinnedByCurrentUser = currentUser.id == pinnedBy.id; if (pinnedByCurrentUser) return 'Messo in evidenza da te'; - return 'Messo in evidenza da ${pinnedBy.name}'; + return 'Messo in evidenza da ${pinnedBy.nameOrId}'; } @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart index cc260621f5..51f5ca1d58 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart @@ -34,9 +34,9 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { if (users.isEmpty) return ''; final first = users.first; if (users.length == 1) { - return '${first.name}が入力しています'; + return '${first.nameOrId}が入力しています'; } - return '${first.name}と${users.length - 1}人が入力しています'; + return '${first.nameOrId}と${users.length - 1}人が入力しています'; } @override @@ -64,7 +64,7 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { }) { final pinnedByCurrentUser = currentUser.id == pinnedBy.id; if (pinnedByCurrentUser) return 'あなたのピン'; - return '${pinnedBy.name}のピン'; + return '${pinnedBy.nameOrId}のピン'; } @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart index e40ec02369..3353ab1fe2 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart @@ -34,9 +34,9 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { if (users.isEmpty) return ''; final first = users.first; if (users.length == 1) { - return '${first.name} 타이핑중'; + return '${first.nameOrId} 타이핑중'; } - return '${first.name}하고 ${users.length - 1}명 타이핑중'; + return '${first.nameOrId}하고 ${users.length - 1}명 타이핑중'; } @override @@ -64,7 +64,7 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { }) { final pinnedByCurrentUser = currentUser.id == pinnedBy.id; if (pinnedByCurrentUser) return '당신의 핀'; - return '${pinnedBy.name}의 핀'; + return '${pinnedBy.nameOrId}의 핀'; } @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart index 3a648aa2d9..5b9d7bdbc5 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart @@ -36,9 +36,9 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { if (users.isEmpty) return ''; final first = users.first; if (users.length == 1) { - return '${first.name} skriver'; + return '${first.nameOrId} skriver'; } - return '${first.name} og ${users.length - 1} flere skriver'; + return '${first.nameOrId} og ${users.length - 1} flere skriver'; } @override @@ -66,7 +66,7 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { }) { final pinnedByCurrentUser = currentUser.id == pinnedBy.id; if (pinnedByCurrentUser) return 'Festet av deg'; - return 'Festet av ${pinnedBy.name}'; + return 'Festet av ${pinnedBy.nameOrId}'; } @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart index 1c87c1d6df..c0ad81ce97 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart @@ -36,9 +36,9 @@ class StreamChatLocalizationsPt extends GlobalStreamChatLocalizations { if (users.isEmpty) return ''; final first = users.first; if (users.length == 1) { - return '${first.name} está digitando'; + return '${first.nameOrId} está digitando'; } - return '${first.name} e ${users.length - 1} estão digitando'; + return '${first.nameOrId} e ${users.length - 1} estão digitando'; } @override @@ -66,7 +66,7 @@ class StreamChatLocalizationsPt extends GlobalStreamChatLocalizations { }) { final pinnedByCurrentUser = currentUser.id == pinnedBy.id; if (pinnedByCurrentUser) return 'Definido por você'; - return 'Definido por ${pinnedBy.name}'; + return 'Definido por ${pinnedBy.nameOrId}'; } @override diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md index 11b409321e..f60f928c90 100644 --- a/packages/stream_chat_persistence/CHANGELOG.md +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -13,6 +13,7 @@ - A cached poll with a voting visibility the SDK does not name is read back instead of failing. - A poll's custom data is stored in a `custom` column, and a poll option's under a `custom` key rather than beside the option's fields; the schema version is bumped for it, so the local cache is rebuilt once on upgrade. - A channel's and a member's custom data are stored in a `custom` column, and the fields it used to carry in columns of their own; the schema version is bumped for it, so the local cache is rebuilt once on upgrade. +- A user's custom data is stored in a `custom` column, and the fields it used to carry in columns of their own; the schema version is bumped for it, so the local cache is rebuilt once on upgrade. 🐞 Fixed diff --git a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart index 685ee6b015..d98ecdfcea 100644 --- a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart +++ b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart @@ -59,7 +59,7 @@ class DriftChatDatabase extends _$DriftChatDatabase { // Band the version by SDK major, and bump the offset whenever you change or // add a table definition. @override - int get schemaVersion => 1100 + 6; + int get schemaVersion => 1100 + 7; // Store DateTime as ISO-8601 text to preserve sub-second precision. @override diff --git a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart index ce1fb22ed2..ff77aefde0 100644 --- a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart +++ b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart @@ -9439,14 +9439,76 @@ class $UsersTable extends Users with TableInfo<$UsersTable, UserEntity> { type: DriftSqlType.int, requiredDuringInsert: false, ); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); @override - late final GeneratedColumnWithTypeConverter, String> extraData = GeneratedColumn( - 'extra_data', + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _imageMeta = const VerificationMeta('image'); + @override + late final GeneratedColumn image = GeneratedColumn( + 'image', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _deactivatedAtMeta = const VerificationMeta( + 'deactivatedAt', + ); + @override + late final GeneratedColumn deactivatedAt = GeneratedColumn( + 'deactivated_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _deletedAtMeta = const VerificationMeta( + 'deletedAt', + ); + @override + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _shadowBannedMeta = const VerificationMeta( + 'shadowBanned', + ); + @override + late final GeneratedColumn shadowBanned = GeneratedColumn( + 'shadow_banned', + aliasedName, + true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("shadow_banned" IN (0, 1))', + ), + ); + @override + late final GeneratedColumnWithTypeConverter, String> blockedUserIds = GeneratedColumn( + 'blocked_user_ids', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, - ).withConverter>($UsersTable.$converterextraData); + ).withConverter>($UsersTable.$converterblockedUserIds); + @override + late final GeneratedColumnWithTypeConverter, String> custom = GeneratedColumn( + 'custom', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter>($UsersTable.$convertercustom); @override List get $columns => [ id, @@ -9459,7 +9521,13 @@ class $UsersTable extends Users with TableInfo<$UsersTable, UserEntity> { banned, teamsRole, avgResponseTime, - extraData, + name, + image, + deactivatedAt, + deletedAt, + shadowBanned, + blockedUserIds, + custom, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -9529,6 +9597,42 @@ class $UsersTable extends Users with TableInfo<$UsersTable, UserEntity> { ), ); } + if (data.containsKey('name')) { + context.handle( + _nameMeta, + name.isAcceptableOrUnknown(data['name']!, _nameMeta), + ); + } + if (data.containsKey('image')) { + context.handle( + _imageMeta, + image.isAcceptableOrUnknown(data['image']!, _imageMeta), + ); + } + if (data.containsKey('deactivated_at')) { + context.handle( + _deactivatedAtMeta, + deactivatedAt.isAcceptableOrUnknown( + data['deactivated_at']!, + _deactivatedAtMeta, + ), + ); + } + if (data.containsKey('deleted_at')) { + context.handle( + _deletedAtMeta, + deletedAt.isAcceptableOrUnknown(data['deleted_at']!, _deletedAtMeta), + ); + } + if (data.containsKey('shadow_banned')) { + context.handle( + _shadowBannedMeta, + shadowBanned.isAcceptableOrUnknown( + data['shadow_banned']!, + _shadowBannedMeta, + ), + ); + } return context; } @@ -9580,10 +9684,36 @@ class $UsersTable extends Users with TableInfo<$UsersTable, UserEntity> { DriftSqlType.int, data['${effectivePrefix}avg_response_time'], ), - extraData: $UsersTable.$converterextraData.fromSql( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + ), + image: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}image'], + ), + deactivatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}deactivated_at'], + ), + deletedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}deleted_at'], + ), + shadowBanned: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}shadow_banned'], + ), + blockedUserIds: $UsersTable.$converterblockedUserIds.fromSql( attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}extra_data'], + data['${effectivePrefix}blocked_user_ids'], + )!, + ), + custom: $UsersTable.$convertercustom.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}custom'], )!, ), ); @@ -9598,7 +9728,8 @@ class $UsersTable extends Users with TableInfo<$UsersTable, UserEntity> { static TypeConverter?, String?> $converterteamsRolen = NullAwareTypeConverter.wrap( $converterteamsRole, ); - static TypeConverter, String> $converterextraData = MapConverter(); + static TypeConverter, String> $converterblockedUserIds = ListConverter(); + static TypeConverter, String> $convertercustom = MapConverter(); } class UserEntity extends DataClass implements Insertable { @@ -9634,8 +9765,26 @@ class UserEntity extends DataClass implements Insertable { /// The average response time for the user in seconds. final int? avgResponseTime; - /// Map of custom user extraData - final Map extraData; + /// The user's name. + final String? name; + + /// The URL of the user's image. + final String? image; + + /// The date at which the user was deactivated. + final DateTime? deactivatedAt; + + /// The date at which the user was deleted. + final DateTime? deletedAt; + + /// Whether the user is shadow banned. + final bool? shadowBanned; + + /// The ids of the users this user blocked. + final List blockedUserIds; + + /// Custom data attached to the user. + final Map custom; const UserEntity({ required this.id, this.role, @@ -9647,7 +9796,13 @@ class UserEntity extends DataClass implements Insertable { required this.banned, this.teamsRole, this.avgResponseTime, - required this.extraData, + this.name, + this.image, + this.deactivatedAt, + this.deletedAt, + this.shadowBanned, + required this.blockedUserIds, + required this.custom, }); @override Map toColumns(bool nullToAbsent) { @@ -9678,9 +9833,29 @@ class UserEntity extends DataClass implements Insertable { if (!nullToAbsent || avgResponseTime != null) { map['avg_response_time'] = Variable(avgResponseTime); } + if (!nullToAbsent || name != null) { + map['name'] = Variable(name); + } + if (!nullToAbsent || image != null) { + map['image'] = Variable(image); + } + if (!nullToAbsent || deactivatedAt != null) { + map['deactivated_at'] = Variable(deactivatedAt); + } + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = Variable(deletedAt); + } + if (!nullToAbsent || shadowBanned != null) { + map['shadow_banned'] = Variable(shadowBanned); + } { - map['extra_data'] = Variable( - $UsersTable.$converterextraData.toSql(extraData), + map['blocked_user_ids'] = Variable( + $UsersTable.$converterblockedUserIds.toSql(blockedUserIds), + ); + } + { + map['custom'] = Variable( + $UsersTable.$convertercustom.toSql(custom), ); } return map; @@ -9702,7 +9877,13 @@ class UserEntity extends DataClass implements Insertable { banned: serializer.fromJson(json['banned']), teamsRole: serializer.fromJson?>(json['teamsRole']), avgResponseTime: serializer.fromJson(json['avgResponseTime']), - extraData: serializer.fromJson>(json['extraData']), + name: serializer.fromJson(json['name']), + image: serializer.fromJson(json['image']), + deactivatedAt: serializer.fromJson(json['deactivatedAt']), + deletedAt: serializer.fromJson(json['deletedAt']), + shadowBanned: serializer.fromJson(json['shadowBanned']), + blockedUserIds: serializer.fromJson>(json['blockedUserIds']), + custom: serializer.fromJson>(json['custom']), ); } @override @@ -9719,7 +9900,13 @@ class UserEntity extends DataClass implements Insertable { 'banned': serializer.toJson(banned), 'teamsRole': serializer.toJson?>(teamsRole), 'avgResponseTime': serializer.toJson(avgResponseTime), - 'extraData': serializer.toJson>(extraData), + 'name': serializer.toJson(name), + 'image': serializer.toJson(image), + 'deactivatedAt': serializer.toJson(deactivatedAt), + 'deletedAt': serializer.toJson(deletedAt), + 'shadowBanned': serializer.toJson(shadowBanned), + 'blockedUserIds': serializer.toJson>(blockedUserIds), + 'custom': serializer.toJson>(custom), }; } @@ -9734,7 +9921,13 @@ class UserEntity extends DataClass implements Insertable { bool? banned, Value?> teamsRole = const Value.absent(), Value avgResponseTime = const Value.absent(), - Map? extraData, + Value name = const Value.absent(), + Value image = const Value.absent(), + Value deactivatedAt = const Value.absent(), + Value deletedAt = const Value.absent(), + Value shadowBanned = const Value.absent(), + List? blockedUserIds, + Map? custom, }) => UserEntity( id: id ?? this.id, role: role.present ? role.value : this.role, @@ -9746,7 +9939,13 @@ class UserEntity extends DataClass implements Insertable { banned: banned ?? this.banned, teamsRole: teamsRole.present ? teamsRole.value : this.teamsRole, avgResponseTime: avgResponseTime.present ? avgResponseTime.value : this.avgResponseTime, - extraData: extraData ?? this.extraData, + name: name.present ? name.value : this.name, + image: image.present ? image.value : this.image, + deactivatedAt: deactivatedAt.present ? deactivatedAt.value : this.deactivatedAt, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + shadowBanned: shadowBanned.present ? shadowBanned.value : this.shadowBanned, + blockedUserIds: blockedUserIds ?? this.blockedUserIds, + custom: custom ?? this.custom, ); UserEntity copyWithCompanion(UsersCompanion data) { return UserEntity( @@ -9760,7 +9959,13 @@ class UserEntity extends DataClass implements Insertable { banned: data.banned.present ? data.banned.value : this.banned, teamsRole: data.teamsRole.present ? data.teamsRole.value : this.teamsRole, avgResponseTime: data.avgResponseTime.present ? data.avgResponseTime.value : this.avgResponseTime, - extraData: data.extraData.present ? data.extraData.value : this.extraData, + name: data.name.present ? data.name.value : this.name, + image: data.image.present ? data.image.value : this.image, + deactivatedAt: data.deactivatedAt.present ? data.deactivatedAt.value : this.deactivatedAt, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, + shadowBanned: data.shadowBanned.present ? data.shadowBanned.value : this.shadowBanned, + blockedUserIds: data.blockedUserIds.present ? data.blockedUserIds.value : this.blockedUserIds, + custom: data.custom.present ? data.custom.value : this.custom, ); } @@ -9777,7 +9982,13 @@ class UserEntity extends DataClass implements Insertable { ..write('banned: $banned, ') ..write('teamsRole: $teamsRole, ') ..write('avgResponseTime: $avgResponseTime, ') - ..write('extraData: $extraData') + ..write('name: $name, ') + ..write('image: $image, ') + ..write('deactivatedAt: $deactivatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('shadowBanned: $shadowBanned, ') + ..write('blockedUserIds: $blockedUserIds, ') + ..write('custom: $custom') ..write(')')) .toString(); } @@ -9794,7 +10005,13 @@ class UserEntity extends DataClass implements Insertable { banned, teamsRole, avgResponseTime, - extraData, + name, + image, + deactivatedAt, + deletedAt, + shadowBanned, + blockedUserIds, + custom, ); @override bool operator ==(Object other) => @@ -9810,7 +10027,13 @@ class UserEntity extends DataClass implements Insertable { other.banned == this.banned && other.teamsRole == this.teamsRole && other.avgResponseTime == this.avgResponseTime && - other.extraData == this.extraData); + other.name == this.name && + other.image == this.image && + other.deactivatedAt == this.deactivatedAt && + other.deletedAt == this.deletedAt && + other.shadowBanned == this.shadowBanned && + other.blockedUserIds == this.blockedUserIds && + other.custom == this.custom); } class UsersCompanion extends UpdateCompanion { @@ -9824,7 +10047,13 @@ class UsersCompanion extends UpdateCompanion { final Value banned; final Value?> teamsRole; final Value avgResponseTime; - final Value> extraData; + final Value name; + final Value image; + final Value deactivatedAt; + final Value deletedAt; + final Value shadowBanned; + final Value> blockedUserIds; + final Value> custom; final Value rowid; const UsersCompanion({ this.id = const Value.absent(), @@ -9837,7 +10066,13 @@ class UsersCompanion extends UpdateCompanion { this.banned = const Value.absent(), this.teamsRole = const Value.absent(), this.avgResponseTime = const Value.absent(), - this.extraData = const Value.absent(), + this.name = const Value.absent(), + this.image = const Value.absent(), + this.deactivatedAt = const Value.absent(), + this.deletedAt = const Value.absent(), + this.shadowBanned = const Value.absent(), + this.blockedUserIds = const Value.absent(), + this.custom = const Value.absent(), this.rowid = const Value.absent(), }); UsersCompanion.insert({ @@ -9851,11 +10086,18 @@ class UsersCompanion extends UpdateCompanion { this.banned = const Value.absent(), this.teamsRole = const Value.absent(), this.avgResponseTime = const Value.absent(), - required Map extraData, + this.name = const Value.absent(), + this.image = const Value.absent(), + this.deactivatedAt = const Value.absent(), + this.deletedAt = const Value.absent(), + this.shadowBanned = const Value.absent(), + required List blockedUserIds, + required Map custom, this.rowid = const Value.absent(), }) : id = Value(id), - extraData = Value(extraData); - static Insertable custom({ + blockedUserIds = Value(blockedUserIds), + custom = Value(custom); + static Insertable createCustom({ Expression? id, Expression? role, Expression? language, @@ -9866,7 +10108,13 @@ class UsersCompanion extends UpdateCompanion { Expression? banned, Expression? teamsRole, Expression? avgResponseTime, - Expression? extraData, + Expression? name, + Expression? image, + Expression? deactivatedAt, + Expression? deletedAt, + Expression? shadowBanned, + Expression? blockedUserIds, + Expression? custom, Expression? rowid, }) { return RawValuesInsertable({ @@ -9880,7 +10128,13 @@ class UsersCompanion extends UpdateCompanion { if (banned != null) 'banned': banned, if (teamsRole != null) 'teams_role': teamsRole, if (avgResponseTime != null) 'avg_response_time': avgResponseTime, - if (extraData != null) 'extra_data': extraData, + if (name != null) 'name': name, + if (image != null) 'image': image, + if (deactivatedAt != null) 'deactivated_at': deactivatedAt, + if (deletedAt != null) 'deleted_at': deletedAt, + if (shadowBanned != null) 'shadow_banned': shadowBanned, + if (blockedUserIds != null) 'blocked_user_ids': blockedUserIds, + if (custom != null) 'custom': custom, if (rowid != null) 'rowid': rowid, }); } @@ -9896,7 +10150,13 @@ class UsersCompanion extends UpdateCompanion { Value? banned, Value?>? teamsRole, Value? avgResponseTime, - Value>? extraData, + Value? name, + Value? image, + Value? deactivatedAt, + Value? deletedAt, + Value? shadowBanned, + Value>? blockedUserIds, + Value>? custom, Value? rowid, }) { return UsersCompanion( @@ -9910,7 +10170,13 @@ class UsersCompanion extends UpdateCompanion { banned: banned ?? this.banned, teamsRole: teamsRole ?? this.teamsRole, avgResponseTime: avgResponseTime ?? this.avgResponseTime, - extraData: extraData ?? this.extraData, + name: name ?? this.name, + image: image ?? this.image, + deactivatedAt: deactivatedAt ?? this.deactivatedAt, + deletedAt: deletedAt ?? this.deletedAt, + shadowBanned: shadowBanned ?? this.shadowBanned, + blockedUserIds: blockedUserIds ?? this.blockedUserIds, + custom: custom ?? this.custom, rowid: rowid ?? this.rowid, ); } @@ -9950,9 +10216,29 @@ class UsersCompanion extends UpdateCompanion { if (avgResponseTime.present) { map['avg_response_time'] = Variable(avgResponseTime.value); } - if (extraData.present) { - map['extra_data'] = Variable( - $UsersTable.$converterextraData.toSql(extraData.value), + if (name.present) { + map['name'] = Variable(name.value); + } + if (image.present) { + map['image'] = Variable(image.value); + } + if (deactivatedAt.present) { + map['deactivated_at'] = Variable(deactivatedAt.value); + } + if (deletedAt.present) { + map['deleted_at'] = Variable(deletedAt.value); + } + if (shadowBanned.present) { + map['shadow_banned'] = Variable(shadowBanned.value); + } + if (blockedUserIds.present) { + map['blocked_user_ids'] = Variable( + $UsersTable.$converterblockedUserIds.toSql(blockedUserIds.value), + ); + } + if (custom.present) { + map['custom'] = Variable( + $UsersTable.$convertercustom.toSql(custom.value), ); } if (rowid.present) { @@ -9974,7 +10260,13 @@ class UsersCompanion extends UpdateCompanion { ..write('banned: $banned, ') ..write('teamsRole: $teamsRole, ') ..write('avgResponseTime: $avgResponseTime, ') - ..write('extraData: $extraData, ') + ..write('name: $name, ') + ..write('image: $image, ') + ..write('deactivatedAt: $deactivatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('shadowBanned: $shadowBanned, ') + ..write('blockedUserIds: $blockedUserIds, ') + ..write('custom: $custom, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -18627,7 +18919,13 @@ typedef $$UsersTableCreateCompanionBuilder = Value banned, Value?> teamsRole, Value avgResponseTime, - required Map extraData, + Value name, + Value image, + Value deactivatedAt, + Value deletedAt, + Value shadowBanned, + required List blockedUserIds, + required Map custom, Value rowid, }); typedef $$UsersTableUpdateCompanionBuilder = @@ -18642,7 +18940,13 @@ typedef $$UsersTableUpdateCompanionBuilder = Value banned, Value?> teamsRole, Value avgResponseTime, - Value> extraData, + Value name, + Value image, + Value deactivatedAt, + Value deletedAt, + Value shadowBanned, + Value> blockedUserIds, + Value> custom, Value rowid, }); @@ -18704,11 +19008,40 @@ class $$UsersTableFilterComposer extends Composer<_$DriftChatDatabase, $UsersTab builder: (column) => ColumnFilters(column), ); - ColumnWithTypeConverterFilters, Map, String> get extraData => - $composableBuilder( - column: $table.extraData, - builder: (column) => ColumnWithTypeConverterFilters(column), - ); + ColumnFilters get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get image => $composableBuilder( + column: $table.image, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get deactivatedAt => $composableBuilder( + column: $table.deactivatedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get deletedAt => $composableBuilder( + column: $table.deletedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get shadowBanned => $composableBuilder( + column: $table.shadowBanned, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters, List, String> get blockedUserIds => $composableBuilder( + column: $table.blockedUserIds, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters, Map, String> get custom => $composableBuilder( + column: $table.custom, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); } class $$UsersTableOrderingComposer extends Composer<_$DriftChatDatabase, $UsersTable> { @@ -18769,8 +19102,38 @@ class $$UsersTableOrderingComposer extends Composer<_$DriftChatDatabase, $UsersT builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get extraData => $composableBuilder( - column: $table.extraData, + ColumnOrderings get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get image => $composableBuilder( + column: $table.image, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get deactivatedAt => $composableBuilder( + column: $table.deactivatedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get deletedAt => $composableBuilder( + column: $table.deletedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get shadowBanned => $composableBuilder( + column: $table.shadowBanned, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get blockedUserIds => $composableBuilder( + column: $table.blockedUserIds, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get custom => $composableBuilder( + column: $table.custom, builder: (column) => ColumnOrderings(column), ); } @@ -18810,8 +19173,29 @@ class $$UsersTableAnnotationComposer extends Composer<_$DriftChatDatabase, $User builder: (column) => column, ); - GeneratedColumnWithTypeConverter, String> get extraData => - $composableBuilder(column: $table.extraData, builder: (column) => column); + GeneratedColumn get name => $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get image => $composableBuilder(column: $table.image, builder: (column) => column); + + GeneratedColumn get deactivatedAt => $composableBuilder( + column: $table.deactivatedAt, + builder: (column) => column, + ); + + GeneratedColumn get deletedAt => $composableBuilder(column: $table.deletedAt, builder: (column) => column); + + GeneratedColumn get shadowBanned => $composableBuilder( + column: $table.shadowBanned, + builder: (column) => column, + ); + + GeneratedColumnWithTypeConverter, String> get blockedUserIds => $composableBuilder( + column: $table.blockedUserIds, + builder: (column) => column, + ); + + GeneratedColumnWithTypeConverter, String> get custom => + $composableBuilder(column: $table.custom, builder: (column) => column); } class $$UsersTableTableManager @@ -18852,7 +19236,13 @@ class $$UsersTableTableManager Value banned = const Value.absent(), Value?> teamsRole = const Value.absent(), Value avgResponseTime = const Value.absent(), - Value> extraData = const Value.absent(), + Value name = const Value.absent(), + Value image = const Value.absent(), + Value deactivatedAt = const Value.absent(), + Value deletedAt = const Value.absent(), + Value shadowBanned = const Value.absent(), + Value> blockedUserIds = const Value.absent(), + Value> custom = const Value.absent(), Value rowid = const Value.absent(), }) => UsersCompanion( id: id, @@ -18865,7 +19255,13 @@ class $$UsersTableTableManager banned: banned, teamsRole: teamsRole, avgResponseTime: avgResponseTime, - extraData: extraData, + name: name, + image: image, + deactivatedAt: deactivatedAt, + deletedAt: deletedAt, + shadowBanned: shadowBanned, + blockedUserIds: blockedUserIds, + custom: custom, rowid: rowid, ), createCompanionCallback: @@ -18880,7 +19276,13 @@ class $$UsersTableTableManager Value banned = const Value.absent(), Value?> teamsRole = const Value.absent(), Value avgResponseTime = const Value.absent(), - required Map extraData, + Value name = const Value.absent(), + Value image = const Value.absent(), + Value deactivatedAt = const Value.absent(), + Value deletedAt = const Value.absent(), + Value shadowBanned = const Value.absent(), + required List blockedUserIds, + required Map custom, Value rowid = const Value.absent(), }) => UsersCompanion.insert( id: id, @@ -18893,7 +19295,13 @@ class $$UsersTableTableManager banned: banned, teamsRole: teamsRole, avgResponseTime: avgResponseTime, - extraData: extraData, + name: name, + image: image, + deactivatedAt: deactivatedAt, + deletedAt: deletedAt, + shadowBanned: shadowBanned, + blockedUserIds: blockedUserIds, + custom: custom, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/packages/stream_chat_persistence/lib/src/entity/users.dart b/packages/stream_chat_persistence/lib/src/entity/users.dart index e6a6da0ff0..0bbcc71d17 100644 --- a/packages/stream_chat_persistence/lib/src/entity/users.dart +++ b/packages/stream_chat_persistence/lib/src/entity/users.dart @@ -1,6 +1,6 @@ // coverage:ignore-file import 'package:drift/drift.dart'; -import '../converter/map_converter.dart'; +import '../converter/converter.dart'; /// Represents a [Users] table in [DriftChatDatabase]. @DataClassName('UserEntity') @@ -37,8 +37,26 @@ class Users extends Table { /// The average response time for the user in seconds. IntColumn get avgResponseTime => integer().nullable()(); - /// Map of custom user extraData - TextColumn get extraData => text().map(MapConverter())(); + /// The user's name. + TextColumn get name => text().nullable()(); + + /// The URL of the user's image. + TextColumn get image => text().nullable()(); + + /// The date at which the user was deactivated. + DateTimeColumn get deactivatedAt => dateTime().nullable()(); + + /// The date at which the user was deleted. + DateTimeColumn get deletedAt => dateTime().nullable()(); + + /// Whether the user is shadow banned. + BoolColumn get shadowBanned => boolean().nullable()(); + + /// The ids of the users this user blocked. + TextColumn get blockedUserIds => text().map(ListConverter())(); + + /// Custom data attached to the user. + TextColumn get custom => text().map(MapConverter())(); @override Set get primaryKey => {id}; diff --git a/packages/stream_chat_persistence/lib/src/mapper/user_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/user_mapper.dart index eda1c0f877..5ad107c6a4 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/user_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/user_mapper.dart @@ -11,11 +11,17 @@ extension UserEntityX on UserEntity { role: role, online: online, lastActive: lastActive, - extraData: extraData, + custom: custom, banned: banned, createdAt: createdAt, teamsRole: teamsRole, avgResponseTime: avgResponseTime, + name: name, + image: image, + deactivatedAt: deactivatedAt, + deletedAt: deletedAt, + shadowBanned: shadowBanned, + blockedUserIds: blockedUserIds, ); } @@ -33,6 +39,12 @@ extension UserX on User { banned: banned, teamsRole: teamsRole, avgResponseTime: avgResponseTime, - extraData: extraData, + custom: custom, + name: name, + image: image, + deactivatedAt: deactivatedAt, + deletedAt: deletedAt, + shadowBanned: shadowBanned, + blockedUserIds: blockedUserIds, ); } diff --git a/packages/stream_chat_persistence/test/src/mapper/user_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/user_mapper_test.dart index 78d1c1910b..0a18083685 100644 --- a/packages/stream_chat_persistence/test/src/mapper/user_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/user_mapper_test.dart @@ -20,7 +20,13 @@ void main() { banned: math.Random().nextBool(), teamsRole: const {'teamId': 'role', 'teamId2': 'role2'}, avgResponseTime: 120, - extraData: {'test_extra_data': 'extraData'}, + custom: {'test_extra_data': 'extraData'}, + name: 'Test User', + image: 'https://example.com/test.png', + deactivatedAt: DateTime.now(), + deletedAt: DateTime.now(), + shadowBanned: math.Random().nextBool(), + blockedUserIds: const ['blocked-user'], ); final user = entity.toUser(); expect(user, isA()); @@ -34,7 +40,13 @@ void main() { expect(user.banned, entity.banned); expect(user.teamsRole, entity.teamsRole); expect(user.avgResponseTime, entity.avgResponseTime); - expect(user.extraData, entity.extraData); + expect(user.custom, entity.custom); + expect(user.name, entity.name); + expect(user.image, entity.image); + expect(user.deactivatedAt, isSameDateAs(entity.deactivatedAt)); + expect(user.deletedAt, isSameDateAs(entity.deletedAt)); + expect(user.shadowBanned, entity.shadowBanned); + expect(user.blockedUserIds, entity.blockedUserIds); }); test('toEntity should map user into UserEntity', () { @@ -49,7 +61,13 @@ void main() { banned: math.Random().nextBool(), teamsRole: const {'teamId': 'role', 'teamId2': 'role2'}, avgResponseTime: 120, - extraData: const {'test_extra_data': 'extraData'}, + custom: const {'test_extra_data': 'extraData'}, + name: 'Test User', + image: 'https://example.com/test.png', + deactivatedAt: DateTime.now(), + deletedAt: DateTime.now(), + shadowBanned: math.Random().nextBool(), + blockedUserIds: const ['blocked-user'], ); final entity = user.toEntity(); expect(entity, isA()); @@ -63,6 +81,12 @@ void main() { expect(entity.banned, user.banned); expect(entity.teamsRole, user.teamsRole); expect(entity.avgResponseTime, user.avgResponseTime); - expect(entity.extraData, user.extraData); + expect(entity.custom, user.custom); + expect(entity.name, user.name); + expect(entity.image, user.image); + expect(entity.deactivatedAt, isSameDateAs(user.deactivatedAt)); + expect(entity.deletedAt, isSameDateAs(user.deletedAt)); + expect(entity.shadowBanned, user.shadowBanned); + expect(entity.blockedUserIds, user.blockedUserIds); }); } diff --git a/sample_app/benchmark/benchmark_page.dart b/sample_app/benchmark/benchmark_page.dart index c6c46bc77f..4d0f6667d3 100644 --- a/sample_app/benchmark/benchmark_page.dart +++ b/sample_app/benchmark/benchmark_page.dart @@ -209,7 +209,7 @@ class _BenchmarkPageState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; for (final user in _floodAuthors) { - final url = user.extraData['image']; + final url = user.image; if (url is String && url.isNotEmpty) { precacheImage(NetworkImage(url), context).ignore(); } @@ -452,7 +452,7 @@ class _BenchmarkPageState extends State { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: Text( - '${message.user?.name ?? '?'}: ${message.text ?? ''}', + '${message.user?.nameOrId ?? '?'}: ${message.text ?? ''}', maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -1741,46 +1741,33 @@ String _formatDeltaMb(int current, int baseline) { final _floodAuthors = [ // User( // id: 'sahil', - // extraData: const { - // 'name': 'Sahil Kumar', - // 'image': - // 'https://avatars.githubusercontent.com/u/25670178?s=400&u=30ded3784d8d2310c5748f263fd5e6433c119aa1&v=4', - // }, + // name: 'Sahil Kumar', + // image: 'https://avatars.githubusercontent.com/u/25670178?s=400&u=30ded3784d8d2310c5748f263fd5e6433c119aa1&v=4', // ), User( id: 'salvatore', - extraData: const { - 'name': 'Salvatore Giordano', - 'image': 'https://avatars.githubusercontent.com/u/20601437?s=460&u=3f66c22a7483980624804054ae7f357cf102c784&v=4', - }, + name: 'Salvatore Giordano', + image: 'https://avatars.githubusercontent.com/u/20601437?s=460&u=3f66c22a7483980624804054ae7f357cf102c784&v=4', ), User( id: 'thierry', - extraData: const { - 'name': 'Thierry Schellenbach', - 'image': 'https://avatars.githubusercontent.com/u/265409?s=400&u=2d0e3bb1820db992066196bff7b004f0eee8e28d&v=4', - }, + name: 'Thierry Schellenbach', + image: 'https://avatars.githubusercontent.com/u/265409?s=400&u=2d0e3bb1820db992066196bff7b004f0eee8e28d&v=4', ), User( id: 'tommaso', - extraData: const { - 'name': 'Tommaso Barbugli', - 'image': 'https://avatars.githubusercontent.com/u/88735?s=400&v=4', - }, + name: 'Tommaso Barbugli', + image: 'https://avatars.githubusercontent.com/u/88735?s=400&v=4', ), User( id: 'deven', - extraData: const { - 'name': 'Deven Joshi', - 'image': 'https://avatars.githubusercontent.com/u/26357843?s=400&u=0c61d890866e67bf69f58878be58915e9bfd39ee&v=4', - }, + name: 'Deven Joshi', + image: 'https://avatars.githubusercontent.com/u/26357843?s=400&u=0c61d890866e67bf69f58878be58915e9bfd39ee&v=4', ), User( id: 'neevash', - extraData: const { - 'name': 'Neevash Ramdial', - 'image': 'https://avatars.githubusercontent.com/u/25674767?s=400&u=1d7333baf7dd9d143db8bfcdb31a838b89cfff9c&v=4', - }, + name: 'Neevash Ramdial', + image: 'https://avatars.githubusercontent.com/u/25674767?s=400&u=1d7333baf7dd9d143db8bfcdb31a838b89cfff9c&v=4', ), ]; diff --git a/sample_app/lib/pages/advanced_options_page.dart b/sample_app/lib/pages/advanced_options_page.dart index 1a503e106e..4eb084dd77 100644 --- a/sample_app/lib/pages/advanced_options_page.dart +++ b/sample_app/lib/pages/advanced_options_page.dart @@ -84,9 +84,7 @@ class _AdvancedOptionsPageState extends State { apiKey: apiKey, user: User( id: userId, - extraData: { - 'name': username, - }, + name: username, ), token: userToken, baseUrl: baseUrl.isEmpty ? null : baseUrl, diff --git a/sample_app/lib/pages/channel_list_page.dart b/sample_app/lib/pages/channel_list_page.dart index 7a16cd8a88..edfb7c35a7 100644 --- a/sample_app/lib/pages/channel_list_page.dart +++ b/sample_app/lib/pages/channel_list_page.dart @@ -180,7 +180,7 @@ class LeftDrawer extends StatelessWidget { StreamUserAvatar(size: .xxl, user: user, showOnlineIndicator: false), SizedBox(height: spacing.sm), Text( - user.name, + user.nameOrId, style: textTheme.headingMd.copyWith(color: colorScheme.textPrimary), overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, diff --git a/sample_app/lib/pages/chat_info_screen.dart b/sample_app/lib/pages/chat_info_screen.dart index 8d581f39cc..16579b4008 100644 --- a/sample_app/lib/pages/chat_info_screen.dart +++ b/sample_app/lib/pages/chat_info_screen.dart @@ -91,7 +91,7 @@ class _ContactInfoHeader extends StatelessWidget { children: [ Flexible( child: Text( - user.name, + user.nameOrId, style: textTheme.headingLg.copyWith(color: colorScheme.textPrimary), overflow: TextOverflow.ellipsis, ), diff --git a/sample_app/lib/pages/choose_user_page.dart b/sample_app/lib/pages/choose_user_page.dart index 244e3b7265..201c0aef6c 100644 --- a/sample_app/lib/pages/choose_user_page.dart +++ b/sample_app/lib/pages/choose_user_page.dart @@ -109,7 +109,7 @@ class ChooseUserPage extends StatelessWidget { user: user, ), title: Text( - user.name, + user.nameOrId, style: context.streamTextTheme.bodyEmphasis, ), subtitle: Text( diff --git a/sample_app/lib/pages/group_chat_details_screen.dart b/sample_app/lib/pages/group_chat_details_screen.dart index 33384bd172..27ef56dcb9 100644 --- a/sample_app/lib/pages/group_chat_details_screen.dart +++ b/sample_app/lib/pages/group_chat_details_screen.dart @@ -193,7 +193,7 @@ class _GroupChatDetailsScreenState extends State { user: user, ), title: Text( - user.name, + user.nameOrId, style: const TextStyle(fontWeight: FontWeight.bold), ), contentPadding: const EdgeInsets.symmetric( diff --git a/sample_app/lib/pages/new_chat_screen.dart b/sample_app/lib/pages/new_chat_screen.dart index e771e7a2eb..edf2be882f 100644 --- a/sample_app/lib/pages/new_chat_screen.dart +++ b/sample_app/lib/pages/new_chat_screen.dart @@ -191,7 +191,7 @@ class _NewChatScreenState extends State { child: Padding( padding: const EdgeInsets.fromLTRB(8, 4, 12, 4), child: Text( - user.name, + user.nameOrId, maxLines: 1, style: TextStyle( color: context.streamColorScheme.textPrimary, diff --git a/sample_app/lib/pages/new_group_chat_screen.dart b/sample_app/lib/pages/new_group_chat_screen.dart index 33bfa771e9..aa20579897 100644 --- a/sample_app/lib/pages/new_group_chat_screen.dart +++ b/sample_app/lib/pages/new_group_chat_screen.dart @@ -164,7 +164,7 @@ class _NewGroupChatScreenState extends State { ), const SizedBox(height: 4), Text( - user.name.split(' ')[0], + user.nameOrId.split(' ')[0], style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 12, diff --git a/sample_app/lib/utils/app_config.dart b/sample_app/lib/utils/app_config.dart index 1b91d050c6..fd2f8de046 100644 --- a/sample_app/lib/utils/app_config.dart +++ b/sample_app/lib/utils/app_config.dart @@ -44,71 +44,52 @@ final defaultUsers = { User( id: 'salvatore', language: 'it', - extraData: const { - 'name': 'Salvatore Giordano', - 'image': - 'https://avatars.githubusercontent.com/u/20601437?s=460&u=3f66c22a7483980624804054ae7f357cf102c784&v=4', - }, + name: 'Salvatore Giordano', + image: 'https://avatars.githubusercontent.com/u/20601437?s=460&u=3f66c22a7483980624804054ae7f357cf102c784&v=4', ), 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoic2FoaWwifQ.WnIUoB5gR2kcAsFhiDvkiD6zdHXZ-VSU2aQWWkhsvfo': User( id: 'sahil', language: 'hi', - extraData: const { - 'name': 'Sahil Kumar', - 'image': 'https://avatars.githubusercontent.com/u/25670178?s=400&u=30ded3784d8d2310c5748f263fd5e6433c119aa1&v=4', - }, + name: 'Sahil Kumar', + image: 'https://avatars.githubusercontent.com/u/25670178?s=400&u=30ded3784d8d2310c5748f263fd5e6433c119aa1&v=4', ), 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiYmVuIn0.nAz2sNFGQwY7rl2Og2z3TGHUsdpnN53tOsUglJFvLmg': User( id: 'ben', language: 'en', - extraData: const { - 'name': 'Ben Golden', - 'image': 'https://avatars.githubusercontent.com/u/1581974?s=400&v=4', - }, + name: 'Ben Golden', + image: 'https://avatars.githubusercontent.com/u/1581974?s=400&v=4', ), 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidGhpZXJyeSJ9.lEq6TrZtHzjoNtf7HHRufUPyGo_pa8vg4_XhEBp4ckY': User( id: 'thierry', language: 'nl', - extraData: const { - 'name': 'Thierry Schellenbach', - 'image': 'https://avatars.githubusercontent.com/u/265409?s=400&u=2d0e3bb1820db992066196bff7b004f0eee8e28d&v=4', - }, + name: 'Thierry Schellenbach', + image: 'https://avatars.githubusercontent.com/u/265409?s=400&u=2d0e3bb1820db992066196bff7b004f0eee8e28d&v=4', ), 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidG9tbWFzbyJ9.GLSI0ESshERMo2WjUpysD709NEtn1zmGimUN2an7g9o': User( id: 'tommaso', language: 'it', - extraData: const { - 'name': 'Tommaso Barbugli', - 'image': 'https://avatars.githubusercontent.com/u/88735?s=400&v=4', - }, + name: 'Tommaso Barbugli', + image: 'https://avatars.githubusercontent.com/u/88735?s=400&v=4', ), // 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiZGV2ZW4ifQ.z3zI4PqJnNhc-1o-VKcmb6BnnQ0oxFNCRHwEulHqcWc': User( // id: 'deven', - // extraData: const { - // 'name': 'Deven Joshi', - // 'image': 'https://avatars.githubusercontent.com/u/26357843?s=400&u=0c61d890866e67bf69f58878be58915e9bfd39ee&v=4', - // }, + // name: 'Deven Joshi', + // image: 'https://avatars.githubusercontent.com/u/26357843?s=400&u=0c61d890866e67bf69f58878be58915e9bfd39ee&v=4', // ), 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoibmVldmFzaCJ9.3EdHegTxibrz3A9cTiKmpEyawwcCVB8FXnoFzr4eKvw': User( id: 'neevash', language: 'en', - extraData: const { - 'name': 'Neevash Ramdial', - 'image': 'https://avatars.githubusercontent.com/u/25674767?s=400&u=1d7333baf7dd9d143db8bfcdb31a838b89cfff9c&v=4', - }, + name: 'Neevash Ramdial', + image: 'https://avatars.githubusercontent.com/u/25674767?s=400&u=1d7333baf7dd9d143db8bfcdb31a838b89cfff9c&v=4', ), 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoicWF0ZXN0MSJ9.fnelU7HcP7QoEEsCGteNlF1fppofzNlrnpDQuIgeKCU': User( id: 'qatest1', language: 'es', - extraData: const { - 'name': 'QA test 1', - }, + name: 'QA test 1', ), 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoicWF0ZXN0MiJ9.vSCqAEbs2WVmMWsOsa7065Fsjq-rsTih6qsHPynl7XM': User( id: 'qatest2', language: 'ja', - extraData: const { - 'name': 'QA test 2', - }, + name: 'QA test 2', ), }; diff --git a/sample_app/lib/widgets/add_members_sheet.dart b/sample_app/lib/widgets/add_members_sheet.dart index c3924682c7..606b6b5ac7 100644 --- a/sample_app/lib/widgets/add_members_sheet.dart +++ b/sample_app/lib/widgets/add_members_sheet.dart @@ -198,7 +198,7 @@ class _UserRow extends StatelessWidget { ), child: StreamListTile( leading: StreamUserAvatar(user: user, size: .md), - title: Text(user.name), + title: Text(user.nameOrId), trailing: StreamCheckbox.circular( value: selected, onChanged: onTap == null ? null : (_) => onTap!(), diff --git a/sample_app/lib/widgets/all_members_sheet.dart b/sample_app/lib/widgets/all_members_sheet.dart index e9dc7cba23..7e26e9c22d 100644 --- a/sample_app/lib/widgets/all_members_sheet.dart +++ b/sample_app/lib/widgets/all_members_sheet.dart @@ -269,7 +269,7 @@ class _ContactDetailHeader extends StatelessWidget { spacing: spacing.xxs, children: [ Text( - user.name, + user.nameOrId, style: textTheme.headingSm.copyWith(color: colorScheme.textPrimary), overflow: TextOverflow.ellipsis, ), @@ -353,7 +353,7 @@ class ChannelMemberTile extends StatelessWidget { final user = member.user; if (user == null) return const SizedBox.shrink(); - final name = isCurrentUser ? 'You' : user.name; + final name = isCurrentUser ? 'You' : user.nameOrId; final isAdmin = const {'admin', 'channel_moderator', 'owner'}.contains(member.channelRole); return StreamListTile( diff --git a/sample_app/lib/widgets/message_info_sheet.dart b/sample_app/lib/widgets/message_info_sheet.dart index 0768f26fda..12813ff77b 100644 --- a/sample_app/lib/widgets/message_info_sheet.dart +++ b/sample_app/lib/widgets/message_info_sheet.dart @@ -255,7 +255,7 @@ class _UserReadTile extends StatelessWidget { // User name Expanded( child: Text( - read.user.name, + read.user.nameOrId, style: context.streamTextTheme.bodyEmphasis, maxLines: 1, overflow: TextOverflow.ellipsis, From 911c40ae213722bf14d239940097e438e9bee45b Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 16 Oct 2026 15:04:56 +0200 Subject: [PATCH 4/9] refactor(llc): apply the review fixes to the user updates migration Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 17 ++- openapi-migration/09-users.md | 2 + openapi-migration/20-user-updates.md | 15 ++- openapi-migration/tool/generate_plan.py | 30 +++-- packages/stream_chat/CHANGELOG.md | 2 +- .../stream_chat/lib/src/client/client.dart | 14 ++- .../lib/src/core/models/own_user.dart | 47 +++++--- .../request/update_user_partial_request.dart | 4 +- .../response/update_users_response.dart | 8 +- .../stream_chat/lib/src/core/models/user.dart | 23 +--- .../src/repository/mapper/users_mapper.dart | 6 +- .../lib/src/repository/users_repository.dart | 6 +- .../stream_chat/lib/src/ws/events/event.dart | 1 + .../client_connect_guest_user_test.dart | 62 ---------- .../test/src/client/client_test.dart | 43 ------- .../client_update_users_partial_test.dart | 74 +----------- .../src/client/client_update_users_test.dart | 106 +----------------- .../src/client/client_user_updated_test.dart | 60 ++++++++++ .../test/src/client/users_fixtures.dart | 69 ++++++++++++ .../test/src/core/models/own_user_test.dart | 26 ++++- .../test/src/core/models/user_test.dart | 61 +--------- .../stream_chat_localizations/CHANGELOG.md | 6 - 22 files changed, 266 insertions(+), 416 deletions(-) create mode 100644 packages/stream_chat/test/src/client/client_user_updated_test.dart create mode 100644 packages/stream_chat/test/src/client/users_fixtures.dart diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index c89deac03b..336f4fa9e1 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -330,7 +330,6 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `User.name` (`String`, the id when the user has none) | `User.nameOrId` | `retyped` | `User.name` is a `String?` that is null when the user has none. Use `nameOrId` wherever a user is named by text, and `name` where a missing name matters, such as initials | | `User.name` / `User.image` blank (`''`) | `null` | `retyped` | A blank name or image is stored as null | | `user.extraData['name']` / `['image']` / `['deactivated_at']` / `['deleted_at']` / `['shadow_banned']` | `User.name` / `image` / `deactivatedAt` / `deletedAt` / `shadowBanned` | `retyped` | Fields now, not custom data; `copyWith(custom:)` no longer sets them | -| `user.extraData['revoke_tokens_issued_before']` | — | `removed` | Not modelled on a user, and no longer kept in its custom data | | `OwnUser.blockedUserIds` | `User.blockedUserIds` | `moved` | Still on `OwnUser`. Empty for anyone but the current user | | `ownUser.extraData['unread_count']` / `['total_unread_count_by_team']` / `['latest_hidden_channels']` | `OwnUser.unreadCount` / `totalUnreadCountByTeam` / `latestHiddenChannels` | `added` | Fields now, not custom data | | `OwnUser.fromUser(user)` reading devices, mutes, unread counts, push preferences and privacy settings out of `user.extraData` | — | `removed` | A `User` no longer carries them; they are left at their defaults | @@ -974,10 +973,10 @@ final result = await client.updateUserPartial(userId, set: {'favorite_color': 'g 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. +**`updateUser` sends the user's name, image, language, visibility and custom data, and for an own user their privacy +settings, 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, hidden @@ -1067,13 +1066,13 @@ Text(user.nameOrId); A string interpolation still compiles with the nullable `name`, so check them by hand: `'${user.name} is typing'` prints `null is typing` for a user with no name. That includes a custom `Translations` or `StreamChatLocalizations` -subclass overriding `typingIndicatorText` or `pinnedByUserText`. +subclass overriding `userTypingText` or `pinnedByUserText`. **The user fields are fields, not custom data.** `User.image` reads the same and `name` changes as described above; `deactivatedAt`, `deletedAt` and `shadowBanned` are new fields, `blockedUserIds` moves up from `OwnUser` to `User`, -and `OwnUser.unreadCount`, `totalUnreadCountByTeam` and `latestHiddenChannels` are new fields. `custom` no longer holds any of their keys, nor -the ones only an own user carries, and an entry with one of those names in `custom` or in `copyWith(custom:)` no -longer sets the field: +and `OwnUser.unreadCount`, `totalUnreadCountByTeam` and `latestHiddenChannels` are new fields. `custom` no longer +holds any of their keys, and an entry with one of those names in `custom` or in `copyWith(custom:)` no longer sets +the field: ```dart // v10 diff --git a/openapi-migration/09-users.md b/openapi-migration/09-users.md index d7e039d3d1..1ec3fa171a 100644 --- a/openapi-migration/09-users.md +++ b/openapi-migration/09-users.md @@ -30,12 +30,14 @@ - `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. - `UserFilterField.bypassModeration` reads `custom`, which no generated user has a field to fill. +- **`User` and `OwnUser` move to freezed without changing the current user's merge rules.** freezed's `copyWith` clears a field passed `null` where today's keeps it, and it can't generate one for `OwnUser` while it takes `User`'s fields through super-parameters. `OwnUser.merge` builds through the constructor and states each field's rule (keep a nullable value the other user leaves out, except `deactivatedAt`, `deletedAt`, `shadowBanned`, `totalUnreadCountByTeam` and `latestHiddenChannels`, taken as they are, and `banExpires`, always kept); keep those rules in a dedicated merge, and run `copywith_audit.py` on both models for every other call site, such as the `user.updated` handler's preserve list. - `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. +- A user mapped from `UserResponse` and the same user decoded from v1 JSON compare unequal: the v1 decode keeps `invisible`, `shadowBanned` and `banExpires`, which `UserResponse` does not carry. - Landing `UserResponse` -> `User` unblocks the two fields [08](08-moderation-and-blocklists.md) had to drop from `MuteUsersResponse`: the `mutes` the call created and the `ownUser` it left behind. Adding them is additive for anyone reading the response, so revisit them here rather than leaving them dropped for good. ## Definition of done diff --git a/openapi-migration/20-user-updates.md b/openapi-migration/20-user-updates.md index dd799ca77e..24e1f4c28f 100644 --- a/openapi-migration/20-user-updates.md +++ b/openapi-migration/20-user-updates.md @@ -31,9 +31,9 @@ - **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 + in [04](04-roles-guest-and-app.md). An `OwnUser` decoded from the connection reads `unread_count`, + `total_unread_count_by_team` and `latest_hidden_channels` into fields, so they no longer reach the + request 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 @@ -52,15 +52,16 @@ (README rule 9), since these calls are the first to map a user. `User.name`, `image`, `deactivatedAt`, `deletedAt` and `shadowBanned` are fields with persistence columns; `blockedUserIds` moves up from `OwnUser`, and `OwnUser` gains `unreadCount`, - `totalUnreadCountByTeam` and `latestHiddenChannels`. `revoke_tokens_issued_before` is left out. + `totalUnreadCountByTeam` and `latestHiddenChannels`. The mappers leave `revoke_tokens_issued_before` out; a v1 + user keeps it in `custom`, as a v1 member keeps `role`. Each generated user mapper fills the ones its response carries (`UserResponse` has no `shadowBanned`). - **`User.name` is nullable and has no fallback to `id`;** `User.nameOrId` has it. A blank name or image is stored as null. The UI names a user by `nameOrId` everywhere except avatar initials, which show the placeholder icon for an unnamed user, and filters and sorts, which read the raw `name`. - **`custom` is the wire's, verbatim,** and the v1 writers leave out a custom entry named like a key, - including the keys only an own user carries, so it cannot overwrite the field. `OwnUser.fromUser` - copies the user's fields and no longer reads an own user's fields out of the custom data. + so it cannot overwrite the field. `OwnUser.fromUser` copies the user's fields and no longer reads an + own user's fields out of the custom data. - **`OwnUser.merge` takes the other user's fields,** as before; the `user.updated` handler also keeps the three new unread fields, which the event leaves out. A `user.updated` event's user is decoded again as an `OwnUser`, so the privacy settings it carries for the current user replace theirs, as in @@ -68,6 +69,8 @@ ## Risks +- A `try`/`catch` around `updateUser`, `updateUsers`, `updateUserPartial` or `updateUsersPartial` still compiles but no longer catches a failed call. +- A string interpolation of `User.name` still compiles now that it is nullable, and prints `null` for a user with no name; `nameOrId` gives the old output. ## Definition of done diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 6209b987f0..e911de1eab 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -590,6 +590,14 @@ def match(verb, path): '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.', '`UserFilterField.bypassModeration` reads `custom`, which no generated user has a field to fill.', + '**`User` and `OwnUser` move to freezed without changing the current user\'s merge rules.** freezed\'s ' + '`copyWith` clears a field passed `null` where today\'s keeps it, and it can\'t generate one for ' + '`OwnUser` while it takes `User`\'s fields through super-parameters. `OwnUser.merge` builds through the ' + 'constructor and states each field\'s rule (keep a nullable value the other user leaves out, except ' + '`deactivatedAt`, `deletedAt`, `shadowBanned`, `totalUnreadCountByTeam` and `latestHiddenChannels`, ' + 'taken as they are, and `banExpires`, always kept); ' + 'keep those rules in a dedicated merge, and run `copywith_audit.py` on both models for every other ' + 'call site, such as the `user.updated` handler\'s preserve list.', '`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.', @@ -597,6 +605,8 @@ def match(verb, path): risks=[ 'Every other group depends on the `User` decision.', 'User data arrives over the WebSocket on nearly every event.', + 'A user mapped from `UserResponse` and the same user decoded from v1 JSON compare unequal: the v1 ' + 'decode keeps `invisible`, `shadowBanned` and `banExpires`, which `UserResponse` does not carry.', '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 ' @@ -1068,9 +1078,9 @@ def match(verb, path): - **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 + in [04](04-roles-guest-and-app.md). An `OwnUser` decoded from the connection reads `unread_count`, + `total_unread_count_by_team` and `latest_hidden_channels` into fields, so they no longer reach the + request 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 @@ -1089,21 +1099,27 @@ def match(verb, path): (README rule 9), since these calls are the first to map a user. `User.name`, `image`, `deactivatedAt`, `deletedAt` and `shadowBanned` are fields with persistence columns; `blockedUserIds` moves up from `OwnUser`, and `OwnUser` gains `unreadCount`, - `totalUnreadCountByTeam` and `latestHiddenChannels`. `revoke_tokens_issued_before` is left out. + `totalUnreadCountByTeam` and `latestHiddenChannels`. The mappers leave `revoke_tokens_issued_before` out; a v1 + user keeps it in `custom`, as a v1 member keeps `role`. Each generated user mapper fills the ones its response carries (`UserResponse` has no `shadowBanned`). - **`User.name` is nullable and has no fallback to `id`;** `User.nameOrId` has it. A blank name or image is stored as null. The UI names a user by `nameOrId` everywhere except avatar initials, which show the placeholder icon for an unnamed user, and filters and sorts, which read the raw `name`. - **`custom` is the wire's, verbatim,** and the v1 writers leave out a custom entry named like a key, - including the keys only an own user carries, so it cannot overwrite the field. `OwnUser.fromUser` - copies the user's fields and no longer reads an own user's fields out of the custom data. + so it cannot overwrite the field. `OwnUser.fromUser` copies the user's fields and no longer reads an + own user's fields out of the custom data. - **`OwnUser.merge` takes the other user's fields,** as before; the `user.updated` handler also keeps the three new unread fields, which the event leaves out. A `user.updated` event's user is decoded again as an `OwnUser`, so the privacy settings it carries for the current user replace theirs, as in v10 — a temporary adapter (README) for the WebSocket v2 move to replace. """), - risks=[], + risks=[ + 'A `try`/`catch` around `updateUser`, `updateUsers`, `updateUserPartial` or `updateUsersPartial` still ' + 'compiles but no longer catches a failed call.', + 'A string interpolation of `User.name` still compiles now that it is nullable, and prints `null` for a ' + 'user with no name; `nameOrId` gives the old output.', + ], done=DONE.replace('- [ ]', '- [x]'), ), dict( diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 284618650b..72b7e0d104 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -134,7 +134,7 @@ - `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. - `User.extraData` is renamed `custom`, and so is the `extraData` argument of the `User` and `OwnUser` constructors and `copyWith`. - `User.name` is nullable and no longer answers the user's id when they have no name; read `User.nameOrId` for that. A blank name or image is stored as null. -- Several user values the custom data used to hold, such as `User.name`, `User.image` and `User.deactivatedAt`, are fields instead: `custom` no longer holds them or the keys only an own user carries, and an entry with one of their names in `custom` or in `copyWith(custom:)` no longer sets the field. +- Several user values the custom data used to hold, such as `User.name`, `User.image` and `User.deactivatedAt`, are fields instead: `custom` no longer holds them, and an entry with one of their names in `custom` or in `copyWith(custom:)` no longer sets the field. - `User.custom` holds the custom data exactly as the API returns it, and `toJson` leaves out an entry named like one of the user's own fields. - `OwnUser.fromUser` no longer reads devices, mutes, unread counts, push preferences or privacy settings out of the user's custom data; they are left at their defaults. diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index ddd50f23a1..fe6656c822 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -1796,24 +1796,26 @@ class StreamChatClient { String? prev, }) => _pollsRepository.queryPollVotes(pollId, filter: filter, sort: sort, limit: limit, next: next, prev: prev); - /// Creates [user], or replaces the data of the existing user with the same id. + /// 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]. + /// This cannot change the user's role, teams or team roles. To change only + /// some fields, consider [updateUserPartial]. Future> updateUser(User user) => updateUsers([user]); /// Creates or replaces each of [users], as [updateUser] does for one. Future> updateUsers(List users) => _usersRepository.updateUsers(users); - /// 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. + /// 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, }) => updateUsersPartial([UpdateUserPartialRequest(id: id, set: set, unset: unset)]); - /// Partially updates several users at once, applying each of [updates] to the user it names. + /// Partially updates several users at once, applying each of [updates] to the + /// user it names. Future> updateUsersPartial(List updates) => _usersRepository.updateUsersPartial(updates); 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 55f0cb93cc..eddce29129 100644 --- a/packages/stream_chat/lib/src/core/models/own_user.dart +++ b/packages/stream_chat/lib/src/core/models/own_user.dart @@ -151,19 +151,25 @@ class OwnUser extends User { /// Returns a new [OwnUser] that is a combination of this ownUser /// and the given [other] ownUser. + /// + /// A nullable value [other] leaves out keeps this user's, except + /// [deactivatedAt], [deletedAt], [shadowBanned], [totalUnreadCountByTeam] and + /// [latestHiddenChannels], which are taken from [other] as they are. + /// [banExpires] is always this user's. OwnUser merge(OwnUser? other) { if (other == null) return this; - return copyWith( + return OwnUser( id: other.id, - role: other.role, - name: other.name, - image: other.image, + role: other.role ?? role, + name: other.name ?? name, + image: other.image ?? image, banned: other.banned, + banExpires: banExpires, channelMutes: other.channelMutes, - createdAt: other.createdAt, + createdAt: other.createdAt ?? createdAt, devices: other.devices, custom: other.custom, - lastActive: other.lastActive, + lastActive: other.lastActive ?? lastActive, mutes: other.mutes, online: other.online, teams: other.teams, @@ -171,13 +177,13 @@ class OwnUser extends User { unreadChannels: other.unreadChannels, unreadThreads: other.unreadThreads, blockedUserIds: other.blockedUserIds, - updatedAt: other.updatedAt, - language: other.language, - invisible: other.invisible, - teamsRole: other.teamsRole, - avgResponseTime: other.avgResponseTime, - pushPreferences: other.pushPreferences, - privacySettings: other.privacySettings, + updatedAt: other.updatedAt ?? updatedAt, + language: other.language ?? language, + invisible: other.invisible ?? invisible, + teamsRole: other.teamsRole ?? teamsRole, + avgResponseTime: other.avgResponseTime ?? avgResponseTime, + pushPreferences: other.pushPreferences ?? pushPreferences, + privacySettings: other.privacySettings ?? privacySettings, deactivatedAt: other.deactivatedAt, deletedAt: other.deletedAt, shadowBanned: other.shadowBanned, @@ -236,7 +242,20 @@ class OwnUser extends User { /// The JSON keys [OwnUser.fromJson] reads into named fields, or leaves out; /// every other key is kept in [custom]. - static const topLevelFields = User.topLevelFields; + static const topLevelFields = [ + 'devices', + 'mutes', + 'channel_mutes', + 'total_unread_count', + 'unread_channels', + 'unread_threads', + 'push_preferences', + 'privacy_settings', + 'unread_count', + 'total_unread_count_by_team', + 'latest_hidden_channels', + ...User.topLevelFields, + ]; } /// Extension methods for [OwnUser] related to privacy settings. 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 index 5d953f7207..e0e130b31a 100644 --- 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 @@ -2,8 +2,8 @@ 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. +/// 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 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 index 6d7a9bfc31..5e94a88843 100644 --- 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 @@ -4,10 +4,10 @@ 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]. +/// The result of creating, updating or partially updating users, returned by +/// [StreamChatClient.updateUser], [StreamChatClient.updateUsers], +/// [StreamChatClient.updateUserPartial] and +/// [StreamChatClient.updateUsersPartial]. @freezed class UpdateUsersResponse with _$UpdateUsersResponse { /// Creates a new [UpdateUsersResponse]. diff --git a/packages/stream_chat/lib/src/core/models/user.dart b/packages/stream_chat/lib/src/core/models/user.dart index f375c402fe..7027633ac3 100644 --- a/packages/stream_chat/lib/src/core/models/user.dart +++ b/packages/stream_chat/lib/src/core/models/user.dart @@ -65,21 +65,6 @@ class User extends Equatable { 'deleted_at', 'shadow_banned', 'blocked_user_ids', - // Not modelled on a user; listed so it never lands in [custom]. - 'revoke_tokens_issued_before', - // Only an own user carries these ([OwnUser] reads them); a plain user - // leaves them out. - 'devices', - 'mutes', - 'channel_mutes', - 'total_unread_count', - 'unread_channels', - 'unread_threads', - 'unread_count', - 'total_unread_count_by_team', - 'latest_hidden_channels', - 'push_preferences', - 'privacy_settings', ]; /// User id. @@ -140,7 +125,10 @@ class User extends Equatable { /// The average response time of the user in seconds. final int? avgResponseTime; - /// This user's custom data: every key not in [topLevelFields]. + /// This user's custom data. + /// + /// It may hold a key named like one of the user's own fields; [toJson] leaves + /// such an entry out so it never overwrites the field. @JsonKey(name: 'extra_data') final Map custom; @@ -154,7 +142,8 @@ class User extends Equatable { /// Null while the user exists, or when not known. final DateTime? deletedAt; - /// Whether the user is shadow banned, so that their messages reach no one else without them being told. + /// Whether the user is shadow banned, so that their messages reach no one + /// else without them being told. /// /// Null when not known. final bool? shadowBanned; diff --git a/packages/stream_chat/lib/src/repository/mapper/users_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/users_mapper.dart index 888f98b9af..6df7a60edf 100644 --- a/packages/stream_chat/lib/src/repository/mapper/users_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/users_mapper.dart @@ -88,7 +88,8 @@ extension UserRequestMapper on User { /// Maps [PrivacySettings] to the generated [api.PrivacySettingsResponse]. /// -/// The generated client names the settings a request sends [api.PrivacySettingsResponse], too. +/// 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( @@ -212,7 +213,8 @@ extension UpdateUsersResponseMapper on api.UpdateUsersResponse { ); } -/// Maps an [UpdateUserPartialRequest] to the generated [api.UpdateUserPartialRequest]. +/// 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 a4d4d09cd8..2d86ad0eaf 100644 --- a/packages/stream_chat/lib/src/repository/users_repository.dart +++ b/packages/stream_chat/lib/src/repository/users_repository.dart @@ -41,7 +41,8 @@ class UsersRepository { return result.map((response) => response.toModel()); } - /// Creates or replaces each of [users], as [StreamChatClient.updateUser] does for one. + /// 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()}), @@ -50,7 +51,8 @@ class UsersRepository { return result.map((response) => response.toModel()); } - /// Partially updates several users at once, applying each of [updates] to the user it names. + /// 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()]), diff --git a/packages/stream_chat/lib/src/ws/events/event.dart b/packages/stream_chat/lib/src/ws/events/event.dart index 9b4f390430..6276a7a7d4 100644 --- a/packages/stream_chat/lib/src/ws/events/event.dart +++ b/packages/stream_chat/lib/src/ws/events/event.dart @@ -70,6 +70,7 @@ class Event extends WsEvent { ), ); + // TODO(openapi-migration): remove once WebSocket events decode v2 payloads. return switch ((event.type, json['user'])) { (EventType.userUpdated, final Map user) => event.copyWith(user: OwnUser.fromJson(user)), _ => event, 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 f99e3b03a3..8e5b14c84e 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 @@ -51,29 +51,6 @@ void main() { expect(guest.updatedAt, DateTime.utc(2026, 9, 2)); }); - test( - "StreamChatClient.connectGuestUser sends no custom data named like one of the user's own fields", - () async { - final defaultApi = _defaultApiAnswering(Result.success(_createGuestResponse())); - final client = _client(defaultApi); - final user = User.fromJson(const { - 'id': 'requested-id', - 'deleted_at': '2026-01-01T00:00:00Z', - 'devices': [], - 'color': 'blue', - }); - - await client.connectGuestUser(user, connectWebSocket: false); - - final request = - verify( - () => defaultApi.createGuest(createGuestRequest: captureAny(named: 'createGuestRequest')), - ).captured.single - as api.CreateGuestRequest; - expect(request.user.custom, {'color': 'blue'}); - }, - ); - test('StreamChatClient.connectGuestUser sends neither a name nor an image for a user without them', () async { final defaultApi = _defaultApiAnswering(Result.success(_createGuestResponse())); final client = _client(defaultApi); @@ -91,45 +68,6 @@ void main() { ); }); - test( - "StreamChatClient.connectGuestUser keeps custom fields named like the user's own fields apart from them", - () async { - final response = _createGuestResponse( - custom: const { - 'favorite_color': 'green', - 'name': 'custom-name', - 'online': 'custom-online', - 'blocked_user_ids': 'custom-blocked-user-ids', - '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', - }, - ); - final client = _client(_defaultApiAnswering(Result.success(response))); - - final guest = await client.connectGuestUser(_fullyPopulatedUser(), connectWebSocket: false); - - expect(guest.custom, { - 'favorite_color': 'green', - 'name': 'custom-name', - 'online': 'custom-online', - 'blocked_user_ids': 'custom-blocked-user-ids', - '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', - }); - expect(guest.name, 'Created Name'); - expect(guest.online, isTrue); - expect(guest.blockedUserIds, ['blocked-user']); - expect(guest.deactivatedAt, DateTime.utc(2026, 9, 4)); - expect(guest.deletedAt, DateTime.utc(2026, 9, 5)); - }, - ); - test('StreamChatClient.connectGuestUser connects the socket as the created guest', () async { final defaultApi = _defaultApiAnswering(Result.success(_createGuestResponse())); final client = _client( diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 85ba5d51d2..39b9692c84 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -1870,49 +1870,6 @@ void main() { expect(pushPreferences?.disabledUntil, pushPreference.disabledUntil); }); - test('a user.updated event for the current user replaces their privacy settings', () async { - client.state.currentUser = client.state.currentUser!.copyWith( - privacySettings: const PrivacySettings(typingIndicators: TypingIndicators(enabled: true)), - ); - - client.handleEvent( - Event.fromJson(const { - 'type': EventType.userUpdated, - 'user': { - 'id': userId, - 'privacy_settings': { - 'typing_indicators': {'enabled': false}, - }, - }, - }), - ); - await Future.delayed(Duration.zero); - - expect(client.state.currentUser!.privacySettings?.typingIndicators?.enabled, isFalse); - }); - - test('a user.updated event for the current user keeps the fields only an own user has', () async { - client.state.currentUser = client.state.currentUser!.copyWith( - unreadCount: 3, - totalUnreadCountByTeam: {'team-1': 3}, - latestHiddenChannels: ['messaging:hidden'], - ); - - client.handleEvent( - Event.fromJson(const { - 'type': EventType.userUpdated, - 'user': {'id': userId, 'name': 'Renamed'}, - }), - ); - await Future.delayed(Duration.zero); - - final currentUser = client.state.currentUser!; - expect(currentUser.name, 'Renamed'); - expect(currentUser.unreadCount, 3); - expect(currentUser.totalUnreadCountByTeam, {'team-1': 3}); - expect(currentUser.latestHiddenChannels, ['messaging:hidden']); - }); - test('StreamChatClient.listUserGroups sends the pagination arguments and returns the listed groups', () async { const limit = 10; const idGt = 'cursor-group-id'; 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 index d4790969a3..cc59ba6dee 100644 --- 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 @@ -4,6 +4,7 @@ import 'package:stream_chat/stream_chat.dart'; import 'package:test/test.dart'; import '../mocks.dart'; +import 'users_fixtures.dart'; void main() { setUpAll(() => registerFallbackValue(const api.UpdateUsersPartialRequest(users: []))); @@ -14,7 +15,7 @@ void main() { api.UpdateUserPartialRequest(id: 'user-id', set: {'favorite_color': 'green'}, unset: ['nickname']), ], ); - final defaultApi = _defaultApiAnswering(Result.success(_updateUsersResponse())); + final defaultApi = _defaultApiAnswering(Result.success(updateUsersResponse())); final client = _client(defaultApi); final res = await client.updateUserPartial( @@ -25,7 +26,7 @@ void main() { expect( res.getOrNull(), - UpdateUsersResponse(duration: '4.21ms', users: {'user-id': _updatedUser()}), + UpdateUsersResponse(duration: '4.21ms', users: {'user-id': updatedUser()}), ); verify(() => defaultApi.updateUsersPartial(updateUsersPartialRequest: request)).called(1); verifyNoMoreInteractions(defaultApi); @@ -38,7 +39,7 @@ void main() { api.UpdateUserPartialRequest(id: 'second', unset: ['b']), ], ); - final defaultApi = _defaultApiAnswering(Result.success(_updateUsersResponse())); + final defaultApi = _defaultApiAnswering(Result.success(updateUsersResponse())); final client = _client(defaultApi); final res = await client.updateUsersPartial(const [ @@ -46,7 +47,7 @@ void main() { UpdateUserPartialRequest(id: 'second', unset: ['b']), ]); - expect(res.getOrNull(), UpdateUsersResponse(duration: '4.21ms', users: {'user-id': _updatedUser()})); + expect(res.getOrNull(), UpdateUsersResponse(duration: '4.21ms', users: {'user-id': updatedUser()})); verify(() => defaultApi.updateUsersPartial(updateUsersPartialRequest: request)).called(1); verifyNoMoreInteractions(defaultApi); }); @@ -85,68 +86,3 @@ MockDefaultApi _defaultApiAnswering(Result result) { ).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, - custom: const {'favorite_color': 'green'}, - deactivatedAt: DateTime.utc(2026, 9, 5), - deletedAt: DateTime.utc(2026, 9, 6), - shadowBanned: false, - blockedUserIds: const ['blocked-user'], -); - -// 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 index 4b88349e09..bdc25dbb9c 100644 --- a/packages/stream_chat/test/src/client/client_update_users_test.dart +++ b/packages/stream_chat/test/src/client/client_update_users_test.dart @@ -4,6 +4,7 @@ import 'package:stream_chat/stream_chat.dart'; import 'package:test/test.dart'; import '../mocks.dart'; +import 'users_fixtures.dart'; void main() { setUpAll(() => registerFallbackValue(const api.UpdateUsersRequest(users: {}))); @@ -12,7 +13,7 @@ void main() { 'StreamChatClient.updateUser sends the name, image, language, visibility and custom data and returns the updated ' 'users', () async { - final defaultApi = _defaultApiAnswering(Result.success(_updateUsersResponse())); + final defaultApi = _defaultApiAnswering(Result.success(updateUsersResponse())); final client = _client(defaultApi); final res = await client.updateUser( @@ -39,7 +40,7 @@ void main() { ), ); - expect(res.getOrNull(), UpdateUsersResponse(duration: '4.21ms', users: {'user-id': _updatedUser()})); + 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)); @@ -66,7 +67,7 @@ void main() { test( 'StreamChatClient.updateUser sends none of the fields an own user decodes from the connection as custom data', () async { - final defaultApi = _defaultApiAnswering(Result.success(_updateUsersResponse())); + final defaultApi = _defaultApiAnswering(Result.success(updateUsersResponse())); final client = _client(defaultApi); await client.updateUser( @@ -76,7 +77,6 @@ void main() { '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', }), ); @@ -92,42 +92,13 @@ void main() { }, ); - test('StreamChatClient.updateUser sends custom data named like a user field as custom data', () async { - final defaultApi = _defaultApiAnswering(Result.success(_updateUsersResponse())); - final client = _client(defaultApi); - - await client.updateUser(User(id: 'user-id', name: 'Jane', custom: const {'name': 'custom'})); - - verify( - () => defaultApi.updateUsers( - updateUsersRequest: const api.UpdateUsersRequest( - users: { - 'user-id': api.UserRequest(id: 'user-id', name: 'Jane', custom: {'name': 'custom'}), - }, - ), - ), - ).called(1); - }); - - test('StreamChatClient.updateUser keeps custom data named like a user field apart from the field', () async { - const custom = {'favorite_color': 'green', 'online': false, 'shadow_banned': true, 'unread_count': 3}; - final client = _client(_defaultApiAnswering(Result.success(_updateUsersResponse(custom: custom)))); - - final res = await client.updateUser(User(id: 'user-id')); - - final user = res.getOrNull()!.users['user-id']!; - expect(user.custom, custom); - expect(user.online, isTrue); - expect(user.shadowBanned, isFalse); - }); - test('StreamChatClient.updateUsers sends every user keyed by id and returns the updated users', () async { - final defaultApi = _defaultApiAnswering(Result.success(_updateUsersResponse())); + 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()})); + expect(res.getOrNull(), UpdateUsersResponse(duration: '4.21ms', users: {'user-id': updatedUser()})); verify( () => defaultApi.updateUsers( updateUsersRequest: const api.UpdateUsersRequest( @@ -173,68 +144,3 @@ MockDefaultApi _defaultApiAnswering(Result result) { ).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, - custom: const {'favorite_color': 'green'}, - deactivatedAt: DateTime.utc(2026, 9, 5), - deletedAt: DateTime.utc(2026, 9, 6), - shadowBanned: false, - blockedUserIds: const ['blocked-user'], -); - -// 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_user_updated_test.dart b/packages/stream_chat/test/src/client/client_user_updated_test.dart new file mode 100644 index 0000000000..cc07e1b2f1 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_user_updated_test.dart @@ -0,0 +1,60 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; + +void main() { + test('a user.updated event for the current user replaces their privacy settings', () async { + final currentUser = OwnUser( + id: 'user-id', + privacySettings: const PrivacySettings(typingIndicators: TypingIndicators(enabled: true)), + ); + final event = Event.fromJson(const { + 'type': EventType.userUpdated, + 'user': { + 'id': 'user-id', + 'privacy_settings': { + 'typing_indicators': {'enabled': false}, + }, + }, + }); + + final client = _client(currentUser)..handleEvent(event); + await Future.delayed(Duration.zero); + + expect(client.state.currentUser!.privacySettings?.typingIndicators?.enabled, isFalse); + }); + + test('a user.updated event for the current user keeps the fields only an own user has', () async { + final currentUser = OwnUser( + id: 'user-id', + unreadCount: 3, + totalUnreadCountByTeam: const {'team-1': 3}, + latestHiddenChannels: const ['messaging:hidden'], + ); + final event = Event.fromJson(const { + 'type': EventType.userUpdated, + 'user': {'id': 'user-id', 'name': 'Renamed'}, + }); + + final client = _client(currentUser)..handleEvent(event); + await Future.delayed(Duration.zero); + + final updatedUser = client.state.currentUser!; + expect(updatedUser.name, 'Renamed'); + expect(updatedUser.unreadCount, 3); + expect(updatedUser.totalUnreadCountByTeam, {'team-1': 3}); + expect(updatedUser.latestHiddenChannels, ['messaging:hidden']); + }); +} + +// A client whose state follows its events for [currentUser], with no +// connection. +StreamChatClient _client(OwnUser currentUser) { + final client = StreamChatClient('test-api-key', defaultApi: MockDefaultApi()); + addTearDown(client.dispose); + client.state + ..currentUser = currentUser + ..subscribeToEvents(); + return client; +} diff --git a/packages/stream_chat/test/src/client/users_fixtures.dart b/packages/stream_chat/test/src/client/users_fixtures.dart new file mode 100644 index 0000000000..3d4367cd6f --- /dev/null +++ b/packages/stream_chat/test/src/client/users_fixtures.dart @@ -0,0 +1,69 @@ +import 'package:stream_chat/open_api/api.dart' as api; +import 'package:stream_chat/stream_chat.dart'; + +// Fixtures shared by the user update client tests. The generated user carries a +// value in every field a [User] maps, none of them its default, so a field the +// mapping drops or swaps changes the compared user. + +/// The user [updateUsersResponse] carries, as the client returns it. +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: false, + banned: true, + banExpires: DateTime.utc(2026, 9, 4), + teams: const ['team-a'], + language: 'de', + invisible: true, + teamsRole: const {'team-a': 'member'}, + avgResponseTime: 42, + custom: const {'favorite_color': 'green'}, + deactivatedAt: DateTime.utc(2026, 9, 5), + deletedAt: DateTime.utc(2026, 9, 6), + shadowBanned: false, + blockedUserIds: const ['blocked-user'], +); + +/// A generated response whose user sets every field. +api.UpdateUsersResponse updateUsersResponse() => 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: false, + banned: true, + banExpires: DateTime.utc(2026, 9, 4), + teams: const ['team-a'], + language: 'de', + invisible: true, + teamsRole: const {'team-a': 'member'}, + avgResponseTime: 42, + custom: const {'favorite_color': 'green'}, + deactivatedAt: DateTime.utc(2026, 9, 5), + deletedAt: DateTime.utc(2026, 9, 6), + shadowBanned: false, + revokeTokensIssuedBefore: DateTime.utc(2026, 9, 7), + blockedUserIds: const ['blocked-user'], + channelMutes: const [], + devices: const [], + mutes: const [], + latestHiddenChannels: const ['messaging:hidden'], + totalUnreadCount: 5, + unreadChannels: 2, + unreadCount: 4, + unreadThreads: 1, + ), + }, +); diff --git a/packages/stream_chat/test/src/core/models/own_user_test.dart b/packages/stream_chat/test/src/core/models/own_user_test.dart index 9e55f7475b..c17865c791 100644 --- a/packages/stream_chat/test/src/core/models/own_user_test.dart +++ b/packages/stream_chat/test/src/core/models/own_user_test.dart @@ -254,6 +254,25 @@ void main() { expect(ownUser.toJson().containsKey('name'), isFalse); }); + test('merge clears the values the other user leaves out', () { + final user = OwnUser( + id: 'test-user', + deactivatedAt: DateTime.utc(2020), + deletedAt: DateTime.utc(2021), + shadowBanned: true, + totalUnreadCountByTeam: const {'team-1': 1}, + latestHiddenChannels: const ['messaging:hidden'], + ); + + final merged = user.merge(OwnUser(id: 'test-user')); + + expect(merged.deactivatedAt, isNull); + expect(merged.deletedAt, isNull); + expect(merged.shadowBanned, isNull); + expect(merged.totalUnreadCountByTeam, isNull); + expect(merged.latestHiddenChannels, isNull); + }); + test('should parse json with privacy settings correctly', () { final json = { 'id': 'test-user', @@ -324,15 +343,10 @@ void main() { expect(ownUser.blockedUserIds, contains('blocked-3')); }); - test('fromUser leaves the own-user fields at their defaults, even when the user JSON carried them', () { + test('fromUser should handle missing OwnUser-specific fields', () { final userJson = { 'id': 'test-user', 'name': 'Test User', - 'total_unread_count': 42, - 'unread_count': 42, - 'privacy_settings': { - 'typing_indicators': {'enabled': false}, - }, }; final user = User.fromJson(userJson); 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 8f256b8719..ad3a5ceeb7 100644 --- a/packages/stream_chat/test/src/core/models/user_test.dart +++ b/packages/stream_chat/test/src/core/models/user_test.dart @@ -67,75 +67,16 @@ void main() { expect(User.fromJson(user.toJson()), user); }); - test('User.fromJson leaves the keys only an own user carries out of custom', () { - final user = User.fromJson(const { - 'id': id, - 'devices': [], - 'mutes': [], - 'channel_mutes': [], - 'total_unread_count': 1, - 'unread_channels': 1, - 'unread_threads': 1, - 'unread_count': 1, - 'total_unread_count_by_team': {'team-1': 1}, - 'latest_hidden_channels': ['messaging:hidden'], - 'push_preferences': {'chat_level': 'all'}, - 'privacy_settings': {}, - 'revoke_tokens_issued_before': '2021-08-06T10:39:21.817646Z', - 'color': 'red', - }); - - expect(user.custom, {'color': 'red'}); - }); - - test('Users that differ only in a promoted field are not equal', () { - User user({ - String? name, - String? image, - DateTime? deactivatedAt, - DateTime? deletedAt, - bool? shadowBanned, - List blockedUserIds = const [], - }) => User( - id: id, - name: name, - image: image, - deactivatedAt: deactivatedAt, - deletedAt: deletedAt, - shadowBanned: shadowBanned, - blockedUserIds: blockedUserIds, - ); - - expect(user(name: 'a'), isNot(user(name: 'b'))); - expect(user(image: 'a'), isNot(user(image: 'b'))); - expect(user(deactivatedAt: DateTime.utc(2020)), isNot(user(deactivatedAt: DateTime.utc(2021)))); - expect(user(deletedAt: DateTime.utc(2020)), isNot(user(deletedAt: DateTime.utc(2021)))); - expect(user(shadowBanned: true), isNot(user(shadowBanned: false))); - expect(user(blockedUserIds: ['a']), isNot(user(blockedUserIds: ['b']))); - }); - - test('User keeps custom data named like a field apart from the field', () { - final user = User( - id: id, - name: name, - custom: const {'name': 'custom', 'color': 'red'}, - ); - - expect(user.name, name); - expect(user.custom, {'name': 'custom', 'color': 'red'}); - }); - test('User.toJson leaves out custom data named like a field', () { final user = User( id: id, name: name, - custom: const {'name': 'custom', 'devices': 'custom', 'color': 'red'}, + custom: const {'name': 'custom', 'color': 'red'}, ); final json = user.toJson(); expect(json['name'], name); - expect(json.containsKey('devices'), isFalse); expect(json['color'], 'red'); }); diff --git a/packages/stream_chat_localizations/CHANGELOG.md b/packages/stream_chat_localizations/CHANGELOG.md index a77a443626..7c9d3c3bcc 100644 --- a/packages/stream_chat_localizations/CHANGELOG.md +++ b/packages/stream_chat_localizations/CHANGELOG.md @@ -1,9 +1,3 @@ -## Upcoming Beta - -🔄 Changed - -- The typing and pinned-by texts still name a user with no name by their id. - ## 10.5.0 ✅ Added From 15298a7f902a4b080e029de28cd30b40d3accb5d Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 16 Oct 2026 15:58:16 +0200 Subject: [PATCH 5/9] refactor(llc, ui): store user name and image as given and keep nameOrId's v10 fallback Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 7 +++-- openapi-migration/20-user-updates.md | 7 ++--- openapi-migration/tool/generate_plan.py | 7 ++--- packages/stream_chat/CHANGELOG.md | 2 +- .../lib/src/core/models/own_user.dart | 5 ---- .../stream_chat/lib/src/core/models/user.dart | 19 ++++++-------- .../test/src/core/models/user_test.dart | 16 +++--------- .../components/avatar/stream_user_avatar.dart | 5 +++- .../test/src/avatars/user_avatar_test.dart | 26 ++++++++++++++++++- 9 files changed, 52 insertions(+), 42 deletions(-) diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index 336f4fa9e1..741b1cd147 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -328,7 +328,6 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `ChannelModel.custom` / `Member.custom` entry named like one of the model's own fields | the same entry, as custom data | `retyped` | Kept as the API returns it; `toJson` leaves it out so it cannot overwrite the field | | `User.extraData` / `OwnUser.extraData` | `User.custom` / `OwnUser.custom` | `renamed` | The wire's name for it; also the constructor and `copyWith` arguments | | `User.name` (`String`, the id when the user has none) | `User.nameOrId` | `retyped` | `User.name` is a `String?` that is null when the user has none. Use `nameOrId` wherever a user is named by text, and `name` where a missing name matters, such as initials | -| `User.name` / `User.image` blank (`''`) | `null` | `retyped` | A blank name or image is stored as null | | `user.extraData['name']` / `['image']` / `['deactivated_at']` / `['deleted_at']` / `['shadow_banned']` | `User.name` / `image` / `deactivatedAt` / `deletedAt` / `shadowBanned` | `retyped` | Fields now, not custom data; `copyWith(custom:)` no longer sets them | | `OwnUser.blockedUserIds` | `User.blockedUserIds` | `moved` | Still on `OwnUser`. Empty for anyone but the current user | | `ownUser.extraData['unread_count']` / `['total_unread_count_by_team']` / `['latest_hidden_channels']` | `OwnUser.unreadCount` / `totalUnreadCountByTeam` / `latestHiddenChannels` | `added` | Fields now, not custom data | @@ -1051,9 +1050,9 @@ final color = user.extraData['favorite_color']; final color = user.custom['favorite_color']; ``` -**`User.name` is nullable and no longer falls back to the id.** It is null when the user has no name; a blank name -or image is stored as null. `User.nameOrId` answers the name, or the id when there is none, which is what v10's -`name` answered. Use it wherever you name a user by text — a title, a label, a mention — and keep `name` where a +**`User.name` is nullable and no longer falls back to the id.** It is null when the user has no name. +`User.nameOrId` answers the name, or the id when the name is missing or empty, which is what v10's `name` +answered. Use it wherever you name a user by text — a title, a label, a mention — and keep `name` where a missing name matters, such as avatar initials or a sort: ```dart diff --git a/openapi-migration/20-user-updates.md b/openapi-migration/20-user-updates.md index 24e1f4c28f..620c66e933 100644 --- a/openapi-migration/20-user-updates.md +++ b/openapi-migration/20-user-updates.md @@ -56,9 +56,10 @@ user keeps it in `custom`, as a v1 member keeps `role`. Each generated user mapper fills the ones its response carries (`UserResponse` has no `shadowBanned`). -- **`User.name` is nullable and has no fallback to `id`;** `User.nameOrId` has it. A blank name or - image is stored as null. The UI names a user by `nameOrId` everywhere except avatar initials, which - show the placeholder icon for an unnamed user, and filters and sorts, which read the raw `name`. +- **`User.name` is nullable and has no fallback to `id`;** `User.nameOrId` has it, for a missing or + empty name, as v10's `name` did. Name and image are stored as given. The UI names a user by + `nameOrId` everywhere except avatar initials, which show the placeholder icon for an unnamed user, + and filters and sorts, which read the raw `name`; the avatar treats an empty image as none. - **`custom` is the wire's, verbatim,** and the v1 writers leave out a custom entry named like a key, so it cannot overwrite the field. `OwnUser.fromUser` copies the user's fields and no longer reads an own user's fields out of the custom data. diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index e911de1eab..6a97eefe69 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -1103,9 +1103,10 @@ def match(verb, path): user keeps it in `custom`, as a v1 member keeps `role`. Each generated user mapper fills the ones its response carries (`UserResponse` has no `shadowBanned`). - - **`User.name` is nullable and has no fallback to `id`;** `User.nameOrId` has it. A blank name or - image is stored as null. The UI names a user by `nameOrId` everywhere except avatar initials, which - show the placeholder icon for an unnamed user, and filters and sorts, which read the raw `name`. + - **`User.name` is nullable and has no fallback to `id`;** `User.nameOrId` has it, for a missing or + empty name, as v10's `name` did. Name and image are stored as given. The UI names a user by + `nameOrId` everywhere except avatar initials, which show the placeholder icon for an unnamed user, + and filters and sorts, which read the raw `name`; the avatar treats an empty image as none. - **`custom` is the wire's, verbatim,** and the v1 writers leave out a custom entry named like a key, so it cannot overwrite the field. `OwnUser.fromUser` copies the user's fields and no longer reads an own user's fields out of the custom data. diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 72b7e0d104..7a5acc50da 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -133,7 +133,7 @@ - Several channel and member values the custom data used to hold, such as `ChannelModel.hidden`, `ChannelModel.truncatedAt` and `Member.status`, are fields instead: `custom` no longer holds them, `toJson` no longer writes them, and an entry with one of their names 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. - `User.extraData` is renamed `custom`, and so is the `extraData` argument of the `User` and `OwnUser` constructors and `copyWith`. -- `User.name` is nullable and no longer answers the user's id when they have no name; read `User.nameOrId` for that. A blank name or image is stored as null. +- `User.name` is nullable and no longer answers the user's id when they have no name; read `User.nameOrId` for that. - Several user values the custom data used to hold, such as `User.name`, `User.image` and `User.deactivatedAt`, are fields instead: `custom` no longer holds them, and an entry with one of their names in `custom` or in `copyWith(custom:)` no longer sets the field. - `User.custom` holds the custom data exactly as the API returns it, and `toJson` leaves out an entry named like one of the user's own fields. - `OwnUser.fromUser` no longer reads devices, mutes, unread counts, push preferences or privacy settings out of the user's custom data; they are left at their defaults. 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 eddce29129..2fcdf0c28b 100644 --- a/packages/stream_chat/lib/src/core/models/own_user.dart +++ b/packages/stream_chat/lib/src/core/models/own_user.dart @@ -151,11 +151,6 @@ class OwnUser extends User { /// Returns a new [OwnUser] that is a combination of this ownUser /// and the given [other] ownUser. - /// - /// A nullable value [other] leaves out keeps this user's, except - /// [deactivatedAt], [deletedAt], [shadowBanned], [totalUnreadCountByTeam] and - /// [latestHiddenChannels], which are taken from [other] as they are. - /// [banExpires] is always this user's. OwnUser merge(OwnUser? other) { if (other == null) return this; return OwnUser( diff --git a/packages/stream_chat/lib/src/core/models/user.dart b/packages/stream_chat/lib/src/core/models/user.dart index 7027633ac3..4604ea4162 100644 --- a/packages/stream_chat/lib/src/core/models/user.dart +++ b/packages/stream_chat/lib/src/core/models/user.dart @@ -12,13 +12,11 @@ part 'user.g.dart'; @JsonSerializable(includeIfNull: false) class User extends Equatable { /// Creates a new user. - /// - /// A blank [name] or [image] is stored as null. User({ required this.id, this.role, - String? name, - String? image, + this.name, + this.image, this.createdAt, this.updatedAt, this.lastActive, @@ -35,9 +33,7 @@ class User extends Equatable { this.deletedAt, this.shadowBanned, this.blockedUserIds = const [], - }) : name = _nonBlank(name), - image = _nonBlank(image), - custom = {...custom}; + }) : custom = {...custom}; /// Create a new instance from json. factory User.fromJson(Map json) => @@ -75,12 +71,15 @@ class User extends Equatable { /// Null when the user has none. final String? name; - /// The user's [name], or their [id] when they have none. + /// The user's [name], or their [id] when the name is missing or empty. /// /// Suited to naming a user by text, such as in a mention; [name] tells an /// unnamed user apart. @JsonKey(includeToJson: false, includeFromJson: false) - String get nameOrId => name ?? id; + String get nameOrId => switch (name) { + final name? when name.isNotEmpty => name, + _ => id, + }; /// The URL of the user's image. /// @@ -153,8 +152,6 @@ class User extends Equatable { /// Empty for anyone but the current user. final List blockedUserIds; - static String? _nonBlank(String? value) => (value == null || value.trim().isEmpty) ? null : value; - /// List of users to list of userIds. static List? toIds(List? users) => users?.map((u) => u.id).toList(); 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 ad3a5ceeb7..74104927d6 100644 --- a/packages/stream_chat/test/src/core/models/user_test.dart +++ b/packages/stream_chat/test/src/core/models/user_test.dart @@ -316,20 +316,10 @@ void main() { expect(user.nameOrId, id); }); - test('User stores a blank name or image as null', () { - for (final blank in ['', ' ']) { - final user = User(id: id, name: blank, image: blank); + test('User.nameOrId returns the id for a user with an empty name', () { + final user = User(id: id, name: ''); - expect(user.name, isNull, reason: 'name "$blank"'); - expect(user.image, isNull, reason: 'image "$blank"'); - } - }); - - test('User.fromJson reads a blank name or image as null', () { - final user = User.fromJson(const {'id': id, 'name': ' ', 'image': ''}); - - expect(user.name, isNull); - expect(user.image, isNull); + expect(user.nameOrId, id); }); }); } diff --git a/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart b/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart index 3e315abbd1..519ad0b492 100644 --- a/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart +++ b/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart @@ -121,7 +121,10 @@ class StreamUserAvatar extends StatelessWidget { final userAvatar = StreamAvatar( size: effectiveSize, - imageUrl: user.image, + imageUrl: switch (user.image) { + final image? when image.isNotEmpty => image, + _ => null, + }, showBorder: showBorder, isFloating: isFloating, backgroundColor: effectiveBackgroundColor, diff --git a/packages/stream_chat_flutter/test/src/avatars/user_avatar_test.dart b/packages/stream_chat_flutter/test/src/avatars/user_avatar_test.dart index b4945d0776..0fd598827c 100644 --- a/packages/stream_chat_flutter/test/src/avatars/user_avatar_test.dart +++ b/packages/stream_chat_flutter/test/src/avatars/user_avatar_test.dart @@ -199,7 +199,31 @@ void main() { } }); - testWidgets('a user with no name shows the placeholder icon rather than initials of their id', (tester) async { + testWidgets('a user with an empty image shows the placeholder', (tester) async { + final user = MockUser(); + when(() => user.id).thenReturn('ada'); + when(() => user.name).thenReturn('Ada Lovelace'); + when(() => user.image).thenReturn(''); + when(() => user.online).thenReturn(false); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + themeData: StreamChatThemeData(), + child: Scaffold( + body: Center( + child: StreamUserAvatar(user: user, size: .lg), + ), + ), + ), + ), + ); + + expect(find.text('AL'), findsOneWidget); + }); + + testWidgets('a user with no name shows the placeholder icon', (tester) async { final user = MockUser(); when(() => user.id).thenReturn('ada'); when(() => user.name).thenReturn(null); From 31e1f2eef916ae2e1d95c3725414044258892abc Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 16 Oct 2026 16:28:57 +0200 Subject: [PATCH 6/9] refactor(llc): leave the connection-only unread and hidden-channel fields out of OwnUser Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 8 +++--- openapi-migration/09-users.md | 2 +- openapi-migration/20-user-updates.md | 9 +++---- openapi-migration/tool/generate_plan.py | 12 ++++----- packages/stream_chat/CHANGELOG.md | 2 +- .../stream_chat/lib/src/client/client.dart | 3 --- .../lib/src/core/models/own_user.dart | 25 ------------------- .../lib/src/core/models/own_user.g.dart | 8 ------ .../src/client/client_user_updated_test.dart | 22 ---------------- .../test/src/core/models/event_test.dart | 1 - .../test/src/core/models/own_user_test.dart | 25 ------------------- 11 files changed, 14 insertions(+), 103 deletions(-) diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index 741b1cd147..4fa84713e1 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -330,7 +330,6 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `User.name` (`String`, the id when the user has none) | `User.nameOrId` | `retyped` | `User.name` is a `String?` that is null when the user has none. Use `nameOrId` wherever a user is named by text, and `name` where a missing name matters, such as initials | | `user.extraData['name']` / `['image']` / `['deactivated_at']` / `['deleted_at']` / `['shadow_banned']` | `User.name` / `image` / `deactivatedAt` / `deletedAt` / `shadowBanned` | `retyped` | Fields now, not custom data; `copyWith(custom:)` no longer sets them | | `OwnUser.blockedUserIds` | `User.blockedUserIds` | `moved` | Still on `OwnUser`. Empty for anyone but the current user | -| `ownUser.extraData['unread_count']` / `['total_unread_count_by_team']` / `['latest_hidden_channels']` | `OwnUser.unreadCount` / `totalUnreadCountByTeam` / `latestHiddenChannels` | `added` | Fields now, not custom data | | `OwnUser.fromUser(user)` reading devices, mutes, unread counts, push preferences and privacy settings out of `user.extraData` | — | `removed` | A `User` no longer carries them; they are left at their defaults | | `User.custom` entry named like one of the user's own fields | the same entry, as custom data | `retyped` | Kept as the API returns it; `toJson` leaves it out so it cannot overwrite the field | | _(more added per feature as PRs land)_ | | | | @@ -1068,10 +1067,9 @@ prints `null is typing` for a user with no name. That includes a custom `Transla subclass overriding `userTypingText` or `pinnedByUserText`. **The user fields are fields, not custom data.** `User.image` reads the same and `name` changes as described above; -`deactivatedAt`, `deletedAt` and `shadowBanned` are new fields, `blockedUserIds` moves up from `OwnUser` to `User`, -and `OwnUser.unreadCount`, `totalUnreadCountByTeam` and `latestHiddenChannels` are new fields. `custom` no longer -holds any of their keys, and an entry with one of those names in `custom` or in `copyWith(custom:)` no longer sets -the field: +`deactivatedAt`, `deletedAt` and `shadowBanned` are new fields, and `blockedUserIds` moves up from `OwnUser` to +`User`. `custom` no longer holds any of their keys, and an entry with one of those names in `custom` or in +`copyWith(custom:)` no longer sets the field: ```dart // v10 diff --git a/openapi-migration/09-users.md b/openapi-migration/09-users.md index 1ec3fa171a..89b6703820 100644 --- a/openapi-migration/09-users.md +++ b/openapi-migration/09-users.md @@ -30,7 +30,7 @@ - `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. - `UserFilterField.bypassModeration` reads `custom`, which no generated user has a field to fill. -- **`User` and `OwnUser` move to freezed without changing the current user's merge rules.** freezed's `copyWith` clears a field passed `null` where today's keeps it, and it can't generate one for `OwnUser` while it takes `User`'s fields through super-parameters. `OwnUser.merge` builds through the constructor and states each field's rule (keep a nullable value the other user leaves out, except `deactivatedAt`, `deletedAt`, `shadowBanned`, `totalUnreadCountByTeam` and `latestHiddenChannels`, taken as they are, and `banExpires`, always kept); keep those rules in a dedicated merge, and run `copywith_audit.py` on both models for every other call site, such as the `user.updated` handler's preserve list. +- **`User` and `OwnUser` move to freezed without changing the current user's merge rules.** freezed's `copyWith` clears a field passed `null` where today's keeps it, and it can't generate one for `OwnUser` while it takes `User`'s fields through super-parameters. `OwnUser.merge` builds through the constructor and states each field's rule (keep a nullable value the other user leaves out, except `deactivatedAt`, `deletedAt` and `shadowBanned`, taken as they are, and `banExpires`, always kept); keep those rules in a dedicated merge, and run `copywith_audit.py` on both models for every other call site, such as the `user.updated` handler's preserve list. - `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 diff --git a/openapi-migration/20-user-updates.md b/openapi-migration/20-user-updates.md index 620c66e933..01b32af3e4 100644 --- a/openapi-migration/20-user-updates.md +++ b/openapi-migration/20-user-updates.md @@ -51,9 +51,9 @@ - **`User` and `OwnUser` promote the fields v10 parked in `extraData`, and rename it `custom`** (README rule 9), since these calls are the first to map a user. `User.name`, `image`, `deactivatedAt`, `deletedAt` and `shadowBanned` are fields with persistence columns; - `blockedUserIds` moves up from `OwnUser`, and `OwnUser` gains `unreadCount`, - `totalUnreadCountByTeam` and `latestHiddenChannels`. The mappers leave `revoke_tokens_issued_before` out; a v1 - user keeps it in `custom`, as a v1 member keeps `role`. + `blockedUserIds` moves up from `OwnUser`. `OwnUser` leaves out `unread_count`, + `total_unread_count_by_team` and `latest_hidden_channels`, which no event keeps current. The mappers + leave `revoke_tokens_issued_before` out; a v1 user keeps it in `custom`, as a v1 member keeps `role`. Each generated user mapper fills the ones its response carries (`UserResponse` has no `shadowBanned`). - **`User.name` is nullable and has no fallback to `id`;** `User.nameOrId` has it, for a missing or @@ -63,8 +63,7 @@ - **`custom` is the wire's, verbatim,** and the v1 writers leave out a custom entry named like a key, so it cannot overwrite the field. `OwnUser.fromUser` copies the user's fields and no longer reads an own user's fields out of the custom data. -- **`OwnUser.merge` takes the other user's fields,** as before; the `user.updated` handler also keeps - the three new unread fields, which the event leaves out. A `user.updated` event's user is decoded +- **`OwnUser.merge` takes the other user's fields,** as before. A `user.updated` event's user is decoded again as an `OwnUser`, so the privacy settings it carries for the current user replace theirs, as in v10 — a temporary adapter (README) for the WebSocket v2 move to replace. diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 6a97eefe69..5443651d44 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -594,8 +594,7 @@ def match(verb, path): '`copyWith` clears a field passed `null` where today\'s keeps it, and it can\'t generate one for ' '`OwnUser` while it takes `User`\'s fields through super-parameters. `OwnUser.merge` builds through the ' 'constructor and states each field\'s rule (keep a nullable value the other user leaves out, except ' - '`deactivatedAt`, `deletedAt`, `shadowBanned`, `totalUnreadCountByTeam` and `latestHiddenChannels`, ' - 'taken as they are, and `banExpires`, always kept); ' + '`deactivatedAt`, `deletedAt` and `shadowBanned`, taken as they are, and `banExpires`, always kept); ' 'keep those rules in a dedicated merge, and run `copywith_audit.py` on both models for every other ' 'call site, such as the `user.updated` handler\'s preserve list.', '`queryUsers` answers `FullUserResponse`, which [20](20-user-updates.md) already maps to `User`; the ' @@ -1098,9 +1097,9 @@ def match(verb, path): - **`User` and `OwnUser` promote the fields v10 parked in `extraData`, and rename it `custom`** (README rule 9), since these calls are the first to map a user. `User.name`, `image`, `deactivatedAt`, `deletedAt` and `shadowBanned` are fields with persistence columns; - `blockedUserIds` moves up from `OwnUser`, and `OwnUser` gains `unreadCount`, - `totalUnreadCountByTeam` and `latestHiddenChannels`. The mappers leave `revoke_tokens_issued_before` out; a v1 - user keeps it in `custom`, as a v1 member keeps `role`. + `blockedUserIds` moves up from `OwnUser`. `OwnUser` leaves out `unread_count`, + `total_unread_count_by_team` and `latest_hidden_channels`, which no event keeps current. The mappers + leave `revoke_tokens_issued_before` out; a v1 user keeps it in `custom`, as a v1 member keeps `role`. Each generated user mapper fills the ones its response carries (`UserResponse` has no `shadowBanned`). - **`User.name` is nullable and has no fallback to `id`;** `User.nameOrId` has it, for a missing or @@ -1110,8 +1109,7 @@ def match(verb, path): - **`custom` is the wire's, verbatim,** and the v1 writers leave out a custom entry named like a key, so it cannot overwrite the field. `OwnUser.fromUser` copies the user's fields and no longer reads an own user's fields out of the custom data. - - **`OwnUser.merge` takes the other user's fields,** as before; the `user.updated` handler also keeps - the three new unread fields, which the event leaves out. A `user.updated` event's user is decoded + - **`OwnUser.merge` takes the other user's fields,** as before. A `user.updated` event's user is decoded again as an `OwnUser`, so the privacy settings it carries for the current user replace theirs, as in v10 — a temporary adapter (README) for the WebSocket v2 move to replace. """), diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 7a5acc50da..fa7c9ba24d 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -168,7 +168,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`, `shadowBanned`, `blockedUserIds` and `nameOrId`, and `OwnUser.unreadCount`, `totalUnreadCountByTeam` and `latestHiddenChannels`; the constructors and `copyWith` accept the new fields. +- Added `User.deactivatedAt`, `deletedAt`, `shadowBanned`, `blockedUserIds` and `nameOrId`; the constructors and `copyWith` accept the new fields. - Added `ChannelModel.muted`, `blocked`, `truncatedBy`, `autoTranslationEnabled` and `autoTranslationLanguage`, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, `futureChannelBanExpires` and `deletedAt`; 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 fe6656c822..0acb3f1807 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -2684,9 +2684,6 @@ class ClientState { unreadChannels: currentUser?.unreadChannels, unreadThreads: currentUser?.unreadThreads, pushPreferences: currentUser?.pushPreferences, - unreadCount: currentUser?.unreadCount, - totalUnreadCountByTeam: currentUser?.totalUnreadCountByTeam, - latestHiddenChannels: currentUser?.latestHiddenChannels, ); } 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 2fcdf0c28b..0e3cfd4283 100644 --- a/packages/stream_chat/lib/src/core/models/own_user.dart +++ b/packages/stream_chat/lib/src/core/models/own_user.dart @@ -22,9 +22,6 @@ class OwnUser extends User { super.blockedUserIds, this.pushPreferences, this.privacySettings, - this.unreadCount = 0, - this.totalUnreadCountByTeam, - this.latestHiddenChannels, required super.id, super.role, super.name, @@ -112,9 +109,6 @@ class OwnUser extends User { DateTime? deactivatedAt, DateTime? deletedAt, bool? shadowBanned, - int? unreadCount, - Map? totalUnreadCountByTeam, - List? latestHiddenChannels, }) => OwnUser( id: id ?? this.id, role: role ?? this.role, @@ -144,9 +138,6 @@ class OwnUser extends User { deactivatedAt: deactivatedAt ?? this.deactivatedAt, deletedAt: deletedAt ?? this.deletedAt, shadowBanned: shadowBanned ?? this.shadowBanned, - unreadCount: unreadCount ?? this.unreadCount, - totalUnreadCountByTeam: totalUnreadCountByTeam ?? this.totalUnreadCountByTeam, - latestHiddenChannels: latestHiddenChannels ?? this.latestHiddenChannels, ); /// Returns a new [OwnUser] that is a combination of this ownUser @@ -182,9 +173,6 @@ class OwnUser extends User { deactivatedAt: other.deactivatedAt, deletedAt: other.deletedAt, shadowBanned: other.shadowBanned, - unreadCount: other.unreadCount, - totalUnreadCountByTeam: other.totalUnreadCountByTeam, - latestHiddenChannels: other.latestHiddenChannels, ); } @@ -213,19 +201,6 @@ class OwnUser extends User { /// Privacy settings for the user if set. final PrivacySettings? privacySettings; - /// The user's unread message count as of the last time the current user was - /// loaded. - /// - /// Unlike [totalUnreadCount], it is not updated by events that carry only a - /// new count; consider [totalUnreadCount] for a live value. - final int unreadCount; - - /// Total unread messages by the user in each of their teams, keyed by team. - final Map? totalUnreadCountByTeam; - - /// The cids of the channels the user hid most recently. - final List? latestHiddenChannels; - /// Convert instance to json. /// /// Writes the entries of [custom] at the root, leaving out any named like one diff --git a/packages/stream_chat/lib/src/core/models/own_user.g.dart b/packages/stream_chat/lib/src/core/models/own_user.g.dart index d1eedaf6ca..a29f285af1 100644 --- a/packages/stream_chat/lib/src/core/models/own_user.g.dart +++ b/packages/stream_chat/lib/src/core/models/own_user.g.dart @@ -26,11 +26,6 @@ OwnUser _$OwnUserFromJson(Map json) => OwnUser( privacySettings: json['privacy_settings'] == null ? null : PrivacySettings.fromJson(json['privacy_settings'] as Map), - unreadCount: (json['unread_count'] as num?)?.toInt() ?? 0, - totalUnreadCountByTeam: (json['total_unread_count_by_team'] as Map?)?.map( - (k, e) => MapEntry(k, (e as num).toInt()), - ), - latestHiddenChannels: (json['latest_hidden_channels'] as List?)?.map((e) => e as String).toList(), id: json['id'] as String, role: json['role'] as String?, name: json['name'] as String?, @@ -81,7 +76,4 @@ Map _$OwnUserToJson(OwnUser instance) => { 'unread_threads': instance.unreadThreads, 'push_preferences': ?instance.pushPreferences?.toJson(), 'privacy_settings': ?instance.privacySettings?.toJson(), - 'unread_count': instance.unreadCount, - 'total_unread_count_by_team': ?instance.totalUnreadCountByTeam, - 'latest_hidden_channels': ?instance.latestHiddenChannels, }; diff --git a/packages/stream_chat/test/src/client/client_user_updated_test.dart b/packages/stream_chat/test/src/client/client_user_updated_test.dart index cc07e1b2f1..a983e79216 100644 --- a/packages/stream_chat/test/src/client/client_user_updated_test.dart +++ b/packages/stream_chat/test/src/client/client_user_updated_test.dart @@ -24,28 +24,6 @@ void main() { expect(client.state.currentUser!.privacySettings?.typingIndicators?.enabled, isFalse); }); - - test('a user.updated event for the current user keeps the fields only an own user has', () async { - final currentUser = OwnUser( - id: 'user-id', - unreadCount: 3, - totalUnreadCountByTeam: const {'team-1': 3}, - latestHiddenChannels: const ['messaging:hidden'], - ); - final event = Event.fromJson(const { - 'type': EventType.userUpdated, - 'user': {'id': 'user-id', 'name': 'Renamed'}, - }); - - final client = _client(currentUser)..handleEvent(event); - await Future.delayed(Duration.zero); - - final updatedUser = client.state.currentUser!; - expect(updatedUser.name, 'Renamed'); - expect(updatedUser.unreadCount, 3); - expect(updatedUser.totalUnreadCountByTeam, {'team-1': 3}); - expect(updatedUser.latestHiddenChannels, ['messaging:hidden']); - }); } // A client whose state follows its events for [currentUser], with no diff --git a/packages/stream_chat/test/src/core/models/event_test.dart b/packages/stream_chat/test/src/core/models/event_test.dart index f9e6ad2964..a9e7883897 100644 --- a/packages/stream_chat/test/src/core/models/event_test.dart +++ b/packages/stream_chat/test/src/core/models/event_test.dart @@ -123,7 +123,6 @@ void main() { 'total_unread_count': 0, 'unread_channels': 0, 'unread_threads': 0, - 'unread_count': 0, 'blocked_user_ids': [], }, 'user': {'id': 'id', 'teams': [], 'online': false, 'banned': false, 'blocked_user_ids': []}, diff --git a/packages/stream_chat/test/src/core/models/own_user_test.dart b/packages/stream_chat/test/src/core/models/own_user_test.dart index c17865c791..59fe3cf6ac 100644 --- a/packages/stream_chat/test/src/core/models/own_user_test.dart +++ b/packages/stream_chat/test/src/core/models/own_user_test.dart @@ -157,9 +157,6 @@ void main() { banned: true, teams: ['team1', 'team2'], language: 'fr', - unreadCount: 7, - totalUnreadCountByTeam: {'team1': 7}, - latestHiddenChannels: ['messaging:hidden'], deactivatedAt: DateTime.utc(2022), deletedAt: DateTime.utc(2023), shadowBanned: true, @@ -182,9 +179,6 @@ void main() { expect(newUser.banned, true); expect(newUser.teams, ['team1', 'team2']); expect(newUser.language, 'fr'); - expect(newUser.unreadCount, 7); - expect(newUser.totalUnreadCountByTeam, {'team1': 7}); - expect(newUser.latestHiddenChannels, ['messaging:hidden']); expect(newUser.deactivatedAt, DateTime.utc(2022)); expect(newUser.deletedAt, DateTime.utc(2023)); expect(newUser.shadowBanned, isTrue); @@ -213,9 +207,6 @@ void main() { banned: true, teams: const ['team1', 'team2'], language: 'fr', - unreadCount: 7, - totalUnreadCountByTeam: const {'team1': 7}, - latestHiddenChannels: const ['messaging:hidden'], deactivatedAt: DateTime.utc(2022), deletedAt: DateTime.utc(2023), shadowBanned: true, @@ -239,9 +230,6 @@ void main() { expect(newUser.banned, true); expect(newUser.teams, ['team1', 'team2']); expect(newUser.language, 'fr'); - expect(newUser.unreadCount, 7); - expect(newUser.totalUnreadCountByTeam, {'team1': 7}); - expect(newUser.latestHiddenChannels, ['messaging:hidden']); expect(newUser.deactivatedAt, DateTime.utc(2022)); expect(newUser.deletedAt, DateTime.utc(2023)); expect(newUser.shadowBanned, isTrue); @@ -260,8 +248,6 @@ void main() { deactivatedAt: DateTime.utc(2020), deletedAt: DateTime.utc(2021), shadowBanned: true, - totalUnreadCountByTeam: const {'team-1': 1}, - latestHiddenChannels: const ['messaging:hidden'], ); final merged = user.merge(OwnUser(id: 'test-user')); @@ -269,8 +255,6 @@ void main() { expect(merged.deactivatedAt, isNull); expect(merged.deletedAt, isNull); expect(merged.shadowBanned, isNull); - expect(merged.totalUnreadCountByTeam, isNull); - expect(merged.latestHiddenChannels, isNull); }); test('should parse json with privacy settings correctly', () { @@ -361,9 +345,6 @@ void main() { expect(ownUser.blockedUserIds, isEmpty); expect(ownUser.pushPreferences, isNull); expect(ownUser.privacySettings, isNull); - expect(ownUser.unreadCount, 0); - expect(ownUser.totalUnreadCountByTeam, isNull); - expect(ownUser.latestHiddenChannels, isNull); }); test('fromUser should work with OwnUser as input', () { @@ -458,9 +439,6 @@ void main() { typingIndicators: TypingIndicators(enabled: true), readReceipts: ReadReceipts(enabled: false), ), - unreadCount: 25, - totalUnreadCountByTeam: const {'team-1': 25}, - latestHiddenChannels: const ['messaging:hidden'], ); final reconstructedOwnUser = OwnUser.fromJson(originalOwnUser.toJson()); @@ -490,9 +468,6 @@ void main() { expect(reconstructedOwnUser.blockedUserIds, contains('blocked-1')); expect(reconstructedOwnUser.blockedUserIds, contains('blocked-2')); expect(reconstructedOwnUser.blockedUserIds, contains('blocked-3')); - expect(reconstructedOwnUser.unreadCount, 25); - expect(reconstructedOwnUser.totalUnreadCountByTeam, {'team-1': 25}); - expect(reconstructedOwnUser.latestHiddenChannels, ['messaging:hidden']); // Verify push preferences final pushPrefs = reconstructedOwnUser.pushPreferences; From bcba47cd56bbed7029396405acbd5d47d7f2f700 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 16 Oct 2026 16:35:07 +0200 Subject: [PATCH 7/9] refactor(llc): keep the unmodelled own-user keys in custom Co-Authored-By: Claude Opus 5.5 --- openapi-migration/20-user-updates.md | 7 ++++--- openapi-migration/tool/generate_plan.py | 7 ++++--- packages/stream_chat/lib/src/core/models/own_user.dart | 3 --- .../test/src/client/client_update_users_test.dart | 8 +++++--- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/openapi-migration/20-user-updates.md b/openapi-migration/20-user-updates.md index 01b32af3e4..8a4bca4ad6 100644 --- a/openapi-migration/20-user-updates.md +++ b/openapi-migration/20-user-updates.md @@ -51,9 +51,10 @@ - **`User` and `OwnUser` promote the fields v10 parked in `extraData`, and rename it `custom`** (README rule 9), since these calls are the first to map a user. `User.name`, `image`, `deactivatedAt`, `deletedAt` and `shadowBanned` are fields with persistence columns; - `blockedUserIds` moves up from `OwnUser`. `OwnUser` leaves out `unread_count`, - `total_unread_count_by_team` and `latest_hidden_channels`, which no event keeps current. The mappers - leave `revoke_tokens_issued_before` out; a v1 user keeps it in `custom`, as a v1 member keeps `role`. + `blockedUserIds` moves up from `OwnUser`. `OwnUser` doesn't model `unread_count`, + `total_unread_count_by_team` or `latest_hidden_channels`, which no event keeps current. The mappers + leave the keys the SDK doesn't model out, such as `revoke_tokens_issued_before`; a v1 user keeps them + in `custom`, as a v1 member keeps `role`. Each generated user mapper fills the ones its response carries (`UserResponse` has no `shadowBanned`). - **`User.name` is nullable and has no fallback to `id`;** `User.nameOrId` has it, for a missing or diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 5443651d44..20f8400514 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -1097,9 +1097,10 @@ def match(verb, path): - **`User` and `OwnUser` promote the fields v10 parked in `extraData`, and rename it `custom`** (README rule 9), since these calls are the first to map a user. `User.name`, `image`, `deactivatedAt`, `deletedAt` and `shadowBanned` are fields with persistence columns; - `blockedUserIds` moves up from `OwnUser`. `OwnUser` leaves out `unread_count`, - `total_unread_count_by_team` and `latest_hidden_channels`, which no event keeps current. The mappers - leave `revoke_tokens_issued_before` out; a v1 user keeps it in `custom`, as a v1 member keeps `role`. + `blockedUserIds` moves up from `OwnUser`. `OwnUser` doesn't model `unread_count`, + `total_unread_count_by_team` or `latest_hidden_channels`, which no event keeps current. The mappers + leave the keys the SDK doesn't model out, such as `revoke_tokens_issued_before`; a v1 user keeps them + in `custom`, as a v1 member keeps `role`. Each generated user mapper fills the ones its response carries (`UserResponse` has no `shadowBanned`). - **`User.name` is nullable and has no fallback to `id`;** `User.nameOrId` has it, for a missing or 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 0e3cfd4283..1f62bca70b 100644 --- a/packages/stream_chat/lib/src/core/models/own_user.dart +++ b/packages/stream_chat/lib/src/core/models/own_user.dart @@ -221,9 +221,6 @@ class OwnUser extends User { 'unread_threads', 'push_preferences', 'privacy_settings', - 'unread_count', - 'total_unread_count_by_team', - 'latest_hidden_channels', ...User.topLevelFields, ]; } 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 index bdc25dbb9c..e77d34ff58 100644 --- a/packages/stream_chat/test/src/client/client_update_users_test.dart +++ b/packages/stream_chat/test/src/client/client_update_users_test.dart @@ -74,9 +74,11 @@ void main() { OwnUser.fromJson(const { 'id': 'user-id', 'favorite_color': 'blue', - 'unread_count': 3, - 'total_unread_count_by_team': {'team-a': 1}, - 'latest_hidden_channels': ['messaging:hidden'], + 'devices': [], + 'mutes': [], + 'total_unread_count': 3, + 'unread_channels': 1, + 'unread_threads': 2, }), ); From 6686a745eb30175b6f9a63e923521bcd3dba6e9c Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 19 Oct 2026 09:38:30 +0200 Subject: [PATCH 8/9] refactor(llc): apply the second review pass to the user updates migration Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 18 ++++++---- openapi-migration/20-user-updates.md | 9 +++-- openapi-migration/tool/generate_plan.py | 9 +++-- packages/stream_chat/CHANGELOG.md | 3 +- .../stream_chat/lib/src/core/models/user.dart | 5 +-- packages/stream_chat/test/fixtures/user.json | 1 + .../src/client/client_update_users_test.dart | 3 +- .../test/src/client/users_fixtures.dart | 8 ++--- .../test/src/core/models/own_user_test.dart | 35 +++++++++++++++++++ .../test/src/core/models/user_test.dart | 1 + 10 files changed, 65 insertions(+), 27 deletions(-) diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index 4fa84713e1..03f162e7f0 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -328,6 +328,7 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `ChannelModel.custom` / `Member.custom` entry named like one of the model's own fields | the same entry, as custom data | `retyped` | Kept as the API returns it; `toJson` leaves it out so it cannot overwrite the field | | `User.extraData` / `OwnUser.extraData` | `User.custom` / `OwnUser.custom` | `renamed` | The wire's name for it; also the constructor and `copyWith` arguments | | `User.name` (`String`, the id when the user has none) | `User.nameOrId` | `retyped` | `User.name` is a `String?` that is null when the user has none. Use `nameOrId` wherever a user is named by text, and `name` where a missing name matters, such as initials | +| `User.image` (`null` for an empty image URL) | `User.image` | `retyped` | Returns `''` as given; check `image?.isNotEmpty` where an empty URL means no image | | `user.extraData['name']` / `['image']` / `['deactivated_at']` / `['deleted_at']` / `['shadow_banned']` | `User.name` / `image` / `deactivatedAt` / `deletedAt` / `shadowBanned` | `retyped` | Fields now, not custom data; `copyWith(custom:)` no longer sets them | | `OwnUser.blockedUserIds` | `User.blockedUserIds` | `moved` | Still on `OwnUser`. Empty for anyone but the current user | | `OwnUser.fromUser(user)` reading devices, mutes, unread counts, push preferences and privacy settings out of `user.extraData` | — | `removed` | A `User` no longer carries them; they are left at their defaults | @@ -973,8 +974,11 @@ if (result case Failure(:final error)) report(error); **`updateUser` sends the user's name, image, language, visibility and custom data, and for an own user their privacy settings, 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. +user's devices, mutes and unread counts, were stored as custom data on every call, and an own user with many devices +went over the custom data size limit and failed; that no longer happens. An own user decoded from the connection +still keeps `unread_count`, `total_unread_count_by_team` and `latest_hidden_channels` in `custom`, so passing +`currentUser` back stores them as custom data, as v10 did; remove them from `custom` first if you don't want them +stored. `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, hidden @@ -991,7 +995,7 @@ no longer encodes to JSON or extends `Equatable`; it still compares by value and > **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. +> what the user can change and their custom data as it is. ### Channel and Member Custom Data @@ -1066,10 +1070,10 @@ A string interpolation still compiles with the nullable `name`, so check them by prints `null is typing` for a user with no name. That includes a custom `Translations` or `StreamChatLocalizations` subclass overriding `userTypingText` or `pinnedByUserText`. -**The user fields are fields, not custom data.** `User.image` reads the same and `name` changes as described above; -`deactivatedAt`, `deletedAt` and `shadowBanned` are new fields, and `blockedUserIds` moves up from `OwnUser` to -`User`. `custom` no longer holds any of their keys, and an entry with one of those names in `custom` or in -`copyWith(custom:)` no longer sets the field: +**The user fields are fields, not custom data.** `name` changes as described above, and `User.image` returns an +empty image URL as `''` instead of `null`; `deactivatedAt`, `deletedAt` and `shadowBanned` are new fields, and +`blockedUserIds` moves up from `OwnUser` to `User`. `custom` no longer holds any of their keys, and an entry with +one of those names in `custom` or in `copyWith(custom:)` no longer sets the field: ```dart // v10 diff --git a/openapi-migration/20-user-updates.md b/openapi-migration/20-user-updates.md index 8a4bca4ad6..62a00e24d2 100644 --- a/openapi-migration/20-user-updates.md +++ b/openapi-migration/20-user-updates.md @@ -31,11 +31,10 @@ - **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). An `OwnUser` decoded from the connection reads `unread_count`, - `total_unread_count_by_team` and `latest_hidden_channels` into fields, so they no longer reach the - request 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. + in [04](04-roles-guest-and-app.md). An `OwnUser` decoded from the connection keeps `unread_count`, + `total_unread_count_by_team` and `latest_hidden_channels` in `custom`, so an upsert of it still + stores them as custom data, as v10 did. `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 diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 20f8400514..41eaf2478b 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -1077,11 +1077,10 @@ def match(verb, path): - **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). An `OwnUser` decoded from the connection reads `unread_count`, - `total_unread_count_by_team` and `latest_hidden_channels` into fields, so they no longer reach the - request 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. + in [04](04-roles-guest-and-app.md). An `OwnUser` decoded from the connection keeps `unread_count`, + `total_unread_count_by_team` and `latest_hidden_channels` in `custom`, so an upsert of it still + stores them as custom data, as v10 did. `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 diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index fa7c9ba24d..38101b989f 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -134,6 +134,7 @@ - `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. - `User.extraData` is renamed `custom`, and so is the `extraData` argument of the `User` and `OwnUser` constructors and `copyWith`. - `User.name` is nullable and no longer answers the user's id when they have no name; read `User.nameOrId` for that. +- `User.image` returns an empty image URL as `''` instead of `null`. - Several user values the custom data used to hold, such as `User.name`, `User.image` and `User.deactivatedAt`, are fields instead: `custom` no longer holds them, and an entry with one of their names in `custom` or in `copyWith(custom:)` no longer sets the field. - `User.custom` holds the custom data exactly as the API returns it, and `toJson` leaves out an entry named like one of the user's own fields. - `OwnUser.fromUser` no longer reads devices, mutes, unread counts, push preferences or privacy settings out of the user's custom data; they are left at their defaults. @@ -142,7 +143,7 @@ - `connectGuestUser` no longer copies the user's online status, ban, unread counts, devices and push preferences into the created guest's custom data. -- `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. +- `updateUser` and `updateUsers` no longer store the user's online status, ban, timestamps and blocked users, or an own user's devices, mutes, total unread count and unread channel and thread counts, as custom data, and no longer fail for an own user with many devices. - `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. diff --git a/packages/stream_chat/lib/src/core/models/user.dart b/packages/stream_chat/lib/src/core/models/user.dart index 4604ea4162..9eff0ff883 100644 --- a/packages/stream_chat/lib/src/core/models/user.dart +++ b/packages/stream_chat/lib/src/core/models/user.dart @@ -141,15 +141,12 @@ class User extends Equatable { /// Null while the user exists, or when not known. final DateTime? deletedAt; - /// Whether the user is shadow banned, so that their messages reach no one - /// else without them being told. + /// Whether the user is shadow banned. /// /// Null when not known. final bool? shadowBanned; /// The ids of the users this user blocked. - /// - /// Empty for anyone but the current user. final List blockedUserIds; /// List of users to list of userIds. diff --git a/packages/stream_chat/test/fixtures/user.json b/packages/stream_chat/test/fixtures/user.json index baa536bda6..b70b72c213 100644 --- a/packages/stream_chat/test/fixtures/user.json +++ b/packages/stream_chat/test/fixtures/user.json @@ -8,6 +8,7 @@ "extraDataDoubleTest": 1.1, "extraDataBoolTest": true, "banned": true, + "ban_expires": "2021-08-08T10:39:21.817646Z", "online": true, "teams": ["team-1", "team-2"], "created_at": "2021-08-03T10:39:21.817646Z", 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 index e77d34ff58..8349244c46 100644 --- a/packages/stream_chat/test/src/client/client_update_users_test.dart +++ b/packages/stream_chat/test/src/client/client_update_users_test.dart @@ -65,7 +65,8 @@ void main() { ); test( - 'StreamChatClient.updateUser sends none of the fields an own user decodes from the connection as custom data', + "StreamChatClient.updateUser sends none of an own user's devices, mutes, total unread count, unread channels or " + 'unread threads as custom data', () async { final defaultApi = _defaultApiAnswering(Result.success(updateUsersResponse())); final client = _client(defaultApi); diff --git a/packages/stream_chat/test/src/client/users_fixtures.dart b/packages/stream_chat/test/src/client/users_fixtures.dart index 3d4367cd6f..026a38960e 100644 --- a/packages/stream_chat/test/src/client/users_fixtures.dart +++ b/packages/stream_chat/test/src/client/users_fixtures.dart @@ -14,12 +14,12 @@ User updatedUser() => User( createdAt: DateTime.utc(2026, 9, 1), updatedAt: DateTime.utc(2026, 9, 2), lastActive: DateTime.utc(2026, 9, 3), - online: false, + online: true, banned: true, banExpires: DateTime.utc(2026, 9, 4), teams: const ['team-a'], language: 'de', - invisible: true, + invisible: false, teamsRole: const {'team-a': 'member'}, avgResponseTime: 42, custom: const {'favorite_color': 'green'}, @@ -42,12 +42,12 @@ api.UpdateUsersResponse updateUsersResponse() => api.UpdateUsersResponse( createdAt: DateTime.utc(2026, 9, 1), updatedAt: DateTime.utc(2026, 9, 2), lastActive: DateTime.utc(2026, 9, 3), - online: false, + online: true, banned: true, banExpires: DateTime.utc(2026, 9, 4), teams: const ['team-a'], language: 'de', - invisible: true, + invisible: false, teamsRole: const {'team-a': 'member'}, avgResponseTime: 42, custom: const {'favorite_color': 'green'}, diff --git a/packages/stream_chat/test/src/core/models/own_user_test.dart b/packages/stream_chat/test/src/core/models/own_user_test.dart index 59fe3cf6ac..9507c27a26 100644 --- a/packages/stream_chat/test/src/core/models/own_user_test.dart +++ b/packages/stream_chat/test/src/core/models/own_user_test.dart @@ -257,6 +257,41 @@ void main() { expect(merged.shadowBanned, isNull); }); + test('merge keeps the values the other user leaves out', () { + final user = OwnUser( + id: 'test-user', + role: 'admin', + name: 'Jane', + image: 'https://example.com/jane.png', + createdAt: createdAt, + updatedAt: updatedAt, + lastActive: lastActive, + banExpires: DateTime.utc(2030), + language: 'fr', + invisible: true, + teamsRole: const {'team-a': 'admin'}, + avgResponseTime: 12, + pushPreferences: const PushPreference(chatLevel: ChatLevel.mentions), + privacySettings: const PrivacySettings(typingIndicators: TypingIndicators(enabled: false)), + ); + + final merged = user.merge(OwnUser(id: 'test-user')); + + expect(merged.role, 'admin'); + expect(merged.name, 'Jane'); + expect(merged.image, 'https://example.com/jane.png'); + expect(merged.createdAt, createdAt); + expect(merged.updatedAt, updatedAt); + expect(merged.lastActive, lastActive); + expect(merged.banExpires, DateTime.utc(2030)); + expect(merged.language, 'fr'); + expect(merged.invisible, isTrue); + expect(merged.teamsRole, {'team-a': 'admin'}); + expect(merged.avgResponseTime, 12); + expect(merged.pushPreferences, const PushPreference(chatLevel: ChatLevel.mentions)); + expect(merged.privacySettings, const PrivacySettings(typingIndicators: TypingIndicators(enabled: false))); + }); + test('should parse json with privacy settings correctly', () { final json = { 'id': 'test-user', 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 74104927d6..21894a6919 100644 --- a/packages/stream_chat/test/src/core/models/user_test.dart +++ b/packages/stream_chat/test/src/core/models/user_test.dart @@ -38,6 +38,7 @@ void main() { }); expect(user.online, online); expect(user.banned, banned); + expect(user.banExpires, DateTime.parse('2021-08-08T10:39:21.817646Z')); expect(user.teams, teams); expect(user.createdAt, DateTime.parse(createdAtString)); expect(user.updatedAt, DateTime.parse(updatedAtString)); From 8d6edc23381e657e5ff5105fb538fa1d7a5aa751 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 20 Oct 2026 10:34:19 +0200 Subject: [PATCH 9/9] refactor(llc): align the user update docs with the review rules Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 6 +++--- packages/stream_chat/CHANGELOG.md | 6 +++--- packages/stream_chat/lib/src/client/client.dart | 6 ++++-- .../stream_chat/lib/src/core/models/own_user.dart | 7 ++++--- .../models/request/update_user_partial_request.dart | 6 ++++-- .../core/models/response/update_users_response.dart | 11 +++++++---- packages/stream_chat/lib/src/core/models/user.dart | 10 ++++------ .../stream_chat/test/src/core/models/user_test.dart | 8 ++++++++ 8 files changed, 37 insertions(+), 23 deletions(-) diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index b4dc770377..5d30772970 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -95,7 +95,7 @@ from the spec, so don't subclass them or depend on their private constructors. | [**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`; the channel and member flags it carried are fields | -| [**User Custom Data**](#user-custom-data) | `extraData` is `custom` on `User` and `OwnUser`, and holds the custom data verbatim; `name` is nullable and `nameOrId` falls back to the id; the user fields it carried are fields | +| [**User Custom Data**](#user-custom-data) | `extraData` is `custom` on `User` and `OwnUser`; `name` is nullable and `nameOrId` falls back to the id; the user fields it carried are fields | | _(filled in per feature as PRs land)_ | | --- @@ -1088,8 +1088,8 @@ final renamed = user.copyWith(name: 'Jane'); devices, mutes, unread counts, push preferences and privacy settings back out of the user's `extraData`; a `User` no longer carries them. -**`custom` is the custom data exactly as the API returns it.** An entry named like one of the user's own fields -stays in `custom` and leaves the field alone; `toJson` leaves it out, so it cannot overwrite the field. +**An entry of `custom` named like one of the user's own fields stays in `custom`** and leaves the field alone; +`toJson` leaves it out, so it cannot overwrite the field. > **Why:** the API calls it `custom`, and every migrated model takes that name. A name that answered the id could > not tell an unnamed user from a named one, so initials and sorts read the id as a name. diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 50ac49c0a9..cce82a2852 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -135,8 +135,8 @@ - `User.extraData` is renamed `custom`, and so is the `extraData` argument of the `User` and `OwnUser` constructors and `copyWith`. - `User.name` is nullable and no longer answers the user's id when they have no name; read `User.nameOrId` for that. - `User.image` returns an empty image URL as `''` instead of `null`. -- Several user values the custom data used to hold, such as `User.name`, `User.image` and `User.deactivatedAt`, are fields instead: `custom` no longer holds them, and an entry with one of their names in `custom` or in `copyWith(custom:)` no longer sets the field. -- `User.custom` holds the custom data exactly as the API returns it, and `toJson` leaves out an entry named like one of the user's own fields. +- The user values `custom` used to hold, such as `User.name` and `User.deactivatedAt`, are fields now: `custom` leaves them out, and setting one through `custom` has no effect. +- An entry of `User.custom` named like one of the user's own fields stays in `custom` and leaves the field alone, and `toJson` leaves it out. - `OwnUser.fromUser` no longer reads devices, mutes, unread counts, push preferences or privacy settings out of the user's custom data; they are left at their defaults. - `rawType` is removed from `MessageType`, `AttachmentType`, `PushLevel`, `ChatPreferenceLevel`, `ChatLevel`, `CallLevel` and `RoleType`, as are `ModerationAction.action` and `ChannelCapability.capability`; use the value itself instead. @@ -145,7 +145,7 @@ - `connectGuestUser` no longer copies the user's online status, ban, unread counts, devices and push preferences into the created guest's custom data. - `Channel.custom` no longer returns the data the channel was created with once the channel's custom data is empty. -- `updateUser` and `updateUsers` no longer store the user's online status, ban, timestamps and blocked users, or an own user's devices, mutes, total unread count and unread channel and thread counts, as custom data, and no longer fail for an own user with many devices. +- `updateUser` and `updateUsers` no longer store the user's online status, ban, devices or unread counts as custom data, and no longer fail for an own user with many devices. - `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. diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index a7ee3c252d..230b56a41f 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -1817,8 +1817,10 @@ class StreamChatClient { /// Creates or replaces each of [users], as [updateUser] does for one. Future> updateUsers(List users) => _usersRepository.updateUsers(users); - /// 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. + /// 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, 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 1f62bca70b..f3233a8aa2 100644 --- a/packages/stream_chat/lib/src/core/models/own_user.dart +++ b/packages/stream_chat/lib/src/core/models/own_user.dart @@ -201,7 +201,7 @@ class OwnUser extends User { /// Privacy settings for the user if set. final PrivacySettings? privacySettings; - /// Convert instance to json. + /// Serializes this user to JSON. /// /// Writes the entries of [custom] at the root, leaving out any named like one /// of [topLevelFields]. @@ -210,8 +210,9 @@ class OwnUser extends User { return Serializer.moveFromExtraDataToRoot(_$OwnUserToJson(this), topLevelFields: topLevelFields); } - /// The JSON keys [OwnUser.fromJson] reads into named fields, or leaves out; - /// every other key is kept in [custom]. + /// The JSON keys [OwnUser.fromJson] reads into named fields, or leaves out. + /// + /// Every other key is kept in [custom]. static const topLevelFields = [ 'devices', 'mutes', 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 index e0e130b31a..1a354faffe 100644 --- 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 @@ -2,8 +2,10 @@ 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. +/// 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 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 index 5e94a88843..369ddd80b6 100644 --- 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 @@ -4,10 +4,13 @@ import '../user.dart'; part 'update_users_response.freezed.dart'; -/// The result of creating, updating or partially updating users, returned by -/// [StreamChatClient.updateUser], [StreamChatClient.updateUsers], -/// [StreamChatClient.updateUserPartial] and -/// [StreamChatClient.updateUsersPartial]. +/// The result of creating, updating or partially updating users. +/// +/// See also: +/// +/// * [StreamChatClient.updateUser], [StreamChatClient.updateUsers], +/// [StreamChatClient.updateUserPartial] and +/// [StreamChatClient.updateUsersPartial], which return it. @freezed class UpdateUsersResponse with _$UpdateUsersResponse { /// Creates a new [UpdateUsersResponse]. diff --git a/packages/stream_chat/lib/src/core/models/user.dart b/packages/stream_chat/lib/src/core/models/user.dart index 9eff0ff883..b5cd50a6c5 100644 --- a/packages/stream_chat/lib/src/core/models/user.dart +++ b/packages/stream_chat/lib/src/core/models/user.dart @@ -39,8 +39,9 @@ class User extends Equatable { factory User.fromJson(Map json) => _$UserFromJson(Serializer.moveToExtraDataFromRoot(json, topLevelFields)); - /// The JSON keys [User.fromJson] reads into named fields, or leaves out; - /// every other key is kept in [custom]. + /// The JSON keys [User.fromJson] reads into named fields, or leaves out. + /// + /// Every other key is kept in [custom]. static const topLevelFields = [ 'id', 'name', @@ -125,9 +126,6 @@ class User extends Equatable { final int? avgResponseTime; /// This user's custom data. - /// - /// It may hold a key named like one of the user's own fields; [toJson] leaves - /// such an entry out so it never overwrites the field. @JsonKey(name: 'extra_data') final Map custom; @@ -152,7 +150,7 @@ class User extends Equatable { /// List of users to list of userIds. static List? toIds(List? users) => users?.map((u) => u.id).toList(); - /// Serialize to json. + /// Serializes this user to JSON. /// /// Writes the entries of [custom] at the root, leaving out any named like one /// of [topLevelFields]. 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 21894a6919..127471cc35 100644 --- a/packages/stream_chat/test/src/core/models/user_test.dart +++ b/packages/stream_chat/test/src/core/models/user_test.dart @@ -81,6 +81,14 @@ void main() { expect(json['color'], 'red'); }); + test('User.copyWith does not set a field from custom data named like it', () { + final user = User(id: id, name: name); + + final newUser = user.copyWith(custom: const {'name': 'custom'}); + + expect(newUser.name, name); + }); + test('should serialize to json correctly', () { final user = User( id: id,