diff --git a/.claude/skills/openapi-migration/SKILL.md b/.claude/skills/openapi-migration/SKILL.md index 581b992fa9..d924fcadd5 100644 --- a/.claude/skills/openapi-migration/SKILL.md +++ b/.claude/skills/openapi-migration/SKILL.md @@ -253,9 +253,9 @@ packages/stream_chat/lib/src/ ``` - **The role decides the folder.** An envelope goes in `response/` (`CreateUserGroupResponse`). A type the caller - passes in to shape a request goes in `request/`, whatever its name: `MessageDelivery`, and also - `PartialUpdateUserRequest`, `PaginationParams` and `ThreadOptions` when their groups migrate them. Every other - model stays in `models/` (`UserGroup`). Copy `lib/src/core/models/request/message_delivery.dart`. + passes in to shape a request goes in `request/`, whatever its name: `MessageDelivery`, `UpdateUserPartialRequest`, + and also `PaginationParams` and `ThreadOptions` when their groups migrate them. Every other model stays in + `models/` (`UserGroup`). Copy `lib/src/core/models/request/message_delivery.dart`. - **Models and envelopes** are `@freezed` classes with a const constructor and `@override final` fields. Copy `lib/src/core/models/user_group.dart` and `lib/src/core/models/response/create_user_group_response.dart`. Envelopes carry `required this.duration`, documented with an example value such as `4.21ms`, plus one field per @@ -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 1385be00e2..5d30772970 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -39,7 +39,9 @@ onto Stream's OpenAPI-generated API client. - [Read Receipts](#read-receipts) - [Unread Counts](#unread-counts) - [User Blocking](#user-blocking) + - [User Updates](#user-updates) - [Channel and Member Custom Data](#channel-and-member-custom-data) + - [User Custom Data](#user-custom-data) - [Migration Checklist](#migration-checklist) - [For AI Agents](#for-ai-agents) - [Contributing to this guide](#contributing-to-this-guide) @@ -91,7 +93,9 @@ 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 lose their JSON codecs and compare by value | | [**User Blocking**](#user-blocking) | `blockUser`, `unblockUser` and `getBlockedUsers` (was `queryBlockedUsers`) return a `Result` instead of throwing; their responses are renamed `BlockUsersResponse` and `GetBlockedUsersResponse`, `unblockUser` answers a new `UnblockUsersResponse`, and `UserBlock`'s fields are all non-nullable | +| [**User Updates**](#user-updates) | `updateUser` and `updateUsers` return a `Result` instead of throwing; `partialUpdateUser(s)` becomes `updateUserPartial` / `updateUsersPartial` and `PartialUpdateUserRequest` becomes `UpdateUserPartialRequest`; `updateUser` no longer sends `role`, `teams` or `teamsRole`, and the returned users no longer carry their private fields in `extraData` | | [**Channel and Member Custom Data**](#channel-and-member-custom-data) | `extraData` is `custom` on `ChannelModel`, `Member` and `Channel`; the channel and member flags 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)_ | | --- @@ -140,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` | @@ -304,11 +308,31 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `UserBlock.blockedUser` (`User?`), `.userId` / `.blockedUserId` (`String?`), `.createdAt` (`DateTime?`) | `User`, `String`, `DateTime` — required in the constructor | `retyped` | The server always sends them; drop any `!`, `?.` or `?? …` | | `UserBlock.copyWith(user: User?, …)`, keeping a field when passed `null` | `copyWith(user: User, …)` | `retyped` | Every argument is non-nullable, so a call that passed a nullable value no longer compiles; pass the value or leave the argument out | | `StreamChatApi.user.blockUser` / `unblockUser` / `queryBlockedUsers` | `StreamChatClient.blockUser` / `unblockUser` / `getBlockedUsers` | `removed` | The endpoints moved to the generated client | +| `StreamChatClient.updateUser` / `updateUsers` → `Future` | `Future>` | `retyped` | Returns a `Result` instead of throwing | +| `StreamChatClient.partialUpdateUser` | `StreamChatClient.updateUserPartial` | `renamed` | Same arguments | +| `StreamChatClient.partialUpdateUser` → `Future` | `updateUserPartial` → `Future>` | `retyped` | Returns a `Result` instead of throwing | +| `StreamChatClient.partialUpdateUsers` | `StreamChatClient.updateUsersPartial` | `renamed` | Takes `List` | +| `StreamChatClient.partialUpdateUsers` → `Future` | `updateUsersPartial` → `Future>` | `retyped` | Returns a `Result` instead of throwing | +| `PartialUpdateUserRequest` | `UpdateUserPartialRequest` | `renamed` | Same fields | +| `PartialUpdateUserRequest.toJson` | — | `removed` | `UpdateUserPartialRequest` is a plain class | +| `UpdateUsersResponse.fromJson` | — | `removed` | The response is a plain class; construct it directly | +| `PartialUpdateUserRequest.props` | — | `removed` | No longer an `Equatable`; still compares by value, and gains `copyWith` | +| `UpdateUsersResponse()..users = …` | `UpdateUsersResponse(duration: …, users: …)` | `retyped` | A plain class with a const constructor and final fields | +| `UpdateUsersResponse` identity `==` | value `==`, plus `copyWith` | `retyped` | Two instances with the same fields are now equal | +| `UpdateUsersResponse.duration` (`String?`) | `String` | `retyped` | Always present; drop any `!` or `?? ''` | +| `StreamChatApi.user.updateUsers` / `partialUpdateUsers` | `StreamChatClient.updateUsers` / `updateUsersPartial` | `removed` | The endpoints moved to the generated client | | `ChannelModel.extraData` / `Member.extraData` | `ChannelModel.custom` / `Member.custom` | `renamed` | The wire's name for it; also the constructor and `copyWith` arguments | | `Channel.extraData` / `Channel.extraDataStream` / `Channel(…, extraData:)` / `StreamChatClient.channel(…, extraData:)` | `Channel.custom` / `Channel.customStream` / `custom:` | `renamed` | Follows `ChannelModel.custom`; the setter is renamed too | | `channel.extraData['disabled']` / `['hidden']` / `['muted']` / `['blocked']` / `['truncated_at']` / `['truncated_by']` / `['auto_translation_enabled']` / `['auto_translation_language']` | `ChannelModel.disabled` / `hidden` / `muted` / `blocked` / `truncatedAt` / `truncatedBy` / `autoTranslationEnabled` / `autoTranslationLanguage` | `retyped` | Fields now, not custom data; `copyWith(custom:)` no longer sets them, and `toJson` no longer writes them | | `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, and `toJson` no longer writes 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 | +| `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 | +| `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 | | `MessageType.rawType` / `AttachmentType.rawType` / `PushLevel.rawType` / `ChatPreferenceLevel.rawType` / `ChatLevel.rawType` / `CallLevel.rawType` / `RoleType.rawType`, `ModerationAction.action`, `ChannelCapability.capability` | the value itself | `removed` | Each is a `String`, so use it directly: `message.type.rawType` becomes `message.type` | | _(more added per feature as PRs land)_ | | | | @@ -502,7 +526,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 @@ -928,6 +952,52 @@ 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 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, 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 +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` +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 and their custom data as it is. + ### Channel and Member Custom Data **`extraData` is renamed `custom` on `ChannelModel`, `Member` and `Channel`.** That covers the fields, the @@ -972,6 +1042,58 @@ rest of the custom data. Read them from the model rather than from its JSON. > **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. +`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 +// 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 `userTypingText` or `pinnedByUserText`. + +**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 +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. + +**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. + ### Moderation **Muting, banning and flagging return a `Result` instead of throwing**, on both `StreamChatClient` diff --git a/openapi-migration/04-roles-guest-and-app.md b/openapi-migration/04-roles-guest-and-app.md index b25e18225b..1f7b2bef69 100644 --- a/openapi-migration/04-roles-guest-and-app.md +++ b/openapi-migration/04-roles-guest-and-app.md @@ -77,7 +77,7 @@ It unwraps `GeneralRepository.createGuest`'s `Result` with `getOrElse`, the one sanctioned unwrap inside the SDK: a `Result` on a connect call could be ignored and hide a failed sign-in. - **`createGuest` lives in `GeneralRepository`**, next to `enrichUrl`. Its mappers are in - `lib/src/repository/mapper/user_mapper.dart`, with the `User` mappers they use. + `lib/src/repository/mapper/users_mapper.dart`, with the `User` mappers they use. - **The envelope is the internal `CreateGuestUserResponse`:** a freezed envelope that only `createGuest` returns. It replaces v10's public `ConnectGuestUserResponse`, whose only public reach was `StreamChatApi.guest`, removed together with `GuestApi`. @@ -100,7 +100,7 @@ non-date value). v10's flat response shadowed them, so the same guest connected; confirmed live. - **Not a regression: a custom `ban_expires` that isn't a date** fails the connect in v10 and now alike, because the socket's `me` lets it through into `OwnUser.fromJson`. -- **`User` is not restructured here.** `user_mapper.dart` maps the generated types onto today's `User`, +- **`User` is not restructured here.** `users_mapper.dart` maps the generated types onto today's `User`, following [01-foundation](01-foundation.md). ## Risks diff --git a/openapi-migration/09-users.md b/openapi-migration/09-users.md index 40b8fd9200..89b6703820 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,24 +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). +- `User` and `OwnUser` are public, persisted, and embedded in nearly every other response. This group restructures them, last: the mappers in `users_mapper.dart` already map the generated types onto the current class for every group before it, and stay. See [01-foundation](01-foundation.md). - `PrivacySettings` and the push-preference sub-shapes — decide per type. -- **`User.name` and `image` become fields** (README rule 9): `UserResponse` types them, and the constructor stops writing them into `extraData`; decide whether `name` keeps its fallback to `id`. `deleted_at`, `deactivated_at` and `blocked_user_ids`, which `UserResponse` types and v1 kept in a plain user's `extraData`, become fields or are dropped, never `extraData`. `UserResponse.toModel()` then copies `custom` verbatim into the field, renamed `custom`, and `UserRequestMapper` stops reading `extraData['name']` and `['image']`. -- `UserFilterField.shadowBanned` and `.bypassModeration` read `extraData`, which the generated `UserResponse` has no field to fill. -- `updateUsers` reuses `User.toRequest()`. It is a full upsert, and v10's flattened body filed the user's client state (`online`, `banned`, `created_at` and similar) into the stored custom data on every call, as it did for guests in [04](04-roles-guest-and-app.md). Decide the same way here, for real users rather than fresh guests, and record it in the CHANGELOG. -- The generated `UserRequest` sends explicit `null` for an unset `language` or `invisible`, where v10 left the key out. For a guest create that made no difference; for an upsert of an existing user, confirm live that a `null` does not reset a stored value differently from an omitted key. +- `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` 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 - 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. -- 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` and `deactivated_at`, 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 @@ -65,8 +60,8 @@ name states its subject and behaviour. - [ ] Temporary adapters owned by this group (`DeviceV1JsonConverter`) are deleted and removed from the table in `README.md`. -- [ ] `user_mapper.dart` maps onto the restructured `User`, and its `TODO(openapi-migration)` note is +- [ ] `users_mapper.dart` maps onto the restructured `User`, and its `TODO(openapi-migration)` note is gone. - [ ] A user mapped from `UserResponse` and the same user decoded from v1 / WebSocket JSON compare equal. -- [ ] `user_mapper.dart` has no `_shadowedCustomKeys`: `User.custom` is the wire's `custom`, verbatim, and +- [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/11-channels-and-members.md b/openapi-migration/11-channels-and-members.md index 61bf8fa595..c65c70021a 100644 --- a/openapi-migration/11-channels-and-members.md +++ b/openapi-migration/11-channels-and-members.md @@ -61,7 +61,7 @@ - **The generated channel and member types map onto today's classes.** `lib/src/repository/mapper/channels_mapper.dart` maps `ChannelResponse`, `ChannelConfigWithInfo`, `ChannelMemberResponse`, `Command` and `ChatPreferences` onto the json_serializable `ChannelModel`, - `ChannelConfig`, `Member`, `Command` and `ChatPreferences`, the way `user_mapper.dart` does for + `ChannelConfig`, `Member`, `Command` and `ChatPreferences`, the way `users_mapper.dart` does for `User`. [15](15-partial-updates.md) uses it first; groups 06, 07 and 14 will reuse it. This group restructures those classes and re-points the mapper. - **Every field `ChannelResponse` and `ChannelMemberResponse` declare is reachable on ours,** as a @@ -73,7 +73,7 @@ `Command`'s `createdAt` / `updatedAt`, which ours do not model. - **The mapper copies `custom` verbatim** (README rule 9). A mapped channel or member still differs from a v1 decode in two ways. `truncatedBy`, `createdBy` and each member's user go through - `user_mapper.dart`, which drops the user fields `User` does not model, for group 09 to settle. And + `users_mapper.dart`, which drops the user fields `User` does not model, for group 09 to settle. And a v1 member keeps the wire's `role` in `custom`, where the mapper leaves the typed `role` out. - **The mapper is tested through the client,** by [15](15-partial-updates.md)'s `updateChannelPartial` tests: the first call that returns a channel through the generated client. diff --git a/openapi-migration/19-user-blocking.md b/openapi-migration/19-user-blocking.md index c48f91f789..96160094e8 100644 --- a/openapi-migration/19-user-blocking.md +++ b/openapi-migration/19-user-blocking.md @@ -1,6 +1,6 @@ # 19 — User Blocking -**Goal:** Move blocking and unblocking a user, and listing the blocked users, ahead of [09](09-users.md): they embed `User` only through the existing `user_mapper.dart`, and nothing persists them. +**Goal:** Move blocking and unblocking a user, and listing the blocked users, ahead of [09](09-users.md): they embed `User` only through the existing `users_mapper.dart`, and nothing persists them. **Size:** 0 hand-written method(s) across 0 file(s) → 3 generated operation(s). diff --git a/openapi-migration/20-user-updates.md b/openapi-migration/20-user-updates.md new file mode 100644 index 0000000000..de04a55ece --- /dev/null +++ b/openapi-migration/20-user-updates.md @@ -0,0 +1,92 @@ +# 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). 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 + `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` 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` 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; 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 + 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. +- **`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. + +## 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 + +- [x] Every method above either routes through `DefaultApi` or is listed here as deliberately left + hand-written, with the reason. +- [x] Public methods return `Future>`; no `getOrThrow()` inside the SDK. +- [x] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [x] `copywith_audit.py` was run for every model that loses a v10 `copyWith`, every site it reports is + handled, and the semantics change is in the CHANGELOG and the guide ([README rule 2](README.md#domain-models)). +- [x] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group + persists anything. +- [x] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. +- [x] CHANGELOG entry under `🛑️ Breaking` for each break; PR title `refactor(llc)!:`. +- [x] Decisions recorded in this file, and the status box ticked in `README.md`. +- [x] Public dartdoc follows [`STYLE_GUIDE.md` § Documentation](../STYLE_GUIDE.md#documentation) + and [`EFFECTIVE_DART_DOC.md`](../EFFECTIVE_DART_DOC.md), including on symbols this group + retyped but whose docs it left alone. +- [x] Tests follow [`TESTING.md`](../TESTING.md): no `group` organizing a file by method, each + name states its subject and behaviour. diff --git a/openapi-migration/README.md b/openapi-migration/README.md index bbd8f43056..8f0462c1fe 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -20,7 +20,7 @@ generated operations in scope, the decisions that group has to make, its risks, | [06](06-reminders.md) | Message Reminders | 4 | 4 | ☐ | | [07](07-threads-and-drafts.md) | Threads & Drafts | 7 | 7 | ☐ | | [08](08-moderation-and-blocklists.md) | Moderation & Blocklists | 0 | 34 | ☑ | -| [09](09-users.md) | Users | 5 | 5 | ☐ | +| [09](09-users.md) | Users | 3 | 3 | ☐ | | [10](10-messages.md) | Messages & Search | 14 | 12 | ☐ | | [11](11-channels-and-members.md) | Channels, Members & Sync | 13 | 15 | ☐ | | [12](12-uploads-cdn.md) | Uploads (CDN) | 8 | 8 | ☑ | @@ -31,9 +31,10 @@ generated operations in scope, the decisions that group has to make, its risks, | [17](17-read-receipts.md) | Read Receipts — split out of 11 | 0 | 4 | ☑ | | [18](18-unread-counts.md) | Unread Counts — split out of 09 | 0 | 1 | ☑ | | [19](19-user-blocking.md) | User Blocking — split out of 09 | 0 | 3 | ☑ | +| [20](20-user-updates.md) | User Updates — split out of 09 | 0 | 2 | ☑ | | [21](21-custom-data.md) | Custom Data Check — no model still says `extraData` | — | — | ☐ | -**Coverage:** 53 hand-written methods across 9 files, and all 129 generated operations, each claimed by exactly +**Coverage:** 51 hand-written methods across 9 files, and all 129 generated operations, each claimed by exactly one group. Verified mechanically — see [Keeping this plan honest](#keeping-this-plan-honest). @@ -161,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 +177,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) | -| `user_mapper.dart` (kept, re-pointed) | today's `User`, which still reads and writes JSON | [09](09-users.md) | +| `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`. @@ -208,9 +216,12 @@ surfaces before it reaches `Message` and `ChannelState`: - **18** is the current user's unread counts, split out of 09 because it embeds no `User` and nothing persists it. - **19** is blocking and unblocking users and listing the blocked ones, split out of 09 because it embeds `User` only through the existing mappers and nothing persists it. +- **20** is creating, updating and partially updating users, split out of 09 because its response maps onto the + current `User`. 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 `user_mapper.dart` onto today's `User`; 09 migrates the +- **09** is last. Every group before it maps users through `users_mapper.dart` onto today's `User`; 09 migrates the user endpoints and restructures `User` and `OwnUser` themselves, when every parent that embeds them has moved. - **21** checks the custom data once every group has run: each model's custom data is named `custom` and is the wire's `custom`, with no mapper filtering it and no typed field parked in it. Each group does that for the diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 22462579d9..b02192937d 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -297,7 +297,7 @@ def match(verb, path): It unwraps `GeneralRepository.createGuest`'s `Result` with `getOrElse`, the one sanctioned unwrap inside the SDK: a `Result` on a connect call could be ignored and hide a failed sign-in. - **`createGuest` lives in `GeneralRepository`**, next to `enrichUrl`. Its mappers are in - `lib/src/repository/mapper/user_mapper.dart`, with the `User` mappers they use. + `lib/src/repository/mapper/users_mapper.dart`, with the `User` mappers they use. - **The envelope is the internal `CreateGuestUserResponse`:** a freezed envelope that only `createGuest` returns. It replaces v10's public `ConnectGuestUserResponse`, whose only public reach was `StreamChatApi.guest`, removed together with `GuestApi`. @@ -320,7 +320,7 @@ def match(verb, path): non-date value). v10's flat response shadowed them, so the same guest connected; confirmed live. - **Not a regression: a custom `ban_expires` that isn't a date** fails the connect in v10 and now alike, because the socket's `me` lets it through into `OwnUser.fromJson`. - - **`User` is not restructured here.** `user_mapper.dart` maps the generated types onto today's `User`, + - **`User` is not restructured here.** `users_mapper.dart` maps the generated types onto today's `User`, following [01-foundation](01-foundation.md). """), done=DONE.replace('- [ ]', '- [x]'), @@ -586,50 +586,45 @@ def match(verb, path): dict( num='09', slug='users', title='Users', hand=['user_api.dart'], - match=owns('/api/v2/users', unless=('/api/v2/users/block', '/api/v2/users/unblock')), + match=owns('/api/v2/users', unless=('/api/v2/users/block', '/api/v2/users/unblock'), + unless_ops=('POST /api/v2/users', 'PATCH /api/v2/users')), goal='`User` is the most widely referenced public model in the SDK; this is where keep-vs-adopt costs the ' - 'most. [18](18-unread-counts.md) split the current user\'s unread counts out of it, and ' - '[19](19-user-blocking.md) blocking users.', + 'most. [18](18-unread-counts.md) split the current user\'s unread counts out of it, ' + '[19](19-user-blocking.md) blocking users, and [20](20-user-updates.md) updating users.', decisions=[ '`User` and `OwnUser` are public, persisted, and embedded in nearly every other response. This group ' - 'restructures them, last: the mappers in `user_mapper.dart` already map the generated types onto the ' + 'restructures them, last: the mappers in `users_mapper.dart` already map the generated types onto the ' 'current class for every group before it, and stay. See [01-foundation](01-foundation.md).', '`PrivacySettings` and the push-preference sub-shapes — decide per type.', - '**`User.name` and `image` become fields** (README rule 9): `UserResponse` types them, and the constructor ' - 'stops writing them into `extraData`; decide whether `name` keeps its fallback to `id`. `deleted_at`, ' - '`deactivated_at` and `blocked_user_ids`, which `UserResponse` types and v1 kept in a plain user\'s ' - '`extraData`, become fields or are dropped, never `extraData`. ' - '`UserResponse.toModel()` then copies `custom` verbatim into the field, renamed `custom`, and ' - '`UserRequestMapper` stops reading `extraData[\'name\']` and `[\'image\']`.', - '`UserFilterField.shadowBanned` and `.bypassModeration` read `extraData`, which the generated ' - '`UserResponse` has no field to fill.', - '`updateUsers` reuses `User.toRequest()`. It is a full upsert, and v10\'s flattened body filed the ' - 'user\'s client state (`online`, `banned`, `created_at` and similar) into the stored custom data on ' - 'every call, as it did for guests in [04](04-roles-guest-and-app.md). Decide the same way here, for real ' - 'users rather than fresh guests, and record it in the CHANGELOG.', - 'The generated `UserRequest` sends explicit `null` for an unset `language` or `invisible`, where v10 ' - 'left the key out. For a guest create that made no difference; for an upsert of an existing user, ' - 'confirm live that a `null` does not reset a stored value differently from an omitted key.', + '`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` 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=[ '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.', - '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` and `deactivated_at`, ' - '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' ' the table in `README.md`.\n' - '- [ ] `user_mapper.dart` maps onto the restructured `User`, and its `TODO(openapi-migration)` note is\n' + '- [ ] `users_mapper.dart` maps onto the restructured `User`, and its `TODO(openapi-migration)` note is\n' ' gone.\n' '- [ ] A user mapped from `UserResponse` and the same user decoded from v1 / WebSocket JSON compare equal.\n' - '- [ ] `user_mapper.dart` has no `_shadowedCustomKeys`: `User.custom` is the wire\'s `custom`, verbatim, and\n' + '- [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' ), ), @@ -731,7 +726,7 @@ def match(verb, path): - **The generated channel and member types map onto today's classes.** `lib/src/repository/mapper/channels_mapper.dart` maps `ChannelResponse`, `ChannelConfigWithInfo`, `ChannelMemberResponse`, `Command` and `ChatPreferences` onto the json_serializable `ChannelModel`, - `ChannelConfig`, `Member`, `Command` and `ChatPreferences`, the way `user_mapper.dart` does for + `ChannelConfig`, `Member`, `Command` and `ChatPreferences`, the way `users_mapper.dart` does for `User`. [15](15-partial-updates.md) uses it first; groups 06, 07 and 14 will reuse it. This group restructures those classes and re-points the mapper. - **Every field `ChannelResponse` and `ChannelMemberResponse` declare is reachable on ours,** as a @@ -743,7 +738,7 @@ def match(verb, path): `Command`'s `createdAt` / `updatedAt`, which ours do not model. - **The mapper copies `custom` verbatim** (README rule 9). A mapped channel or member still differs from a v1 decode in two ways. `truncatedBy`, `createdBy` and each member's user go through - `user_mapper.dart`, which drops the user fields `User` does not model, for group 09 to settle. And + `users_mapper.dart`, which drops the user fields `User` does not model, for group 09 to settle. And a v1 member keeps the wire's `role` in `custom`, where the mapper leaves the typed `role` out. - **The mapper is tested through the client,** by [15](15-partial-updates.md)'s `updateChannelPartial` tests: the first call that returns a channel through the generated client. @@ -1042,7 +1037,7 @@ def match(verb, path): hand=[], match=only_ops('POST /api/v2/users/block', 'GET /api/v2/users/block', 'POST /api/v2/users/unblock'), goal='Move blocking and unblocking a user, and listing the blocked users, ahead of [09](09-users.md): they ' - 'embed `User` only through the existing `user_mapper.dart`, and nothing persists them.', + 'embed `User` only through the existing `users_mapper.dart`, and nothing persists them.', decisions=[], taken=textwrap.dedent("""\ - **Split out of [09](09-users.md), ahead of it, as one slice.** Blocking and unblocking are a pair, @@ -1073,6 +1068,66 @@ def match(verb, path): ], 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). 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 + `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` 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` 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; 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 + 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. + - **`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. + """), + 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( num='21', slug='custom-data', title='Custom Data Check', hand=[], diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 31f0294dac..cce82a2852 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -121,16 +121,32 @@ - `UserBlock` no longer decodes from or encodes to JSON and no longer extends `Equatable`, so `props` is removed; it still compares by value. - `UserBlock.blockedUser`, `userId`, `blockedUserId` and `createdAt` are required and non-nullable, and `UserBlock.copyWith` takes every field as a non-nullable value. - `StreamChatApi.user`'s blocking methods are removed; use `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, 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`. - The channel and member values `custom` used to hold, such as `ChannelModel.hidden` and `Member.status`, are fields now: `custom` and `toJson` leave them out, and setting one through `custom` has no effect. - An entry of `ChannelModel.custom` or `Member.custom` named like one of the model's own fields stays in `custom` and leaves the field alone, and `toJson` leaves it out. +- `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`. +- 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. 🐞 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. - `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, 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. - A message that fails because its attachments did not upload now says which ones and what each reported, instead of `Failed to upload one or more attachments`. @@ -157,6 +173,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`; 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 db9cc47526..230b56a41f 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -60,6 +60,7 @@ import '../core/models/push_preference.dart'; import '../core/models/push_provider.dart'; import '../core/models/reaction.dart'; import '../core/models/request/message_delivery.dart'; +import '../core/models/request/update_user_partial_request.dart'; import '../core/models/response/add_user_group_members_response.dart'; import '../core/models/response/app_settings_response.dart'; import '../core/models/response/block_users_response.dart'; @@ -87,6 +88,7 @@ import '../core/models/response/unblock_users_response.dart'; import '../core/models/response/update_channel_partial_response.dart'; import '../core/models/response/update_member_partial_response.dart'; import '../core/models/response/update_user_group_response.dart'; +import '../core/models/response/update_users_response.dart'; import '../core/models/role_type.dart'; import '../core/models/thread.dart'; import '../core/models/user.dart'; @@ -1805,32 +1807,30 @@ class StreamChatClient { String? prev, }) => _pollsRepository.queryPollVotes(pollId, filter: filter, sort: sort, limit: limit, next: next, prev: prev); - /// Update or Create the given user object. - Future updateUser(User user) => updateUsers([user]); + /// Creates [user], or replaces the data of the existing user with the same + /// id. + /// + /// This cannot change the user's role, teams or team roles. To change only + /// some fields, consider [updateUserPartial]. + Future> updateUser(User user) => updateUsers([user]); - /// Batch update a list of users - Future updateUsers(List users) => _chatApi.user.updateUsers(users); + /// Creates or replaces each of [users], as [updateUser] does for one. + Future> updateUsers(List users) => _usersRepository.updateUsers(users); - /// Partially update the given user with [id]. - /// Use [set] to define values to be set. - /// Use [unset] to define values to be unset. - Future partialUpdateUser( + /// Partially updates the user with [id]. + /// + /// Sets the fields in [set] and removes the fields named in [unset], leaving + /// every other field as it is. + Future> updateUserPartial( String id, { Map? set, List? unset, - }) { - final user = PartialUpdateUserRequest( - id: id, - set: set, - unset: unset, - ); - return partialUpdateUsers([user]); - } + }) => updateUsersPartial([UpdateUserPartialRequest(id: id, set: set, unset: unset)]); - /// Batch partial updates the [users]. - Future partialUpdateUsers( - List users, - ) => _chatApi.user.partialUpdateUsers(users); + /// Partially updates several users at once, applying each of [updates] to the + /// user it names. + Future> updateUsersPartial(List updates) => + _usersRepository.updateUsersPartial(updates); final _userBlockLock = Lock(); diff --git a/packages/stream_chat/lib/src/core/api/requests.dart b/packages/stream_chat/lib/src/core/api/requests.dart index 26ea16f9d7..c566daf837 100644 --- a/packages/stream_chat/lib/src/core/api/requests.dart +++ b/packages/stream_chat/lib/src/core/api/requests.dart @@ -141,32 +141,6 @@ class PaginationParams extends Equatable { ]; } -/// Request model for the [client.partialUpdateUser] api call. -@JsonSerializable(createFactory: false) -class PartialUpdateUserRequest extends Equatable { - /// Creates a new PartialUpdateUserRequest instance. - const PartialUpdateUserRequest({ - required this.id, - this.set, - this.unset, - }); - - /// User ID. - final String id; - - /// Fields to set. - final Map? set; - - /// Fields to unset. - final List? unset; - - /// Serialize model to json - Map toJson() => _$PartialUpdateUserRequestToJson(this); - - @override - List get props => [id, set, unset]; -} - /// {@template threadOptions} /// Options for querying threads. /// {@endtemplate} diff --git a/packages/stream_chat/lib/src/core/api/requests.g.dart b/packages/stream_chat/lib/src/core/api/requests.g.dart index 8d2380876a..73d38b0d70 100644 --- a/packages/stream_chat/lib/src/core/api/requests.g.dart +++ b/packages/stream_chat/lib/src/core/api/requests.g.dart @@ -42,17 +42,6 @@ Map _$PaginationParamsToJson(PaginationParams instance) => _$PartialUpdateUserRequestToJson( - PartialUpdateUserRequest instance, -) => { - 'stringify': instance.stringify, - 'hash_code': instance.hashCode, - 'id': instance.id, - 'set': instance.set, - 'unset': instance.unset, - 'props': instance.props, -}; - Map _$ThreadOptionsToJson(ThreadOptions instance) => { 'stringify': instance.stringify, 'hash_code': instance.hashCode, diff --git a/packages/stream_chat/lib/src/core/api/responses.dart b/packages/stream_chat/lib/src/core/api/responses.dart index fef131e992..3e5647a14e 100644 --- a/packages/stream_chat/lib/src/core/api/responses.dart +++ b/packages/stream_chat/lib/src/core/api/responses.dart @@ -157,17 +157,6 @@ class SendReactionResponse extends MessageResponse { static SendReactionResponse fromJson(Map json) => _$SendReactionResponseFromJson(json); } -/// Model response for [StreamChatClient.updateUser] api call -@JsonSerializable(createToJson: false) -class UpdateUsersResponse extends _BaseResponse { - /// Updated users - @JsonKey(defaultValue: {}) - late Map users; - - /// Create a new instance from a json - static UpdateUsersResponse fromJson(Map json) => _$UpdateUsersResponseFromJson(json); -} - /// Base Model response for message based api calls. class MessageResponse extends _BaseResponse { /// Message returned by the api call diff --git a/packages/stream_chat/lib/src/core/api/responses.g.dart b/packages/stream_chat/lib/src/core/api/responses.g.dart index fb67c5d102..f9cc331b51 100644 --- a/packages/stream_chat/lib/src/core/api/responses.g.dart +++ b/packages/stream_chat/lib/src/core/api/responses.g.dart @@ -82,14 +82,6 @@ SendReactionResponse _$SendReactionResponseFromJson( ..message = Message.fromJson(json['message'] as Map) ..reaction = Reaction.fromJson(json['reaction'] as Map); -UpdateUsersResponse _$UpdateUsersResponseFromJson(Map json) => UpdateUsersResponse() - ..duration = json['duration'] as String? - ..users = - (json['users'] as Map?)?.map( - (k, e) => MapEntry(k, User.fromJson(e as Map)), - ) ?? - {}; - UpdateMessageResponse _$UpdateMessageResponseFromJson( Map json, ) => UpdateMessageResponse() diff --git a/packages/stream_chat/lib/src/core/api/user_api.dart b/packages/stream_chat/lib/src/core/api/user_api.dart index 8940d993fa..d5c6e4557a 100644 --- a/packages/stream_chat/lib/src/core/api/user_api.dart +++ b/packages/stream_chat/lib/src/core/api/user_api.dart @@ -38,32 +38,6 @@ class UserApi { return QueryUsersResponse.fromJson(response.data); } - /// Batch update a list of users - Future updateUsers( - List users, - ) async { - final response = await _client.post( - '/users', - data: { - 'users': {for (final user in users) user.id: user}, - }, - ); - return UpdateUsersResponse.fromJson(response.data); - } - - /// Batch partial update of [users]. - Future partialUpdateUsers( - List users, - ) async { - final response = await _client.patch( - '/users', - data: { - 'users': users, - }, - ); - return UpdateUsersResponse.fromJson(response.data); - } - /// Retrieves all the active live locations of the current user. Future getActiveLiveLocations() async { final response = await _client.get( diff --git a/packages/stream_chat/lib/src/core/models/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 bb3abc0cd6..bceffb55bd 100644 --- a/packages/stream_chat/lib/src/core/models/member.dart +++ b/packages/stream_chat/lib/src/core/models/member.dart @@ -341,7 +341,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. @@ -349,7 +349,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. @@ -446,11 +446,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 31febe3d45..24706b5c8e 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 d509f02684..f3233a8aa2 100644 --- a/packages/stream_chat/lib/src/core/models/own_user.dart +++ b/packages/stream_chat/lib/src/core/models/own_user.dart @@ -19,7 +19,7 @@ class OwnUser extends User { this.unreadChannels = 0, this.channelMutes = const [], this.unreadThreads = 0, - this.blockedUserIds = const [], + super.blockedUserIds, this.pushPreferences, this.privacySettings, required super.id, @@ -30,7 +30,7 @@ class OwnUser extends User { super.updatedAt, super.lastActive, super.online, - super.extraData, + super.custom, super.banned, super.banExpires, super.teams, @@ -38,6 +38,9 @@ class OwnUser extends User { super.invisible, super.teamsRole, super.avgResponseTime, + super.deactivatedAt, + super.deletedAt, + super.shadowBanned, }); /// Create a new instance from json. @@ -45,8 +48,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 @@ -59,7 +89,7 @@ class OwnUser extends User { DateTime? updatedAt, DateTime? lastActive, bool? online, - Map? extraData, + Map? custom, bool? banned, DateTime? banExpires, List? teams, @@ -76,22 +106,21 @@ class OwnUser extends User { int? avgResponseTime, PushPreference? pushPreferences, PrivacySettings? privacySettings, + DateTime? deactivatedAt, + DateTime? deletedAt, + bool? shadowBanned, }) => 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, @@ -106,24 +135,27 @@ 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, ); /// Returns a new [OwnUser] that is a combination of this ownUser /// and the given [other] ownUser. OwnUser merge(OwnUser? other) { if (other == null) return this; - return copyWith( + return OwnUser( id: other.id, - role: other.role, - // Using extraData value in order to not use id as name. - name: other.extraData['name'] as String?, - 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, - extraData: other.extraData, - lastActive: other.lastActive, + custom: other.custom, + lastActive: other.lastActive ?? lastActive, mutes: other.mutes, online: other.online, teams: other.teams, @@ -131,13 +163,16 @@ 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, ); } @@ -160,32 +195,31 @@ 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; - /// 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]. @override Map toJson() { - return Serializer.moveFromExtraDataToRoot(_$OwnUserToJson(this)); + return Serializer.moveFromExtraDataToRoot(_$OwnUserToJson(this), topLevelFields: topLevelFields); } - /// Known top level fields. + /// The JSON keys [OwnUser.fromJson] reads into named fields, or leaves out. /// - /// Useful for [Serializer] methods. + /// Every other key is kept in [custom]. static const topLevelFields = [ 'devices', 'mutes', + 'channel_mutes', 'total_unread_count', 'unread_channels', - 'channel_mutes', 'unread_threads', - 'blocked_user_ids', 'push_preferences', 'privacy_settings', ...User.topLevelFields, 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..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 @@ -28,11 +28,13 @@ OwnUser _$OwnUserFromJson(Map json) => OwnUser( : PrivacySettings.fromJson(json['privacy_settings'] as Map), 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 +42,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 +63,17 @@ 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(), }; 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..1a354faffe --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/request/update_user_partial_request.dart @@ -0,0 +1,31 @@ +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..369ddd80b6 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/update_users_response.dart @@ -0,0 +1,29 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../user.dart'; + +part 'update_users_response.freezed.dart'; + +/// 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]. + 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..b5cd50a6c5 100644 --- a/packages/stream_chat/lib/src/core/models/user.dart +++ b/packages/stream_chat/lib/src/core/models/user.dart @@ -12,33 +12,11 @@ part 'user.g.dart'; @JsonSerializable(includeIfNull: false) 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} User({ required this.id, this.role, - String? name, - String? image, + this.name, + this.image, this.createdAt, this.updatedAt, this.lastActive, @@ -50,23 +28,24 @@ class User extends Equatable { this.invisible, this.teamsRole, this.avgResponseTime, - Map extraData = const {}, - }) : // For backwards compatibility, set 'name', 'image' in [extraData]. - extraData = { - ...extraData, - if (name != null) 'name': name, - if (image != null) 'image': image, - }; + Map custom = const {}, + this.deactivatedAt, + this.deletedAt, + this.shadowBanned, + this.blockedUserIds = const [], + }) : custom = {...custom}; /// Create a new instance from json. factory User.fromJson(Map json) => _$UserFromJson(Serializer.moveToExtraDataFromRoot(json, topLevelFields)); - /// Known top level fields. + /// The JSON keys [User.fromJson] reads into named fields, or leaves out. /// - /// Useful for [Serializer] methods. + /// Every other key is kept in [custom]. static const topLevelFields = [ 'id', + 'name', + 'image', 'role', 'created_at', 'updated_at', @@ -79,32 +58,34 @@ class User extends Equatable { 'invisible', 'teams_role', 'avg_response_time', + 'deactivated_at', + 'deleted_at', + 'shadow_banned', + 'blocked_user_ids', ]; /// 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; - - return id; - } + /// Null when the user has none. + final String? name; - /// Shortcut for user image. + /// The user's [name], or their [id] when the name is missing or empty. /// - /// {@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 => switch (name) { + final name? when name.isNotEmpty => 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; @@ -144,15 +125,38 @@ 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. + @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. + final DateTime? deactivatedAt; + + /// The date at which the user was deleted. + /// + /// Null while the user exists, or when not known. + final DateTime? deletedAt; + + /// Whether the user is shadow banned. + /// + /// Null when not known. + final bool? shadowBanned; + + /// The ids of the users this user blocked. + final List blockedUserIds; /// 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]. Map toJson() => Serializer.moveFromExtraDataToRoot( _$UserToJson(this), + topLevelFields: topLevelFields, ); /// Creates a copy of [User] with specified attributes overridden. @@ -165,7 +169,7 @@ class User extends Equatable { DateTime? updatedAt, DateTime? lastActive, bool? online, - Map? extraData, + Map? custom, bool? banned, DateTime? banExpires, List? teams, @@ -173,20 +177,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, @@ -194,15 +198,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, @@ -210,6 +220,10 @@ class User extends Equatable { invisible, teamsRole, avgResponseTime, + deactivatedAt, + deletedAt, + shadowBanned, + blockedUserIds, ]; } @@ -231,14 +245,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. @@ -262,7 +276,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. @@ -270,7 +284,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. @@ -302,7 +316,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. @@ -310,7 +324,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. @@ -389,11 +403,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. @@ -422,10 +436,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/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 e24023cf71..5f112466a7 100644 --- a/packages/stream_chat/lib/src/repository/mapper/channels_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/channels_mapper.dart @@ -15,7 +15,7 @@ import '../../core/models/response/mark_read_response.dart'; import '../../core/models/response/show_channel_response.dart'; import '../../core/models/response/update_channel_partial_response.dart'; import '../../core/models/response/update_member_partial_response.dart'; -import 'user_mapper.dart'; +import 'users_mapper.dart'; // TODO(openapi-migration): re-point these mappers in group 11. diff --git a/packages/stream_chat/lib/src/repository/mapper/polls_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/polls_mapper.dart index ff36b3be76..c709c24e80 100644 --- a/packages/stream_chat/lib/src/repository/mapper/polls_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/polls_mapper.dart @@ -8,7 +8,7 @@ import '../../core/models/response/poll_vote_response.dart'; import '../../core/models/response/query_poll_votes_response.dart'; import '../../core/models/response/query_polls_response.dart'; import '../../core/models/voting_visibility.dart'; -import 'user_mapper.dart'; +import 'users_mapper.dart'; /// Maps a generated [api.PollResponseData] to a [Poll]. extension PollResponseDataMapper on api.PollResponseData { diff --git a/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/users_mapper.dart similarity index 76% 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 c7076222d0..6df7a60edf 100644 --- a/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/users_mapper.dart @@ -1,32 +1,48 @@ 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'; 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', - }; + /// Converts this response into a [User]. + User toModel() => User( + id: id, + role: role, + name: name, + image: image, + createdAt: createdAt, + updatedAt: updatedAt, + lastActive: lastActive, + online: online, + banned: banned, + teams: teams, + language: language, + teamsRole: teamsRole, + avgResponseTime: avgResponseTime, + 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, @@ -37,11 +53,17 @@ extension UserResponseMapper on api.UserResponse { lastActive: lastActive, online: online, banned: banned, + banExpires: banExpires, teams: teams, language: language, + invisible: invisible, teamsRole: teamsRole, avgResponseTime: avgResponseTime, - extraData: {...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), + custom: custom, + deactivatedAt: deactivatedAt, + deletedAt: deletedAt, + shadowBanned: shadowBanned, + blockedUserIds: blockedUserIds, ); } @@ -49,28 +71,25 @@ extension UserResponseMapper on api.UserResponse { 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, ); } /// 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( @@ -184,3 +203,19 @@ 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 2061b3d21c..2d86ad0eaf 100644 --- a/packages/stream_chat/lib/src/repository/users_repository.dart +++ b/packages/stream_chat/lib/src/repository/users_repository.dart @@ -1,11 +1,14 @@ import 'package:stream_core/stream_core.dart' show PatternMatching, Result; import '../../open_api/api.dart' as api; +import '../core/models/request/update_user_partial_request.dart'; import '../core/models/response/block_users_response.dart'; import '../core/models/response/get_blocked_users_response.dart'; import '../core/models/response/get_unread_count_response.dart'; import '../core/models/response/unblock_users_response.dart'; -import 'mapper/user_mapper.dart'; +import '../core/models/response/update_users_response.dart'; +import '../core/models/user.dart'; +import 'mapper/users_mapper.dart'; /// Repository dedicated to user operations. class UsersRepository { @@ -37,4 +40,24 @@ 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/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..6276a7a7d4 100644 --- a/packages/stream_chat/lib/src/ws/events/event.dart +++ b/packages/stream_chat/lib/src/ws/events/event.dart @@ -62,12 +62,20 @@ 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, + ), + ); + + // 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, + }; + } /// The type of the event /// [EventType] contains some predefined constant types @@ -98,6 +106,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/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index 86de999612..22484c7594 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -120,6 +120,7 @@ export 'src/core/models/reaction.dart'; export 'src/core/models/reaction_group.dart'; export 'src/core/models/read.dart'; export 'src/core/models/request/message_delivery.dart'; +export 'src/core/models/request/update_user_partial_request.dart'; export 'src/core/models/response/add_user_group_members_response.dart'; export 'src/core/models/response/app_settings_response.dart'; export 'src/core/models/response/block_users_response.dart'; @@ -150,6 +151,7 @@ export 'src/core/models/response/unmute_users_response.dart'; export 'src/core/models/response/update_channel_partial_response.dart'; export 'src/core/models/response/update_member_partial_response.dart'; export 'src/core/models/response/update_user_group_response.dart'; +export 'src/core/models/response/update_users_response.dart'; export 'src/core/models/role.dart'; export 'src/core/models/role_type.dart'; export 'src/core/models/thread.dart'; diff --git a/packages/stream_chat/test/fixtures/user.json b/packages/stream_chat/test/fixtures/user.json index 0e56fefa0b..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", @@ -16,5 +17,9 @@ "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, + "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 cffb9d64b5..893ec540da 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,31 +68,6 @@ void main() { ); }); - test( - "StreamChatClient.connectGuestUser keeps custom fields named like the user's own fields out of the created guest", - () async { - final response = _createGuestResponse( - custom: const { - 'favorite_color': 'green', - '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', - }, - ); - final client = _client(_defaultApiAnswering(Result.success(response))); - - final guest = await client.connectGuestUser(_fullyPopulatedUser(), connectWebSocket: false); - - expect(guest.extraData, { - 'favorite_color': 'green', - 'name': 'Created Name', - 'image': 'https://example.com/created.png', - }); - }, - ); - test('StreamChatClient.connectGuestUser connects the socket as the created guest', () async { final defaultApi = _defaultApiAnswering(Result.success(_createGuestResponse())); final client = _client( @@ -212,7 +164,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( @@ -281,8 +233,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 dead2949d5..c489a05fd5 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -2983,64 +2983,6 @@ void main() { verifyNoMoreInteractions(fakeChatApi.message); }); - test('`.updateUser`', () async { - final user = User( - id: 'test-user-id', - extraData: const {'name': 'test-user'}, - ); - - when( - () => fakeChatApi.user.updateUsers([user]), - ).thenAnswer((_) async => UpdateUsersResponse()..users = {user.id: user}); - - final res = await client.updateUser(user); - - expect(res, isNotNull); - expect(res.users, {user.id: user}); - - verify(() => fakeChatApi.user.updateUsers([user])).called(1); - verifyNoMoreInteractions(fakeChatApi.user); - }); - - test('`.partialUpdateUser`', () async { - const userId = 'test-user-id'; - - final set = {'color': 'yellow'}; - final unset = []; - - final partialUpdateRequest = PartialUpdateUserRequest( - id: userId, - set: set, - unset: unset, - ); - - final updatedUser = User( - id: userId, - extraData: {'color': set['color']}, - ); - - when(() => fakeChatApi.user.partialUpdateUsers([partialUpdateRequest])).thenAnswer( - (_) async => UpdateUsersResponse() - ..users = { - updatedUser.id: updatedUser, - }, - ); - - final res = await client.partialUpdateUser( - userId, - set: set, - unset: unset, - ); - - expect(res, isNotNull); - expect(res.users, {updatedUser.id: updatedUser}); - - verify( - () => fakeChatApi.user.partialUpdateUsers([partialUpdateRequest]), - ).called(1); - verifyNoMoreInteractions(fakeChatApi.user); - }); - test('`.getActiveLiveLocations`', () async { final locations = [ Location( diff --git a/packages/stream_chat/test/src/client/client_update_users_partial_test.dart b/packages/stream_chat/test/src/client/client_update_users_partial_test.dart new file mode 100644 index 0000000000..cc59ba6dee --- /dev/null +++ b/packages/stream_chat/test/src/client/client_update_users_partial_test.dart @@ -0,0 +1,88 @@ +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'; +import 'users_fixtures.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; +} 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..8349244c46 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_update_users_test.dart @@ -0,0 +1,149 @@ +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'; +import 'users_fixtures.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, + custom: 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 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); + + await client.updateUser( + OwnUser.fromJson(const { + 'id': 'user-id', + 'favorite_color': 'blue', + 'devices': [], + 'mutes': [], + 'total_unread_count': 3, + 'unread_channels': 1, + 'unread_threads': 2, + }), + ); + + verify( + () => defaultApi.updateUsers( + updateUsersRequest: const api.UpdateUsersRequest( + users: { + 'user-id': api.UserRequest(id: 'user-id', custom: {'favorite_color': 'blue'}), + }, + ), + ), + ).called(1); + }, + ); + + 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; +} 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..a983e79216 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_user_updated_test.dart @@ -0,0 +1,38 @@ +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); + }); +} + +// 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/poll_fixtures.dart b/packages/stream_chat/test/src/client/poll_fixtures.dart index aa9ca30da1..f41cf5d578 100644 --- a/packages/stream_chat/test/src/client/poll_fixtures.dart +++ b/packages/stream_chat/test/src/client/poll_fixtures.dart @@ -40,7 +40,10 @@ final voter = User( language: 'en', teamsRole: const {'jedi': 'admin'}, avgResponseTime: 12, - extraData: const {'favorite_color': 'blue'}, + deactivatedAt: DateTime.utc(2024, 1, 4), + deletedAt: DateTime.utc(2024, 1, 5), + custom: const {'favorite_color': 'blue'}, + blockedUserIds: const ['blocked-user'], ); final generatedVote = api.PollVoteResponseData( 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..65c1fa8982 --- /dev/null +++ b/packages/stream_chat/test/src/client/users_fixtures.dart @@ -0,0 +1,68 @@ +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: 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 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: 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'], + 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/event_test.dart b/packages/stream_chat/test/src/core/models/event_test.dart index e15a01ca5e..a9e7883897 100644 --- a/packages/stream_chat/test/src/core/models/event_test.dart +++ b/packages/stream_chat/test/src/core/models/event_test.dart @@ -125,7 +125,7 @@ void main() { 'unread_threads': 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..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 @@ -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,16 @@ void main() { banned: true, teams: ['team1', 'team2'], language: 'fr', + 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 +179,9 @@ void main() { expect(newUser.banned, true); expect(newUser.teams, ['team1', 'team2']); expect(newUser.language, 'fr'); + expect(newUser.deactivatedAt, DateTime.utc(2022)); + expect(newUser.deletedAt, DateTime.utc(2023)); + expect(newUser.shadowBanned, isTrue); }); test('merge', () { @@ -181,8 +190,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 +207,9 @@ void main() { banned: true, teams: const ['team1', 'team2'], language: 'fr', + deactivatedAt: DateTime.utc(2022), + deletedAt: DateTime.utc(2023), + shadowBanned: true, ), ); @@ -204,14 +217,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 +230,67 @@ void main() { expect(newUser.banned, true); expect(newUser.teams, ['team1', 'team2']); expect(newUser.language, 'fr'); + 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'); + test('OwnUser.fromUser keeps a user with no name nameless', () { + final ownUser = OwnUser.fromUser(User(id: 'test-id')); - final encodedUser = user.toJson(); - expect(encodedUser['id'], 'test-id'); - expect(encodedUser['name'], null); + expect(ownUser.name, isNull); + expect(ownUser.toJson().containsKey('name'), isFalse); + }); - final ownUser = OwnUser.fromUser(user); - expect(user.id, 'test-id'); - expect(user.name, 'test-id'); + 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, + ); - final encodedOwnUser = ownUser.toJson(); - expect(encodedOwnUser['id'], 'test-id'); - expect(encodedOwnUser['name'], null); - }, - ); + final merged = user.merge(OwnUser(id: 'test-user')); + + expect(merged.deactivatedAt, isNull); + expect(merged.deletedAt, isNull); + 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 = { @@ -302,131 +347,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'], @@ -467,7 +388,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 +420,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 +439,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, @@ -556,14 +476,7 @@ void main() { ), ); - // 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 +484,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); 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 b725056693..127471cc35 100644 --- a/packages/stream_chat/test/src/core/models/user_test.dart +++ b/packages/stream_chat/test/src/core/models/user_test.dart @@ -30,13 +30,15 @@ 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.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)); @@ -45,6 +47,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); + expect(user.blockedUserIds, ['blocked-1']); + }); + + 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, + blockedUserIds: const ['blocked-1'], + custom: const {'color': 'red'}, + ); + + expect(User.fromJson(user.toJson()), user); + }); + + test('User.toJson leaves out custom data named like a field', () { + final user = User( + id: id, + name: name, + custom: const {'name': 'custom', 'color': 'red'}, + ); + + final json = user.toJson(); + + expect(json['name'], name); + 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', () { @@ -53,7 +95,7 @@ void main() { role: role, name: name, image: image, - extraData: const { + custom: const { 'extraDataStringTest': extraDataStringTest, 'extraDataIntTest': extraDataIntTest, 'extraDataDoubleTest': extraDataDoubleTest, @@ -90,6 +132,7 @@ void main() { 'invisible': true, 'teams_role': teamsRole, 'avg_response_time': avgResponseTime, + 'blocked_user_ids': [], }); }); @@ -111,6 +154,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', @@ -127,13 +174,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']); @@ -144,70 +194,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', () { @@ -215,9 +205,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, []); @@ -226,6 +216,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', () { @@ -233,9 +227,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, []); @@ -244,6 +238,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', () { @@ -266,9 +264,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'), @@ -300,11 +297,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}), ); }); @@ -316,76 +313,22 @@ 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'}, - ); - - expect(user.image, 'https://example.com/image.png'); - }); - - test('.image should return null if not available in extraData', () { - final user = User( - id: 'test-user', - extraData: const {}, - ); + test('User.nameOrId returns the name', () { + final user = User(id: id, name: name); - expect(user.image, isNull); + expect(user.nameOrId, name); }); - test('.image should return null if extraData value is not String', () { - final user = User( - id: 'test-user', - extraData: const {'image': true}, - ); + 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 empty', () { - final user = User( - id: 'test-user', - extraData: const {'image': ''}, - ); + test('User.nameOrId returns the id for a user with an empty name', () { + final user = User(id: id, name: ''); - expect(user.image, isNull); + expect(user.nameOrId, id); }); }); } @@ -397,7 +340,7 @@ User createTestUser({ String? role, bool? banned, DateTime? lastActive, - Map? extraData, + Map? custom, Map? teamsRole, int? avgResponseTime, }) { @@ -407,7 +350,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..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, @@ -183,7 +186,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..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 @@ -198,4 +198,52 @@ void main() { }); } }); + + 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); + 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/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/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 00ac17f110..7bc61450e7 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 96ca608977..b8a5a72d33 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,