From 1433b257120cc247be63fd6ff95db1f42678a7c8 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 29 Sep 2026 17:31:41 +0200 Subject: [PATCH 01/32] refactor(llc)!: route connectGuestUser through the generated client Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 19 ++ openapi-migration/01-foundation.md | 12 + openapi-migration/04-roles-guest-and-app.md | 49 +++- openapi-migration/09-users.md | 8 +- openapi-migration/README.md | 16 +- openapi-migration/tool/generate_plan.py | 50 +++- packages/stream_chat/CHANGELOG.md | 4 + .../stream_chat/lib/src/client/client.dart | 19 +- .../lib/src/core/api/guest_api.dart | 20 -- .../lib/src/core/api/responses.dart | 13 - .../lib/src/core/api/responses.g.dart | 7 - .../lib/src/core/api/stream_chat_api.dart | 5 - .../response/connect_guest_user_response.dart | 28 +++ .../connect_guest_user_response.freezed.dart | 92 +++++++ .../src/repository/general_repository.dart | 14 ++ .../src/repository/mapper/user_mapper.dart | 91 +++++++ .../client_connect_guest_user_test.dart | 227 ++++++++++++++++++ .../test/src/client/client_test.dart | 124 ---------- .../test/src/core/api/guest_api_test.dart | 53 ---- .../test/src/core/api/responses_test.dart | 8 - .../src/core/api/stream_chat_api_test.dart | 4 - packages/stream_chat/test/src/fakes.dart | 6 - packages/stream_chat/test/src/mocks.dart | 3 - 23 files changed, 603 insertions(+), 269 deletions(-) delete mode 100644 packages/stream_chat/lib/src/core/api/guest_api.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/connect_guest_user_response.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/connect_guest_user_response.freezed.dart create mode 100644 packages/stream_chat/lib/src/repository/mapper/user_mapper.dart create mode 100644 packages/stream_chat/test/src/client/client_connect_guest_user_test.dart delete mode 100644 packages/stream_chat/test/src/core/api/guest_api_test.dart diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index e021982c89..e23da88d90 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -29,6 +29,7 @@ onto Stream's OpenAPI-generated API client. - [Devices](#devices) - [Moderation](#moderation) - [App Settings](#app-settings) + - [Guest Users](#guest-users) - [User Groups](#user-groups) - [Link Previews](#link-previews) - [Migration Checklist](#migration-checklist) @@ -74,6 +75,7 @@ from the spec, so don't subclass them or depend on their private constructors. | [**Error Handling**](#error-handling) | Failures carry `stream_core`'s sealed `StreamException` family instead of `StreamChatNetworkError`; `ChatErrorCode` → `StreamErrorCode`. API calls will return `Result` rather than throwing, endpoint by endpoint | | [**Moderation**](#moderation) | Muting, banning and flagging return a `Result` and call the moderation v2 API; `banUser`'s options map becomes named parameters; `unflagMessage`, `unflagUser` and `removeShadowBan` are removed | | [**App Settings**](#app-settings) | `getAppSettings` returns a `Result` instead of throwing; `GetAppSettingsResponse` is renamed `AppSettingsResponse` | +| [**Guest Users**](#guest-users) | `connectGuestUser` is unchanged; `StreamChatApi.guest` and `ConnectGuestUserResponse` are removed | | _(filled in per feature as PRs land)_ | | --- @@ -208,6 +210,8 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `GetAppSettingsResponse` identity `==` | value `==`, plus `copyWith` | `retyped` | Two instances with the same fields are now equal | | `AppSettings extends Equatable` / `UploadConfig extends Equatable`, `props` | `AppSettings` / `UploadConfig` (value `==`, `copyWith`) | `removed` | Equality is unchanged; `props` is gone and neither is an `Equatable` any more | | `StreamChatApi.general.getAppSettings()` | `StreamChatClient.getAppSettings()` | `removed` | The call moved to the generated client | +| `StreamChatApi.guest` (`GuestApi.getGuestUser`) | `StreamChatClient.connectGuestUser` | `removed` | The call moved to the generated client; `connectGuestUser` keeps its signature and still throws | +| `ConnectGuestUserResponse` | — | `removed` | Only `StreamChatApi.guest` returned it; `connectGuestUser` still returns the connected `OwnUser` | | _(more added per feature as PRs land)_ | | | | --- @@ -710,6 +714,21 @@ build them with their constructors — `AppSettingsResponse(duration: '0ms', app **`AppSettings` and `UploadConfig` no longer extend `Equatable`.** They still compare by value, `props` is gone, and both gain `copyWith`. +### Guest Users + +**`connectGuestUser` is unchanged.** It still returns the connected `OwnUser`, and still throws a +`StreamException` when the guest cannot be created or the connection fails. + +**`StreamChatApi.guest` and `ConnectGuestUserResponse` are removed.** `connectGuestUser` creates the guest and +connects it in one call; there is no separate call that only creates one. + +**The guest no longer stores your client state as custom data.** v10 sent the whole user, so fields such as +`online`, `banned`, the unread counts, devices and push preferences were saved on the new guest as custom data, and +some came back: an `OwnUser`'s `pushPreferences` on the returned user and in `extraData` when the guest was +queried, and the unread counts when connecting with `connectWebSocket: false`. None of them was ever applied to the +guest. v11 sends its id, name, image, language, `invisible`, an `OwnUser`'s privacy settings, and your own +`extraData`. If you read one of those values back, store it as a custom field of your own instead. + --- ## Migration Checklist diff --git a/openapi-migration/01-foundation.md b/openapi-migration/01-foundation.md index 2ed4488be9..5b07650215 100644 --- a/openapi-migration/01-foundation.md +++ b/openapi-migration/01-foundation.md @@ -38,6 +38,18 @@ real fields is a break worth making in v11 if it is made at all, and it belongs whichever group happens to touch `User` first. Raised on [#2957](https://github.com/GetStream/stream-chat-flutter/pull/2957). +**Decided: map now, restructure last.** + +- **`User` and `OwnUser` are not restructured until group 09, which runs last.** They keep their v10 shape, + including their JSON, until every parent that embeds them has moved. +- **The mappers land with the first group that needs them.** `lib/src/repository/mapper/user_mapper.dart` + (`UserResponse.toModel()`, `User.toRequest()`) arrived with group 04 and maps the generated types onto today's + class. Later groups reuse it; they never write their own `User` mapping. It is kept, not deleted: group 09 points + it at the restructured class. +- **v1 JSON keeps decoding through `User.fromJson`** until then, including in a parent's rule-7 converter, with no + wrapper in between. Group 09 replaces those calls when it removes `fromJson`, and the compiler finds every one. +- **`name` and `image` stay open.** Promoting them to real fields changes only `user_mapper.dart`. + ## Prerequisites - **The generator's `client.tpl` calls `runApiSafely`, not `runSafely`.** Until it does, a regenerated client diff --git a/openapi-migration/04-roles-guest-and-app.md b/openapi-migration/04-roles-guest-and-app.md index 568cc7e099..f6da6636af 100644 --- a/openapi-migration/04-roles-guest-and-app.md +++ b/openapi-migration/04-roles-guest-and-app.md @@ -2,7 +2,7 @@ **Goal:** Sweep up the singletons — one-method families that share no state and can land in one PR. -**Size:** 1 hand-written method(s) across 1 file(s) → 5 generated operation(s). +**Size:** 0 hand-written method(s) across 0 file(s) → 5 generated operation(s). > This whole file is generated by `openapi-migration/tool/generate_plan.py`, prose included. > Edit its `GROUPS` entry and re-run the script — edits made here are lost on the next run. @@ -13,7 +13,6 @@ | File | Method | Returns | | --- | --- | --- | -| `guest_api.dart` | `getGuestUser` | `ConnectGuestUserResponse` | ### Generated operations that cover it @@ -73,6 +72,34 @@ - **An unset size limit stays `0`.** The server reports one as `size_limit: 0`, and `UploadConfig.sizeLimit` passes it through as v10 did; `StreamAttachmentValidator` applies `UploadConfig.defaultSizeLimit` in that case. No `effectiveSizeLimit` getter was added. +- **`connectGuestUser` keeps its v10 signature and keeps throwing.** It returns `Future` and + throws a `StreamException`, like `connectUser`, `connectUserWithProvider` and `connectAnonymousUser`. + 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. +- **`ConnectGuestUserResponse` is internal:** a freezed envelope that only `createGuest` returns. Its + only public reach in v10 was `StreamChatApi.guest`, removed together with `GuestApi`. +- **The request carries every field the backend honours for a client-side guest:** id, name, image, + custom data, language, `invisible` and, for an `OwnUser`, privacy settings. `role`, `teams` and + `teams_role` are ignored for client-side calls and are not sent. +- **The rest of the user is no longer sent.** v10 sent the whole flattened user, and v1 filed every key + the request does not declare (`online`, `banned`, devices, unread counts, push preferences and so on) + into the guest's custom data, where some were echoed back: `pushPreferences` on the returned + `OwnUser` and in `queryUsers`' `extraData`, the unread counts when the socket is off, and `ban_expires` + in the raw `me`. None was ever applied. Verified live against the demo app and in the backend source; + recorded as a 🐞 Fixed entry. +- **`UserResponse.toModel()` leaves custom keys named like `OwnUser` fields (`OwnUser.topLevelFields`), + plus `deleted_at`, `deactivated_at` and `revoke_tokens_issued_before`, out of `extraData`.** v1's flat user JSON shadowed them; v2 nests custom data, and without the guard a + custom `online` string crashed `OwnUser.fromUser`. +- **The three extra names are guarded because the socket connect refuses them.** A custom + `deleted_at`, `deactivated_at` or `revoke_tokens_issued_before` left in `extraData` is re-sent in the + connect's user details, and the backend answers 400 ("reserved field", or "expected date" for a + 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`, + following [01-foundation](01-foundation.md). ## Risks @@ -80,17 +107,17 @@ ## Definition of done -- [ ] Every method above either routes through `DefaultApi` or is listed here as deliberately left +- [x] Every method above either routes through `DefaultApi` or is listed here as deliberately left hand-written, with the reason. -- [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. -- [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. -- [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group +- [x] Public methods return `Future>`; no `getOrThrow()` inside the SDK. +- [x] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [x] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. -- [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. -- [ ] CHANGELOG entry under `🛑️ Breaking` for each break; PR title `refactor(llc)!:`. -- [ ] Decisions recorded in this file, and the status box ticked in `README.md`. -- [ ] Public dartdoc follows [`STYLE_GUIDE.md` § Documentation](../STYLE_GUIDE.md#documentation) +- [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. -- [ ] Tests follow [`TESTING.md`](../TESTING.md): no `group` organizing a file by method, each +- [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/09-users.md b/openapi-migration/09-users.md index ed4e8f4999..3df0231010 100644 --- a/openapi-migration/09-users.md +++ b/openapi-migration/09-users.md @@ -39,8 +39,12 @@ ## Decisions to make -- `User` and `OwnUser` are public, persisted, and embedded in nearly every other response. The decision is made in 01-foundation and frozen there; this group executes it. +- `User` and `OwnUser` are public, persisted, and embedded in nearly every other response. This group restructures them, last: the mappers in `user_mapper.dart` already map the generated types onto the current class for every group before it, and stay. See [01-foundation](01-foundation.md). - `PrivacySettings` and the push-preference sub-shapes — decide per type. +- `UserResponse.toModel()` leaves `OwnUser.topLevelFields`, `deleted_at`, `deactivated_at` and `revoke_tokens_issued_before` out of every user's `extraData` (`_shadowedCustomKeys`), where v1 kept the `OwnUser`-only keys in a plain user's `extraData`. Revisit once the mapper serves plain users. +- `UserFilterField.shadowBanned` and `.bypassModeration` read `extraData`, which the generated `UserResponse` has no field to fill. +- `updateUsers` reuses `User.toRequest()`. It is a full upsert, and v10's flattened body filed the user's client state (`online`, `banned`, `created_at` and similar) into the stored custom data on every call, as it did for guests in [04](04-roles-guest-and-app.md). Decide the same way here, for real users rather than fresh guests, and record it in the CHANGELOG. +- The generated `UserRequest` sends explicit `null` for an unset `language` or `invisible`, where v10 left the key out. For a guest create that made no difference; for an upsert of an existing user, confirm live that a `null` does not reset a stored value differently from an omitted key. ## Risks @@ -66,3 +70,5 @@ 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 + gone. diff --git a/openapi-migration/README.md b/openapi-migration/README.md index 602582e873..31c5cb5eea 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -15,7 +15,7 @@ generated operations in scope, the decisions that group has to make, its risks, | [01](01-foundation.md) | Foundation — `DefaultApi` wiring, `User` shape | — | — | ☐ | | [02](02-devices.md) | Devices | 0 | 3 | ☑ | | [03](03-user-groups.md) | User Groups | 0 | 8 | ☑ | -| [04](04-roles-guest-and-app.md) | Roles, Guest & App Settings | 1 | 5 | ☐ | +| [04](04-roles-guest-and-app.md) | Roles, Guest & App Settings | 0 | 5 | ☑ | | [05](05-polls.md) | Polls | 13 | 13 | ☐ | | [06](06-reminders.md) | Message Reminders | 4 | 4 | ☐ | | [07](07-threads-and-drafts.md) | Threads & Drafts | 7 | 7 | ☐ | @@ -116,10 +116,10 @@ Each ships with a CHANGELOG entry and a Symbol Map row like any other break. | `DeviceV1JsonConverter` | `OwnUser.devices` | [09](09-users.md) | | `userGroupsFromV1Json` | `Message.mentionedGroups` | [10](10-messages.md) | | `DataSerializable` | `UserGroup`, `UserGroupMember` (`fromData`, `toData`) | [10](10-messages.md) | +| `user_mapper.dart` (kept, re-pointed) | today's `User`, which still reads and writes JSON | [09](09-users.md) | -Before group 09 starts, decide how v1 events decode a parent once it becomes a plain model: through the -generated types plus a shim that rebuilds `custom` from the flattened v1 keys, or through a private v1 decoder in -the WebSocket layer. Record the answer in [01-foundation](01-foundation.md). +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`. ## Order, and why @@ -134,11 +134,11 @@ surfaces before it reaches `Message` and `ChannelState`: - **05–07** introduce persisted models and WebSocket-delivered updates, one at a time. - **08** is where we decide what *not* to expose: 34 generated operations against 10 hand-written methods. - **14** is `queryBannedUsers`, split out of 08 because it is the only moderation call that answers with a - model. It waits on 09, so it sorts after the groups that decide the shapes it embeds. -- **09** freezes the `User` mapping that everything else already depends on (the *decision* is made in 01; this - group executes it). + model. The `User` mappers it needs landed with group 04. - **10–11** are the core of the SDK, and carry the `custom` / `extraData` promotion problem. -- **12** is last because it needs its own hand-written multipart client and is the highest-traffic path in the SDK. +- **12** 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 + user endpoints and restructures `User` and `OwnUser` themselves, when every parent that embeds them has moved. ## Prerequisites diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 131b76a074..4930f18647 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -217,7 +217,7 @@ def match(path): ), dict( num='04', slug='roles-guest-and-app', title='Roles, Guest & App Settings', - hand=['guest_api.dart'], + hand=[], match=owns('/api/v2/roles', '/api/v2/guest', '/api/v2/app', '/api/v2/og', '/api/v2/longpoll'), goal='Sweep up the singletons — one-method families that share no state and can land in one PR.', decisions=[], @@ -268,7 +268,36 @@ def match(path): - **An unset size limit stays `0`.** The server reports one as `size_limit: 0`, and `UploadConfig.sizeLimit` passes it through as v10 did; `StreamAttachmentValidator` applies `UploadConfig.defaultSizeLimit` in that case. No `effectiveSizeLimit` getter was added. + - **`connectGuestUser` keeps its v10 signature and keeps throwing.** It returns `Future` and + throws a `StreamException`, like `connectUser`, `connectUserWithProvider` and `connectAnonymousUser`. + 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. + - **`ConnectGuestUserResponse` is internal:** a freezed envelope that only `createGuest` returns. Its + only public reach in v10 was `StreamChatApi.guest`, removed together with `GuestApi`. + - **The request carries every field the backend honours for a client-side guest:** id, name, image, + custom data, language, `invisible` and, for an `OwnUser`, privacy settings. `role`, `teams` and + `teams_role` are ignored for client-side calls and are not sent. + - **The rest of the user is no longer sent.** v10 sent the whole flattened user, and v1 filed every key + the request does not declare (`online`, `banned`, devices, unread counts, push preferences and so on) + into the guest's custom data, where some were echoed back: `pushPreferences` on the returned + `OwnUser` and in `queryUsers`' `extraData`, the unread counts when the socket is off, and `ban_expires` + in the raw `me`. None was ever applied. Verified live against the demo app and in the backend source; + recorded as a 🐞 Fixed entry. + - **`UserResponse.toModel()` leaves custom keys named like `OwnUser` fields (`OwnUser.topLevelFields`), + plus `deleted_at`, `deactivated_at` and `revoke_tokens_issued_before`, out of `extraData`.** v1's flat user JSON shadowed them; v2 nests custom data, and without the guard a + custom `online` string crashed `OwnUser.fromUser`. + - **The three extra names are guarded because the socket connect refuses them.** A custom + `deleted_at`, `deactivated_at` or `revoke_tokens_issued_before` left in `extraData` is re-sent in the + connect's user details, and the backend answers 400 ("reserved field", or "expected date" for a + 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`, + following [01-foundation](01-foundation.md). """), + done=DONE.replace('- [ ]', '- [x]'), risks=[ '`general_api.dart` has no methods left in this group — `sync` and `queryMembers` go to group 11, ' '`searchMessages` to group 10. Do not migrate the file as a unit.', @@ -469,9 +498,22 @@ def match(path): goal='`User` is the most widely referenced public model in the SDK; this is where keep-vs-adopt costs the ' 'most.', decisions=[ - '`User` and `OwnUser` are public, persisted, and embedded in nearly every other response. The ' - 'decision is made in 01-foundation and frozen there; this group executes it.', + '`User` and `OwnUser` are public, persisted, and embedded in nearly every other response. This group ' + 'restructures them, last: the mappers in `user_mapper.dart` already map the generated types onto the ' + 'current class for every group before it, and stay. See [01-foundation](01-foundation.md).', '`PrivacySettings` and the push-preference sub-shapes — decide per type.', + '`UserResponse.toModel()` leaves `OwnUser.topLevelFields`, `deleted_at`, `deactivated_at` and ' + '`revoke_tokens_issued_before` out of every user\'s `extraData` (`_shadowedCustomKeys`), where v1 kept the ' + '`OwnUser`-only keys in a plain user\'s `extraData`. Revisit once the mapper serves plain users.', + '`UserFilterField.shadowBanned` and `.bypassModeration` read `extraData`, which the generated ' + '`UserResponse` has no field to fill.', + '`updateUsers` reuses `User.toRequest()`. It is a full upsert, and v10\'s flattened body filed the ' + 'user\'s client state (`online`, `banned`, `created_at` and similar) into the stored custom data on ' + 'every call, as it did for guests in [04](04-roles-guest-and-app.md). Decide the same way here, for real ' + 'users rather than fresh guests, and record it in the CHANGELOG.', + 'The generated `UserRequest` sends explicit `null` for an unset `language` or `invisible`, where v10 ' + 'left the key out. For a guest create that made no difference; for an upsert of an existing user, ' + 'confirm live that a `null` does not reset a stored value differently from an omitted key.', ], risks=[ 'Every other group depends on the `User` decision.', @@ -484,6 +526,8 @@ def match(path): done=DONE + ( '- [ ] Temporary adapters owned by this group (`DeviceV1JsonConverter`) are deleted and removed from\n' ' the table in `README.md`.\n' + '- [ ] `user_mapper.dart` maps onto the restructured `User`, and its `TODO(openapi-migration)` note is\n' + ' gone.\n' ), ), dict( diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index ab42c80aaf..b6fe47a83b 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -77,9 +77,13 @@ - `AppSettingsResponse` is immutable, built through a const constructor, compares by value, gains `copyWith`, and its `duration` is a non-nullable `String`. - `AppSettings` and `UploadConfig` no longer extend `Equatable`, so `props` is removed; they still compare by value and gain `copyWith`. - `StreamChatApi.general.getAppSettings` is removed; call `StreamChatClient.getAppSettings` instead. +- `StreamChatApi.guest` is removed; call `StreamChatClient.connectGuestUser` instead. +- `ConnectGuestUserResponse` is removed from the public API. 🐞 Fixed +- `connectGuestUser` no longer saves the user's client state (online, banned, unread counts, devices, push preferences and similar) as custom data on the created guest. + - `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`. diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 032d0b3c59..879326c657 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -14,10 +14,13 @@ import 'package:stream_core/stream_core.dart' InFlightCache, LocationCoordinate, LoggingInterceptor, + PatternMatching, Result, SortedListExtensions, Standard, + StreamClientException, StreamCoreHttpClient, + StreamException, StreamLogConfig, StreamLogger, SystemEnvironment, @@ -443,8 +446,10 @@ class StreamChatClient { ); } - /// Connects the current user as guest, this triggers a connection to the API. - /// It returns a [Future] that resolves when the connection is setup. + /// Connects a guest created from [user], which triggers a connection to the API. + /// + /// Returns a [Future] that resolves when the connection is set up. Throws a [StreamException] if the guest cannot + /// be created or the connection fails. Future connectGuestUser( User user, { bool connectWebSocket = true, @@ -457,7 +462,15 @@ class StreamChatClient { tokenProvider: .static(anonymousToken), ); - final guestUser = await _chatApi.guest.getGuestUser(user); + final result = await _generalRepository.createGuest(user); + + // Unlike the endpoint methods, connecting reports a failure by throwing. + final guestUser = result.getOrElse((error, stackTrace) { + final exception = + StreamException.tryFrom(error) ?? + StreamClientException(message: 'Failed to create a guest user', cause: error); + Error.throwWithStackTrace(exception, stackTrace ?? StackTrace.current); + }); // resetting tokenManager after successful request _tokenManager.reset(); diff --git a/packages/stream_chat/lib/src/core/api/guest_api.dart b/packages/stream_chat/lib/src/core/api/guest_api.dart deleted file mode 100644 index 58f3b190c2..0000000000 --- a/packages/stream_chat/lib/src/core/api/guest_api.dart +++ /dev/null @@ -1,20 +0,0 @@ -import '../http/stream_http_client.dart'; -import '../models/user.dart'; -import 'responses.dart'; - -/// Defines the api dedicated to guest users operations -class GuestApi { - /// Initialize a new guest api - GuestApi(this._client); - - final StreamHttpClient _client; - - /// Returns the information about guest user - Future getGuestUser(User user) async { - final response = await _client.post( - '/guest', - data: {'user': user}, - ); - return ConnectGuestUserResponse.fromJson(response.data); - } -} diff --git a/packages/stream_chat/lib/src/core/api/responses.dart b/packages/stream_chat/lib/src/core/api/responses.dart index 3baaca0989..2112329cab 100644 --- a/packages/stream_chat/lib/src/core/api/responses.dart +++ b/packages/stream_chat/lib/src/core/api/responses.dart @@ -204,19 +204,6 @@ class SendReactionResponse extends MessageResponse { static SendReactionResponse fromJson(Map json) => _$SendReactionResponseFromJson(json); } -/// Model response for [StreamChatClient.connectGuestUser] api call -@JsonSerializable(createToJson: false) -class ConnectGuestUserResponse extends _BaseResponse { - /// Guest user access token - late String accessToken; - - /// Guest user - late User user; - - /// Create a new instance from a json - static ConnectGuestUserResponse fromJson(Map json) => _$ConnectGuestUserResponseFromJson(json); -} - /// Model response for [StreamChatClient.updateUser] api call @JsonSerializable(createToJson: false) class UpdateUsersResponse extends _BaseResponse { diff --git a/packages/stream_chat/lib/src/core/api/responses.g.dart b/packages/stream_chat/lib/src/core/api/responses.g.dart index afd5aef5b0..9daf6f4192 100644 --- a/packages/stream_chat/lib/src/core/api/responses.g.dart +++ b/packages/stream_chat/lib/src/core/api/responses.g.dart @@ -101,13 +101,6 @@ SendReactionResponse _$SendReactionResponseFromJson( ..message = Message.fromJson(json['message'] as Map) ..reaction = Reaction.fromJson(json['reaction'] as Map); -ConnectGuestUserResponse _$ConnectGuestUserResponseFromJson( - Map json, -) => ConnectGuestUserResponse() - ..duration = json['duration'] as String? - ..accessToken = json['access_token'] as String - ..user = User.fromJson(json['user'] as Map); - UpdateUsersResponse _$UpdateUsersResponseFromJson(Map json) => UpdateUsersResponse() ..duration = json['duration'] as String? ..users = diff --git a/packages/stream_chat/lib/src/core/api/stream_chat_api.dart b/packages/stream_chat/lib/src/core/api/stream_chat_api.dart index b02e7cc26a..c241201b83 100644 --- a/packages/stream_chat/lib/src/core/api/stream_chat_api.dart +++ b/packages/stream_chat/lib/src/core/api/stream_chat_api.dart @@ -5,7 +5,6 @@ import '../http/stream_http_client.dart'; import 'attachment_file_uploader.dart'; import 'channel_api.dart'; import 'general_api.dart'; -import 'guest_api.dart'; import 'message_api.dart'; import 'moderation_api.dart'; import 'polls_api.dart'; @@ -47,10 +46,6 @@ class StreamChatApi { UserApi get user => _user ??= UserApi(_client); UserApi? _user; - /// Api dedicated to guest operations - GuestApi get guest => _guest ??= GuestApi(_client); - GuestApi? _guest; - /// Api dedicated to message operations MessageApi get message => _message ??= MessageApi(_client); MessageApi? _message; diff --git a/packages/stream_chat/lib/src/core/models/response/connect_guest_user_response.dart b/packages/stream_chat/lib/src/core/models/response/connect_guest_user_response.dart new file mode 100644 index 0000000000..22ddf57da8 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/connect_guest_user_response.dart @@ -0,0 +1,28 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../user.dart'; + +part 'connect_guest_user_response.freezed.dart'; + +/// A newly created guest and the token that authenticates it. +@freezed +class ConnectGuestUserResponse with _$ConnectGuestUserResponse { + /// Creates a new [ConnectGuestUserResponse]. + const ConnectGuestUserResponse({ + required this.duration, + required this.accessToken, + required this.user, + }); + + /// How long the request took to handle, such as `4.21ms`. + @override + final String duration; + + /// The token that authenticates [user]. + @override + final String accessToken; + + /// The created guest, which has an id of its own rather than the one requested. + @override + final User user; +} diff --git a/packages/stream_chat/lib/src/core/models/response/connect_guest_user_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/connect_guest_user_response.freezed.dart new file mode 100644 index 0000000000..956b65604f --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/connect_guest_user_response.freezed.dart @@ -0,0 +1,92 @@ +// 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 'connect_guest_user_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$ConnectGuestUserResponse { + String get duration; + String get accessToken; + User get user; + + /// Create a copy of ConnectGuestUserResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $ConnectGuestUserResponseCopyWith get copyWith => + _$ConnectGuestUserResponseCopyWithImpl( + this as ConnectGuestUserResponse, + _$identity, + ); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is ConnectGuestUserResponse && + (identical(other.duration, duration) || other.duration == duration) && + (identical(other.accessToken, accessToken) || other.accessToken == accessToken) && + (identical(other.user, user) || other.user == user)); + } + + @override + int get hashCode => Object.hash(runtimeType, duration, accessToken, user); + + @override + String toString() { + return 'ConnectGuestUserResponse(duration: $duration, accessToken: $accessToken, user: $user)'; + } +} + +/// @nodoc +abstract mixin class $ConnectGuestUserResponseCopyWith<$Res> { + factory $ConnectGuestUserResponseCopyWith( + ConnectGuestUserResponse value, + $Res Function(ConnectGuestUserResponse) _then, + ) = _$ConnectGuestUserResponseCopyWithImpl; + @useResult + $Res call({String duration, String accessToken, User user}); +} + +/// @nodoc +class _$ConnectGuestUserResponseCopyWithImpl<$Res> implements $ConnectGuestUserResponseCopyWith<$Res> { + _$ConnectGuestUserResponseCopyWithImpl(this._self, this._then); + + final ConnectGuestUserResponse _self; + final $Res Function(ConnectGuestUserResponse) _then; + + /// Create a copy of ConnectGuestUserResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? duration = null, + Object? accessToken = null, + Object? user = null, + }) { + return _then( + ConnectGuestUserResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + accessToken: null == accessToken + ? _self.accessToken + : accessToken // ignore: cast_nullable_to_non_nullable + as String, + user: null == user + ? _self.user + : user // ignore: cast_nullable_to_non_nullable + as User, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/repository/general_repository.dart b/packages/stream_chat/lib/src/repository/general_repository.dart index 1b5b23ef2e..16dca35133 100644 --- a/packages/stream_chat/lib/src/repository/general_repository.dart +++ b/packages/stream_chat/lib/src/repository/general_repository.dart @@ -1,8 +1,11 @@ import 'package:stream_core/stream_core.dart' show PatternMatching, Result; import '../../open_api/api.dart' as api; +import '../core/models/response/connect_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'; /// Repository dedicated to operations that belong to no single feature. class GeneralRepository { @@ -20,4 +23,15 @@ class GeneralRepository { return result.map((response) => response.toModel(requestedUrl: url)); } + + /// Creates a guest from [user] and returns it with the token that authenticates it. + /// + /// The returned guest has an id of its own and the `guest` role, rather than those of [user]. + Future> createGuest(User user) async { + final result = await _api.createGuest( + createGuestRequest: api.CreateGuestRequest(user: user.toRequest()), + ); + + return result.map((response) => response.toModel()); + } } diff --git a/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart new file mode 100644 index 0000000000..18d2c1ca95 --- /dev/null +++ b/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart @@ -0,0 +1,91 @@ +import '../../../open_api/api.dart' as api; +import '../../core/models/own_user.dart'; +import '../../core/models/privacy_settings.dart'; +import '../../core/models/response/connect_guest_user_response.dart'; +import '../../core/models/user.dart'; +import '../../core/util/extension.dart'; + +// The user and privacy settings mappers in this file build today's `User` class, which has not been restructured +// for the generated client yet and still reads and writes JSON. +// TODO(openapi-migration): Update these mappers when `User` is restructured. + +/// Maps a generated [api.UserResponse] to a [User]. +extension UserResponseMapper on api.UserResponse { + /// Converts this response into a [User]. + /// + /// [custom] becomes [User.extraData], without the keys named after a field of the user's own, so a custom field + /// never stands in for one of them. + 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, + extraData: {...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), + ); +} + +// Custom keys a user's own fields hide: every field of an [OwnUser], and three the socket connect refuses to take +// back from its user details. +const _shadowedCustomKeys = {...OwnUser.topLevelFields, 'deleted_at', 'deactivated_at', 'revoke_tokens_issued_before'}; + +/// Maps a [User] to the generated [api.UserRequest]. +extension UserRequestMapper on User { + /// Converts this user into an [api.UserRequest]. + /// + /// The `name` and `image` keys of [extraData] become their own fields and the rest becomes + /// [api.UserRequest.custom]. The name is read from [extraData] rather than [name], which falls back to [id]. + /// + /// The privacy settings of an [OwnUser] are carried over as well. + api.UserRequest toRequest() => api.UserRequest( + id: id, + name: extraData['name'].safeCast(), + image: extraData['image'].safeCast(), + language: language, + invisible: invisible, + privacySettings: switch (this) { + OwnUser(:final privacySettings?) => privacySettings.toRequest(), + _ => null, + }, + custom: {...extraData} + ..remove('name') + ..remove('image'), + ); +} + +/// Maps [PrivacySettings] to the generated [api.PrivacySettingsResponse]. +extension PrivacySettingsRequestMapper on PrivacySettings { + /// Converts these settings into an [api.PrivacySettingsResponse]. + api.PrivacySettingsResponse toRequest() => api.PrivacySettingsResponse( + typingIndicators: switch (typingIndicators) { + final it? => api.TypingIndicatorsResponse(enabled: it.enabled), + null => null, + }, + readReceipts: switch (readReceipts) { + final it? => api.ReadReceiptsResponse(enabled: it.enabled), + null => null, + }, + deliveryReceipts: switch (deliveryReceipts) { + final it? => api.DeliveryReceiptsResponse(enabled: it.enabled), + null => null, + }, + ); +} + +/// Maps a generated [api.CreateGuestResponse] to a [ConnectGuestUserResponse]. +extension CreateGuestResponseMapper on api.CreateGuestResponse { + /// Converts this response into a [ConnectGuestUserResponse]. + ConnectGuestUserResponse toModel() => ConnectGuestUserResponse( + duration: duration, + accessToken: accessToken, + user: user.toModel(), + ); +} 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 new file mode 100644 index 0000000000..992bb5af44 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_connect_guest_user_test.dart @@ -0,0 +1,227 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/open_api/api.dart' as api; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../fakes.dart'; +import '../mocks.dart'; +import '../utils.dart'; +import '../ws/fake_chat_server.dart'; + +const _guestId = 'guest-1b2c3d4e-requested-id'; + +void main() { + test('StreamChatClient.connectGuestUser sends the user and returns the created guest', () async { + final defaultApi = _defaultApiAnswering(Result.success(_createGuestResponse())); + final client = _client(defaultApi); + + final guest = await client.connectGuestUser(_fullyPopulatedUser(), connectWebSocket: false); + + final request = verify( + () => defaultApi.createGuest(createGuestRequest: captureAny(named: 'createGuestRequest')), + ).captured.single; + expect( + request, + const api.CreateGuestRequest( + user: api.UserRequest( + id: 'requested-id', + name: 'Requested Name', + image: 'https://example.com/requested.png', + language: 'fr', + invisible: true, + privacySettings: api.PrivacySettingsResponse( + typingIndicators: api.TypingIndicatorsResponse(enabled: false), + readReceipts: api.ReadReceiptsResponse(enabled: true), + deliveryReceipts: api.DeliveryReceiptsResponse(enabled: false), + ), + custom: {'favorite_color': 'blue'}, + ), + ), + ); + expect(guest, OwnUser.fromUser(_createdGuest())); + // `User` equality leaves these two out. + expect(guest.createdAt, DateTime.utc(2026, 9, 1)); + expect(guest.updatedAt, DateTime.utc(2026, 9, 2)); + }); + + 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); + + await client.connectGuestUser(User(id: 'requested-id'), connectWebSocket: false); + + final request = verify( + () => defaultApi.createGuest(createGuestRequest: captureAny(named: 'createGuestRequest')), + ).captured.single; + expect( + request, + const api.CreateGuestRequest( + user: api.UserRequest(id: 'requested-id', custom: {}), + ), + ); + }); + + 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', + 'revoke_tokens_issued_before': 'custom-revoke-tokens-issued-before', + }, + ); + final client = _client(_defaultApiAnswering(Result.success(response))); + + final guest = await client.connectGuestUser(_fullyPopulatedUser(), connectWebSocket: false); + + expect(guest.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( + defaultApi, + server: FakeChatServer(user: OwnUser(id: _guestId)), + ); + + await client.connectGuestUser(_fullyPopulatedUser()); + + expect(client.connectionStatus, ConnectionStatus.connected); + expect(client.state.currentUser?.id, _guestId); + }); + + test('StreamChatClient.connectGuestUser throws the failure when the guest cannot be created', () async { + final defaultApi = _defaultApiAnswering(Result.failure(apiException(statusCode: 403))); + final client = _client(defaultApi); + + await expectLater( + client.connectGuestUser(_fullyPopulatedUser()), + throwsA(isA().having((it) => it.statusCode, 'statusCode', 403)), + ); + }); +} + +StreamChatClient _client(api.DefaultApi defaultApi, {FakeChatServer? server}) { + final client = StreamChatClient( + 'test-api-key', + defaultApi: defaultApi, + wsProvider: (server ?? FakeChatServer()).connect, + ); + addTearDown(client.dispose); + return client; +} + +MockDefaultApi _defaultApiAnswering(Result result) { + registerFallbackValue(const api.CreateGuestRequest(user: api.UserRequest(id: 'fallback'))); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.createGuest(createGuestRequest: any(named: 'createGuestRequest')), + ).thenAnswer((_) async => result); + // Connecting loads the app settings in the background. + when(defaultApi.getApp).thenAnswer((_) async => Result.success(fakeGetApplicationResponse())); + return defaultApi; +} + +// Carries a value in every field, so the request shows which of them a guest takes. +OwnUser _fullyPopulatedUser() => OwnUser( + id: 'requested-id', + role: 'admin', + name: 'Requested Name', + image: 'https://example.com/requested.png', + createdAt: DateTime.utc(2020, 1, 1), + updatedAt: DateTime.utc(2020, 1, 2), + lastActive: DateTime.utc(2020, 1, 3), + online: true, + banned: true, + banExpires: DateTime.utc(2030), + teams: const ['team-x'], + language: 'fr', + invisible: true, + teamsRole: const {'team-x': 'admin'}, + avgResponseTime: 7, + extraData: const {'favorite_color': 'blue'}, + devices: const [Device(id: 'device-1', pushProvider: PushProvider.firebase)], + mutes: [ + Mute( + user: User(id: 'requested-id'), + target: User(id: 'muted-user'), + createdAt: DateTime.utc(2020), + updatedAt: DateTime.utc(2020), + ), + ], + channelMutes: [ + ChannelMute( + user: User(id: 'requested-id'), + channel: ChannelModel(cid: 'messaging:muted'), + createdAt: DateTime.utc(2020), + updatedAt: DateTime.utc(2020), + ), + ], + totalUnreadCount: 5, + unreadChannels: 2, + unreadThreads: 1, + blockedUserIds: const ['blocked-user'], + pushPreferences: const PushPreference(chatLevel: ChatLevel.allMentions), + privacySettings: const PrivacySettings( + typingIndicators: TypingIndicators(enabled: false), + readReceipts: ReadReceipts(), + deliveryReceipts: DeliveryReceipts(enabled: false), + ), +); + +// Every field the server answers with, including the ones a [User] does not carry. +api.CreateGuestResponse _createGuestResponse({ + Map custom = const {'favorite_color': 'green'}, +}) => api.CreateGuestResponse( + duration: '3.20ms', + accessToken: testUserToken(_guestId).rawValue, + user: api.UserResponse( + id: _guestId, + role: 'guest', + name: 'Created Name', + image: 'https://example.com/created.png', + language: 'de', + online: true, + banned: true, + teams: const ['team-a'], + teamsRole: const {'team-a': 'member'}, + avgResponseTime: 42, + custom: custom, + blockedUserIds: const ['blocked-user'], + createdAt: DateTime.utc(2026, 9, 1), + updatedAt: DateTime.utc(2026, 9, 2), + lastActive: DateTime.utc(2026, 9, 3), + deactivatedAt: DateTime.utc(2026, 9, 4), + deletedAt: DateTime.utc(2026, 9, 5), + revokeTokensIssuedBefore: DateTime.utc(2026, 9, 6), + ), +); + +// The user [_createGuestResponse] carries. +User _createdGuest() => User( + id: _guestId, + role: 'guest', + name: 'Created Name', + image: 'https://example.com/created.png', + language: 'de', + online: true, + banned: true, + teams: const ['team-a'], + teamsRole: const {'team-a': 'member'}, + avgResponseTime: 42, + extraData: const {'favorite_color': 'green'}, + createdAt: DateTime.utc(2026, 9, 1), + updatedAt: DateTime.utc(2026, 9, 2), + lastActive: DateTime.utc(2026, 9, 3), +); diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index fd698b9f60..c3d24bf83f 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -68,59 +68,6 @@ void main() { expect(res, isSameUserAs(user)); }); - group('`.connectGuestUser`', () { - test('should work fine', () async { - final user = User(id: 'test-user-id'); - final token = testUserToken(user.id).rawValue; - - when(() => fakeChatApi.guest.getGuestUser(any(that: isSameUserAs(user)))).thenAnswer( - (_) async => ConnectGuestUserResponse() - ..user = user - ..accessToken = token, - ); - - expectLater( - // skipping first seed status -> ConnectionStatus.disconnected - client.connectionStatusStream.skip(1), - emitsInOrder([ConnectionStatus.connecting, ConnectionStatus.connected]), - ); - - final res = await client.connectGuestUser(user); - expect(res, isNotNull); - expect(res, isSameUserAs(user)); - - verify( - () => fakeChatApi.guest.getGuestUser(any(that: isSameUserAs(user))), - ).called(1); - }); - - test('should throw if `.getGuestUser` fails', () async { - final user = User(id: 'test-user-id'); - - when( - () => fakeChatApi.guest.getGuestUser(any(that: isSameUserAs(user))), - ).thenThrow(apiException(code: StreamErrorCode.inputError, statusCode: 400)); - - expectLater( - client.connectionStatusStream, - emitsInOrder([ - // only emits the seed -> disconnected status - // as the call never reaches `ws.connect` - ConnectionStatus.disconnected, - ]), - ); - - await expectLater( - client.connectGuestUser(user), - throwsA(isA()), - ); - - verify( - () => fakeChatApi.guest.getGuestUser(any(that: isSameUserAs(user))), - ).called(1); - }); - }); - test('`.connectAnonymousUser` should work fine', () async { expectLater( // skipping first seed status -> ConnectionStatus.disconnected @@ -226,25 +173,6 @@ void main() { }, ); - test('`.connectGuestUser` should throw if `ws.connect` fails', () async { - final user = User(id: 'test-user-id'); - final token = testUserToken(user.id).rawValue; - - when(() => fakeChatApi.guest.getGuestUser(any(that: isSameUserAs(user)))).thenAnswer( - (_) async => ConnectGuestUserResponse() - ..user = user - ..accessToken = token, - ); - - await expectLater( - client.connectGuestUser(user), - throwsA(isA()), - ); - verify( - () => fakeChatApi.guest.getGuestUser(any(that: isSameUserAs(user))), - ).called(1); - }); - test( '`.connectAnonymousUser` should throw if `ws.connect` fails', () async { @@ -307,28 +235,6 @@ void main() { }, ); - test('`.connectGuestUser` should succeed without connecting', () async { - final user = User(id: 'test-user-id'); - final token = testUserToken(user.id).rawValue; - - when(() => fakeChatApi.guest.getGuestUser(any(that: isSameUserAs(user)))).thenAnswer( - (_) async => ConnectGuestUserResponse() - ..user = user - ..accessToken = token, - ); - - final res = await client.connectGuestUser( - user, - connectWebSocket: false, - ); - - expect(res, isSameUserAs(user)); - expect(client.connectionStatus, ConnectionStatus.disconnected); - verify( - () => fakeChatApi.guest.getGuestUser(any(that: isSameUserAs(user))), - ).called(1); - }); - test( '`.connectAnonymousUser` should succeed without connecting', () async { @@ -411,36 +317,6 @@ void main() { }, ); - test( - '''`.connectGuestUser` should connect successfully if persistence contains event''', - () async { - final user = User(id: 'test-user-id'); - final token = testUserToken(user.id).rawValue; - - final event = Event( - type: EventType.healthCheck, - connectionId: 'test-connection-id', - me: OwnUser.fromUser(user), - ); - when(persistence.getConnectionInfo).thenAnswer((_) async => event); - - when(() => fakeChatApi.guest.getGuestUser(any(that: isSameUserAs(user)))).thenAnswer( - (_) async => ConnectGuestUserResponse() - ..user = user - ..accessToken = token, - ); - - final res = await client.connectGuestUser(user); - expect(res, isNotNull); - expect(res, isSameUserAs(user)); - - verify(persistence.getConnectionInfo).called(1); - verifyNoMoreInteractions(persistence); - verify(() => fakeChatApi.guest.getGuestUser(any(that: isSameUserAs(user)))).called(1); - verifyNoMoreInteractions(fakeChatApi.guest); - }, - ); - test( '''`.connectAnonymousUser` should connect successfully if persistence contains event''', () async { diff --git a/packages/stream_chat/test/src/core/api/guest_api_test.dart b/packages/stream_chat/test/src/core/api/guest_api_test.dart deleted file mode 100644 index 40f83e7bee..0000000000 --- a/packages/stream_chat/test/src/core/api/guest_api_test.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat/src/core/api/guest_api.dart'; -import 'package:stream_chat/stream_chat.dart'; -import 'package:test/test.dart'; - -import '../../mocks.dart'; - -void main() { - Response successResponse(String path, {Object? data}) => Response( - data: data, - requestOptions: RequestOptions(path: path), - statusCode: 200, - ); - - late final client = MockHttpClient(); - late GuestApi guestApi; - - setUp(() { - guestApi = GuestApi(client); - }); - - test('getGuestUser', () async { - const accessToken = 'test-guest-token'; - final user = User(id: 'test-user-id'); - - const path = '/guest'; - - when( - () => client.post( - path, - data: {'user': user}, - ), - ).thenAnswer( - (_) async => successResponse( - path, - data: { - 'access_token': accessToken, - 'user': user.toJson(), - }, - ), - ); - - final res = await guestApi.getGuestUser(user); - - expect(res, isNotNull); - expect(res.accessToken, accessToken); - expect(res.user.id, user.id); - - verify(() => client.post(path, data: any(named: 'data'))).called(1); - verifyNoMoreInteractions(client); - }); -} diff --git a/packages/stream_chat/test/src/core/api/responses_test.dart b/packages/stream_chat/test/src/core/api/responses_test.dart index b281d2fdbc..c19a3f6118 100644 --- a/packages/stream_chat/test/src/core/api/responses_test.dart +++ b/packages/stream_chat/test/src/core/api/responses_test.dart @@ -3505,14 +3505,6 @@ void main() { expect(response.users, isA>()); }); - test('ConnectGuestUserResponse', () { - const jsonExample = - '''{"user":{"id":"guest-ac612aee-25fe-49fb-b1af-969e41f452a0-wild-breeze-7","role":"guest","created_at":"2020-02-03T10:19:01.538434Z","updated_at":"2020-02-03T10:19:01.539543Z","banned":false,"online":false},"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiZ3Vlc3QtYWM2MTJhZWUtMjVmZS00OWZiLWIxYWYtOTY5ZTQxZjQ1MmEwLXdpbGQtYnJlZXplLTcifQ.mmoFGu7oJjpFsp7nFN78UbIpO7gowbuIbyoppsuvbXA","duration":"4.66ms"}'''; - final response = ConnectGuestUserResponse.fromJson(json.decode(jsonExample)); - expect(response.user, isA()); - expect(response.accessToken, isA()); - }); - test('GetMessagesByIdResponse', () { const jsonExample = r''' {"messages":[{ diff --git a/packages/stream_chat/test/src/core/api/stream_chat_api_test.dart b/packages/stream_chat/test/src/core/api/stream_chat_api_test.dart index 31d1e96662..dd43c93da0 100644 --- a/packages/stream_chat/test/src/core/api/stream_chat_api_test.dart +++ b/packages/stream_chat/test/src/core/api/stream_chat_api_test.dart @@ -19,10 +19,6 @@ void main() { expect(streamChatApi.user, isNotNull); }); - test('`.guest`', () { - expect(streamChatApi.guest, isNotNull); - }); - test('`.message`', () { expect(streamChatApi.message, isNotNull); }); diff --git a/packages/stream_chat/test/src/fakes.dart b/packages/stream_chat/test/src/fakes.dart index 20d3acd40c..cdb28df367 100644 --- a/packages/stream_chat/test/src/fakes.dart +++ b/packages/stream_chat/test/src/fakes.dart @@ -4,7 +4,6 @@ import 'package:mocktail/mocktail.dart'; import 'package:stream_chat/open_api/api.dart' as api; import 'package:stream_chat/src/core/api/channel_api.dart'; import 'package:stream_chat/src/core/api/general_api.dart'; -import 'package:stream_chat/src/core/api/guest_api.dart'; import 'package:stream_chat/src/core/api/message_api.dart'; import 'package:stream_chat/src/core/api/moderation_api.dart'; import 'package:stream_chat/src/core/api/polls_api.dart'; @@ -130,11 +129,6 @@ class FakeChatApi extends Fake implements StreamChatApi { @override UserApi get user => _user ??= MockUserApi(); - GuestApi? _guest; - - @override - GuestApi get guest => _guest ??= MockGuestApi(); - MessageApi? _message; @override diff --git a/packages/stream_chat/test/src/mocks.dart b/packages/stream_chat/test/src/mocks.dart index f222d2608e..004e7d7d64 100644 --- a/packages/stream_chat/test/src/mocks.dart +++ b/packages/stream_chat/test/src/mocks.dart @@ -8,7 +8,6 @@ import 'package:stream_chat/src/client/moderation_client.dart'; import 'package:stream_chat/src/core/api/attachment_file_uploader.dart'; import 'package:stream_chat/src/core/api/channel_api.dart'; import 'package:stream_chat/src/core/api/general_api.dart'; -import 'package:stream_chat/src/core/api/guest_api.dart'; import 'package:stream_chat/src/core/api/message_api.dart'; import 'package:stream_chat/src/core/api/moderation_api.dart'; import 'package:stream_chat/src/core/api/polls_api.dart'; @@ -45,8 +44,6 @@ class MockTokenManager extends Mock implements TokenManager {} class MockUserApi extends Mock implements UserApi {} -class MockGuestApi extends Mock implements GuestApi {} - class MockMessageApi extends Mock implements MessageApi {} class MockPollsApi extends Mock implements PollsApi {} From 8339e789141d084c5ad8d7cbbed6eed512df1b69 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 1 Oct 2026 14:10:39 +0200 Subject: [PATCH 02/32] refactor(llc)!: route polls through the generated client Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 93 ++++ openapi-migration/05-polls.md | 90 +++- openapi-migration/15-custom-data-rename.md | 38 ++ openapi-migration/README.md | 11 +- openapi-migration/tool/generate_plan.py | 92 +++- packages/stream_chat/CHANGELOG.md | 10 + .../lib/src/client/channel/channel.dart | 97 ++-- .../stream_chat/lib/src/client/client.dart | 145 ++--- .../lib/src/core/api/polls_api.dart | 197 ------- .../lib/src/core/api/responses.dart | 111 ---- .../lib/src/core/api/responses.g.dart | 237 ++------- .../lib/src/core/api/stream_chat_api.dart | 5 - .../models/converters/v1_json_converters.dart | 126 +++++ .../lib/src/core/models/draft_message.dart | 2 + .../lib/src/core/models/draft_message.g.dart | 5 +- .../lib/src/core/models/message.dart | 1 + .../lib/src/core/models/message.g.dart | 13 +- .../stream_chat/lib/src/core/models/poll.dart | 230 +++----- .../lib/src/core/models/poll.freezed.dart | 257 +++++++++ .../lib/src/core/models/poll.g.dart | 70 --- .../lib/src/core/models/poll_option.dart | 71 +-- .../src/core/models/poll_option.freezed.dart | 81 +++ .../lib/src/core/models/poll_vote.dart | 109 ++-- .../src/core/models/poll_vote.freezed.dart | 129 +++++ .../lib/src/core/models/poll_vote.g.dart | 24 - .../models/response/poll_option_response.dart | 23 + .../poll_option_response.freezed.dart | 77 +++ .../core/models/response/poll_response.dart | 23 + .../response/poll_response.freezed.dart | 76 +++ .../models/response/poll_vote_response.dart | 23 + .../response/poll_vote_response.freezed.dart | 77 +++ .../response/query_poll_votes_response.dart | 33 ++ .../query_poll_votes_response.freezed.dart | 89 ++++ .../models/response/query_polls_response.dart | 33 ++ .../query_polls_response.freezed.dart | 89 ++++ .../src/core/models/voting_visibility.dart | 11 + .../src/repository/mapper/polls_mapper.dart | 182 +++++++ .../lib/src/repository/polls_repository.dart | 194 +++++++ .../stream_chat/lib/src/ws/events/event.dart | 3 + .../lib/src/ws/events/event.g.dart | 34 +- packages/stream_chat/lib/stream_chat.dart | 6 + .../client/channel/channel_polls_test.dart | 332 ++++++++++++ .../src/client/client_poll_options_test.dart | 159 ++++++ .../src/client/client_poll_votes_test.dart | 183 +++++++ .../test/src/client/client_polls_test.dart | 337 ++++++++++++ .../test/src/client/client_test.dart | 334 ------------ .../test/src/client/poll_fixtures.dart | 176 +++++++ .../test/src/core/api/polls_api_test.dart | 495 ------------------ .../src/core/api/stream_chat_api_test.dart | 4 - .../models/converters/poll_v1_json_test.dart | 261 +++++++++ .../src/core/models/poll_option_test.dart | 32 ++ .../test/src/core/models/poll_test.dart | 213 ++++---- .../test/src/core/models/poll_vote_test.dart | 60 ++- packages/stream_chat/test/src/fakes.dart | 6 - packages/stream_chat/test/src/mocks.dart | 3 - .../stream_chat_flutter_core/CHANGELOG.md | 1 + .../src/stream_poll_vote_list_controller.dart | 87 ++- ...stream_poll_vote_list_controller_test.dart | 138 +++++ packages/stream_chat_persistence/CHANGELOG.md | 1 + .../voting_visibility_converter.dart | 22 +- .../lib/src/db/drift_chat_database.dart | 2 +- .../lib/src/mapper/poll_mapper.dart | 7 +- .../voting_visibility_converter_test.dart | 7 +- .../test/src/mapper/poll_mapper_test.dart | 34 +- 64 files changed, 4014 insertions(+), 2097 deletions(-) create mode 100644 openapi-migration/15-custom-data-rename.md delete mode 100644 packages/stream_chat/lib/src/core/api/polls_api.dart create mode 100644 packages/stream_chat/lib/src/core/models/poll.freezed.dart delete mode 100644 packages/stream_chat/lib/src/core/models/poll.g.dart create mode 100644 packages/stream_chat/lib/src/core/models/poll_option.freezed.dart create mode 100644 packages/stream_chat/lib/src/core/models/poll_vote.freezed.dart delete mode 100644 packages/stream_chat/lib/src/core/models/poll_vote.g.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/poll_option_response.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/poll_option_response.freezed.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/poll_response.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/poll_response.freezed.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/poll_vote_response.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/poll_vote_response.freezed.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/query_poll_votes_response.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/query_poll_votes_response.freezed.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/query_polls_response.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/query_polls_response.freezed.dart create mode 100644 packages/stream_chat/lib/src/core/models/voting_visibility.dart create mode 100644 packages/stream_chat/lib/src/repository/mapper/polls_mapper.dart create mode 100644 packages/stream_chat/lib/src/repository/polls_repository.dart create mode 100644 packages/stream_chat/test/src/client/channel/channel_polls_test.dart create mode 100644 packages/stream_chat/test/src/client/client_poll_options_test.dart create mode 100644 packages/stream_chat/test/src/client/client_poll_votes_test.dart create mode 100644 packages/stream_chat/test/src/client/client_polls_test.dart create mode 100644 packages/stream_chat/test/src/client/poll_fixtures.dart delete mode 100644 packages/stream_chat/test/src/core/api/polls_api_test.dart create mode 100644 packages/stream_chat/test/src/core/models/converters/poll_v1_json_test.dart create mode 100644 packages/stream_chat/test/src/core/models/poll_option_test.dart create mode 100644 packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index e23da88d90..8e8f356344 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -30,6 +30,7 @@ onto Stream's OpenAPI-generated API client. - [Moderation](#moderation) - [App Settings](#app-settings) - [Guest Users](#guest-users) + - [Polls](#polls) - [User Groups](#user-groups) - [Link Previews](#link-previews) - [Migration Checklist](#migration-checklist) @@ -76,6 +77,7 @@ from the spec, so don't subclass them or depend on their private constructors. | [**Moderation**](#moderation) | Muting, banning and flagging return a `Result` and call the moderation v2 API; `banUser`'s options map becomes named parameters; `unflagMessage`, `unflagUser` and `removeShadowBan` are removed | | [**App Settings**](#app-settings) | `getAppSettings` returns a `Result` instead of throwing; `GetAppSettingsResponse` is renamed `AppSettingsResponse` | | [**Guest Users**](#guest-users) | `connectGuestUser` is unchanged; `StreamChatApi.guest` and `ConnectGuestUserResponse` are removed | +| [**Polls**](#polls) | Poll calls return a `Result`; eight responses become `PollResponse`, `PollOptionResponse` and `PollVoteResponse`; queries take `limit`/`next`/`prev`; `VotingVisibility` is an extension type | | _(filled in per feature as PRs land)_ | | --- @@ -212,6 +214,22 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `StreamChatApi.general.getAppSettings()` | `StreamChatClient.getAppSettings()` | `removed` | The call moved to the generated client | | `StreamChatApi.guest` (`GuestApi.getGuestUser`) | `StreamChatClient.connectGuestUser` | `removed` | The call moved to the generated client; `connectGuestUser` keeps its signature and still throws | | `ConnectGuestUserResponse` | — | `removed` | Only `StreamChatApi.guest` returned it; `connectGuestUser` still returns the connected `OwnUser` | +| `StreamChatClient` poll methods → `Future` and the like | `Future>` and the like | `retyped` | `createPoll`, `getPoll`, `updatePoll`, `partialUpdatePoll`, `closePoll`, `createPollOption`, `getPollOption`, `updatePollOption`, `castPollVote`, `addPollAnswer`, `removePollVote`, `queryPolls` and `queryPollVotes` return a `Result` instead of throwing | +| `StreamChatClient.deletePoll` / `.deletePollOption` → `Future` | `Future>` | `retyped` | No value on success | +| `Channel.sendPoll` / `.updatePoll` / `.deletePoll` / `.closePoll` / `.createPollOption` / `.castPollVote` / `.addPollAnswer` / `.removePollVote` / `.queryPollVotes` | the same names, returning a `Result` | `retyped` | `sendPoll` also returns a failure when the message cannot be sent, rather than throwing | +| `CreatePollResponse` / `GetPollResponse` / `UpdatePollResponse` | `PollResponse` | `renamed` | Same fields: `duration` and `poll` | +| `CreatePollOptionResponse` / `GetPollOptionResponse` / `UpdatePollOptionResponse` | `PollOptionResponse` | `renamed` | Same fields: `duration` and `pollOption` | +| `CastPollVoteResponse` / `RemovePollVoteResponse` | `PollVoteResponse` | `renamed` | `vote` is nullable | +| `queryPolls(pagination: PaginationParams(limit: l, next: n))` / `queryPollVotes(…)` | `queryPolls(limit: l, next: n)` / `queryPollVotes(…)` | `retyped` | `limit` is still 10 when omitted. Pass a response's `prev` as `prev` to page backwards. The other `PaginationParams` fields never had an effect on these queries | +| `Channel.castPollVote` / `.removePollVote` throwing `ArgumentError` | a `Failure` carrying a `StreamClientException` | `retyped` | For an option or vote without an id. `updatePoll` and `updatePollOption` fail the same way for an option without one | +| `VotingVisibility` (enum) | `VotingVisibility` (extension type over `String`) | `retyped` | `VotingVisibility.public` and `.anonymous` are unchanged; a `switch` needs a default arm, and `.name` / `.values` are gone — read `.value` | +| `Poll.fromJson` / `.toJson`, `PollOption.fromJson` / `.toJson`, `PollVote.fromJson` / `.toJson` | — | `removed` | The models are plain classes; construct them directly. `Message.poll` and the poll events still decode from the same keys | +| — | `PollOption.fromData` / `.toData` | `added` | Read and write only the format the offline database stores; not a codec for API payloads | +| `CreatePollResponse()..poll = …` and the other response setters | `PollResponse(duration: …, poll: …)` | `retyped` | The responses are plain classes with a const constructor and final fields | +| poll responses' `duration` (`String?`) | `String` | `retyped` | Always present; drop any `!` or `?? ''` | +| `Poll` / `PollOption` / `PollVote extends Equatable`, `props` | value `==`, plus `copyWith` | `removed` | `Poll` and `PollOption` now compare `extraData` too. `copyWith` sets a field passed as `null`: a `null` `Poll.id` generates a new id, and a `null` `createdAt` / `updatedAt` becomes the current time | +| `Poll.ownVotes` / `.ownAnswers` / `.latestVotes` (late fields), `PollVote.isAnswer` (a field set in the constructor) | the same names, as getters | `retyped` | Computed on each read | +| `StreamChatApi.polls` (`PollsApi`) | `StreamChatClient`'s poll methods | `removed` | The endpoints moved to the generated client | | _(more added per feature as PRs land)_ | | | | --- @@ -729,6 +747,81 @@ queried, and the unread counts when connecting with `connectWebSocket: false`. N guest. v11 sends its id, name, image, language, `invisible`, an `OwnUser`'s privacy settings, and your own `extraData`. If you read one of those values back, store it as a custom field of your own instead. +### Polls + +**Every poll call returns a `Result` instead of throwing,** on `StreamChatClient` and on `Channel`. +`deletePoll` and `deletePollOption` carry no value on success. Calling a `Channel` method on a channel that is not +initialized still throws a `StateError`, as in v10. + +```dart +// v10 +try { + final response = await client.getPoll(pollId); + showPoll(response.poll); +} on StreamChatException catch (e) { + report(e); +} + +// v11 +final result = await client.getPoll(pollId); +result.fold( + onSuccess: (response) => showPoll(response.poll), + onFailure: (error, _) => report(error), +); +``` + +**`Channel.sendPoll` reports both of its steps through the `Result`.** It creates the poll and then sends it in a +message; a failure in either comes back as a `Failure`, where v10 threw. If the message fails, the poll stays created. + +**Eight responses become three.** Creating, fetching and updating a poll all answer a `PollResponse`; the same calls +on an option answer a `PollOptionResponse`; casting and removing a vote answer a `PollVoteResponse`. The fields are +unchanged, except that `PollVoteResponse.vote` is nullable. + +```dart +// v10 +final CreatePollResponse created = await client.createPoll(poll); + +// v11 +final Result created = await client.createPoll(poll); +``` + +**`queryPolls` and `queryPollVotes` take `limit`, `next` and `prev`** instead of a `PaginationParams`. `limit` is still +10 when omitted. The offset and id-based fields of `PaginationParams` never had an effect on these queries. The +responses now carry a `prev` cursor next to `next`; pass it as `prev` to fetch the page before. + +```dart +// v10 +await channel.queryPollVotes(pollId, pagination: PaginationParams(limit: 10, next: cursor)); + +// v11 +await channel.queryPollVotes(pollId, limit: 10, next: cursor); +``` + +**A missing id is a failure, not an `ArgumentError`.** `Channel.castPollVote` with an option that has no id, and +`removePollVote` with a vote that has none, return a `Failure` carrying a `StreamClientException`. `updatePoll` and +`updatePollOption` do the same for an option without an id, without sending anything. + +**`VotingVisibility` is an extension type over its wire string.** `VotingVisibility.public` and +`VotingVisibility.anonymous` read the same, and a visibility the SDK does not name is kept rather than rejected. +A `switch` over it needs a default arm, and `.name` / `.values` are gone — read `.value`. + +**`Poll`, `PollOption`, `PollVote` and the responses no longer decode JSON.** Build them with their constructors — +`PollResponse(duration: '0ms', poll: poll)` where v10 wrote `CreatePollResponse()..poll = poll`. `Message.poll` and +the poll events still decode from the same keys. `PollOption.fromData` and `toData` read and write the format the +offline database stores; they are not a way to decode API responses. + +**`Poll`, `PollOption` and `PollVote` no longer extend `Equatable`.** They still compare by value, `props` is gone, +and two changes follow: + +- `Poll` and `PollOption` now include `extraData` in `==`. Two polls that differ only in custom data are no longer + equal, so neither are the messages that carry them; a widget comparing them rebuilds when only custom data + changes. +- `copyWith` sets a field passed as `null` instead of keeping it — `poll.copyWith(description: null)` clears the + description. Fields with a default take it again: a `null` `Poll.id` generates a new id, and a `null` `createdAt` or + `updatedAt` becomes the current time. + +**`StreamChatApi.polls` is removed.** Call the poll methods on `StreamChatClient` or `Channel` instead. + --- ## Migration Checklist diff --git a/openapi-migration/05-polls.md b/openapi-migration/05-polls.md index 39991be852..8b591ba349 100644 --- a/openapi-migration/05-polls.md +++ b/openapi-migration/05-polls.md @@ -2,7 +2,7 @@ **Goal:** First group with real domain models and persistence behind it. -**Size:** 13 hand-written method(s) across 1 file(s) → 13 generated operation(s). +**Size:** 0 hand-written method(s) across 0 file(s) → 13 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. @@ -13,19 +13,6 @@ | File | Method | Returns | | --- | --- | --- | -| `polls_api.dart` | `createPoll` | `CreatePollResponse` | -| `polls_api.dart` | `getPoll` | `GetPollResponse` | -| `polls_api.dart` | `updatePoll` | `UpdatePollResponse` | -| `polls_api.dart` | `partialUpdatePoll` | `UpdatePollResponse` | -| `polls_api.dart` | `deletePoll` | `EmptyResponse` | -| `polls_api.dart` | `createPollOption` | `CreatePollOptionResponse` | -| `polls_api.dart` | `getPollOption` | `GetPollOptionResponse` | -| `polls_api.dart` | `updatePollOption` | `UpdatePollOptionResponse` | -| `polls_api.dart` | `deletePollOption` | `EmptyResponse` | -| `polls_api.dart` | `castPollVote` | `CastPollVoteResponse` | -| `polls_api.dart` | `removePollVote` | `RemovePollVoteResponse` | -| `polls_api.dart` | `queryPolls` | `QueryPollsResponse` | -| `polls_api.dart` | `queryPollVotes` | `QueryPollVotesResponse` | ### Generated operations that cover it @@ -45,11 +32,60 @@ | `PUT` | `/api/v2/polls/{poll_id}/options` | `updatePollOption` | `PollOptionResponse` | | `PATCH` | `/api/v2/polls/{poll_id}` | `updatePollPartial` | `PollResponse` | -## Decisions to make +## Decisions taken -- `Poll`, `PollOption` and `PollVote` are public and persisted; adopting generated shapes means touching `stream_chat_persistence` in the same PR. -- `VotingVisibility` is ours; the generated equivalent is an inline per-operation enum. -- One generated `PollResponse` answers `createPoll`, `getPoll`, `updatePoll` and `updatePollPartial`, where v10 has `CreatePollResponse`, `GetPollResponse` and `UpdatePollResponse`. The v10 envelopes stay; decide with the user how the mapper names its conversions, since one `toModel()` cannot return three types. `PollOptionResponse` (create, get, update an option) and `PollVoteResponse` (cast, remove a vote) have the same shape. +- **`Poll`, `PollOption` and `PollVote` keep their v10 names, fields and defaults,** as `@freezed` + classes with no JSON, mapped from `PollResponseData`, `PollOptionResponseData` and + `PollVoteResponseData` in `lib/src/repository/mapper/polls_mapper.dart`. The uuid default `id` and the + `DateTime.now()` timestamps stay; `latestVotes`, `ownVotes`, `ownAnswers` and `PollVote.isAnswer` + become getters. +- **Equality now includes `extraData`.** v10's `Equatable` props left it out of `Poll` and `PollOption`; + freezed compares every field, and so does `Message`, whose props include its poll. Accepted as a + behavioural break and flagged for review. +- **The eight v10 envelopes collapse to the three the server returns:** `PollResponse` (create, get, + update, partial update), `PollOptionResponse` (create, get, update an option) and `PollVoteResponse` + (cast, remove a vote), each with one `toModel()`. `QueryPollsResponse` and `QueryPollVotesResponse` + keep their names. A rename break beyond the [sanctioned ones](README.md#domain-models), approved for + this group and flagged for review. `PollVoteResponse.vote` is nullable, as the spec marks it. +- **The fields only the generated types carry stay out:** the `*_i18n` maps, `is_answer` (derived from + `answerText`) and `PollVoteResponse.poll`. Adding them is additive. The i18n keys are known fields + (`topLevelFields`), so neither the mapper nor the v1 decoder puts them in `extraData`, and a poll + compares equal whichever path delivered it. +- **Write methods keep taking `Poll` and `PollOption`.** The mapper sends only the writable settings; + the vote summary, `createdBy` and timestamps are not sent. Dedicated request types were considered + and rejected: the draft a caller builds must stay a `Poll`, because a message carries one. Flagged + for review. +- **Option ids are not sent on create.** `PollOptionInput` has none; the server assigns them and + rejects a client id as reserved (confirmed live: v10 answered 400). **`updatePoll` and + `updatePollOption` fail without a request when an option has no id,** since the server requires one. +- **`queryPolls` and `queryPollVotes` take `limit`, `next` and `prev`** instead of `PaginationParams`. + The generated requests carry only those, and the backend never read the offset or id/date cursors on + these queries. `limit` defaults to 10, `PaginationParams`' default, so an omitted limit pages as in + v10. The responses expose `prev`, so the parameter is usable. Flagged for review. +- **`VotingVisibility` becomes an extension type over its wire string,** following `PushProvider`. An + unknown value is kept rather than rejected, in the mapper, the v1 decoder and persistence. +- **An answer's `optionId` stays `""`,** as the server sends it and v10 decoded it. +- **`custom` becomes `extraData` without the keys named after the model's own fields** + (`Poll.topLevelFields`, `PollOption.topLevelFields`), matching v1's flat JSON, which shadowed them. +- **Every public poll method returns a `Result`,** on `StreamChatClient` and `Channel`. `deletePoll` and + `deletePollOption` answer `DurationResponse` and return `Result`. `closePoll` stays a + `partialUpdatePoll` that sets `is_closed`. +- **`Channel.sendPoll` returns `Result`.** A `createPoll` failure passes through + and no message is sent; a `StreamException` thrown by `sendMessage` (still v1) is caught into a + `Failure`. +- **A missing option or vote id is a `Failure(StreamClientException)`, not an `ArgumentError`,** in + `Channel.castPollVote` and `removePollVote`, matching the migrated never-throws contract. +- **v1 JSON keeps decoding through hand-written converters** (`PollV1JsonConverter`, + `PollVoteV1JsonConverter`) on `Message.poll`, `DraftMessage.poll`, `Event.poll` and `Event.pollVote`. + The WebSocket is v1-only and sends custom data flat, with no `custom` key, so the generated + `fromJson` would throw on it; the converters mirror v10's `fromJson` and tolerate missing keys. + `Event` writes these fields too, so its converters run both ways. +- **`PollOption` gets the `@DataSerializable` codec** for `polls.options`. The stored format nests + custom data under `extra_data` where v10 flattened it, so `schemaVersion` is bumped. +- **`extraData` keeps its name.** Renaming it to `custom` is a migration-wide step for every model at + once — [group 15](15-custom-data-rename.md). +- **Verified live** against the demo app with a before/after harness covering every endpoint, the + error cases and the WebSocket events, and in the sample app on web. ## Risks @@ -59,17 +95,17 @@ ## Definition of done -- [ ] Every method above either routes through `DefaultApi` or is listed here as deliberately left +- [x] Every method above either routes through `DefaultApi` or is listed here as deliberately left hand-written, with the reason. -- [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. -- [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. -- [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group +- [x] Public methods return `Future>`; no `getOrThrow()` inside the SDK. +- [x] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [x] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. -- [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. -- [ ] CHANGELOG entry under `🛑️ Breaking` for each break; PR title `refactor(llc)!:`. -- [ ] Decisions recorded in this file, and the status box ticked in `README.md`. -- [ ] Public dartdoc follows [`STYLE_GUIDE.md` § Documentation](../STYLE_GUIDE.md#documentation) +- [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. -- [ ] Tests follow [`TESTING.md`](../TESTING.md): no `group` organizing a file by method, each +- [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/15-custom-data-rename.md b/openapi-migration/15-custom-data-rename.md new file mode 100644 index 0000000000..ac4816877d --- /dev/null +++ b/openapi-migration/15-custom-data-rename.md @@ -0,0 +1,38 @@ +# 15 — `extraData` → `custom` + +**Goal:** Rename `extraData` to `custom` on every public model at once, matching the generated client. + +**Size:** 0 hand-written method(s) across 0 file(s) → 0 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 | +| --- | --- | --- | --- | + +## Decisions to make + +- **Whether to rename at all.** Every model names its custom data `extraData` today; the generated client calls it `custom`. Renaming one model at a time would leave the SDK inconsistent, so the groups keep `extraData` and this step decides for every model together. +- **How to stage it.** Add `custom` beside a deprecated `extraData`, or rename in one break. + +## Risks + +- `Serializer`, the models' `topLevelFields` and the persistence `extra_data` columns name the concept too. +- Runs after group 09 at the earliest, once `User` is restructured. + +## Definition of done + +- [ ] Every public model names its custom data the same way. +- [ ] The mappers and the v1 decoders read and write the renamed field. +- [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section. +- [ ] CHANGELOG entry under `🛑️ Breaking` for each break; PR title `refactor(llc)!:`. +- [ ] Decisions recorded in this file, and the status box ticked in `README.md`. diff --git a/openapi-migration/README.md b/openapi-migration/README.md index cf2b62507a..03ba6bc099 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -16,7 +16,7 @@ generated operations in scope, the decisions that group has to make, its risks, | [02](02-devices.md) | Devices | 0 | 3 | ☑ | | [03](03-user-groups.md) | User Groups | 0 | 8 | ☑ | | [04](04-roles-guest-and-app.md) | Roles, Guest & App Settings | 0 | 5 | ☑ | -| [05](05-polls.md) | Polls | 13 | 13 | ☐ | +| [05](05-polls.md) | Polls | 0 | 13 | ☑ | | [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 | ☑ | @@ -26,8 +26,9 @@ generated operations in scope, the decisions that group has to make, its risks, | [12](12-uploads-cdn.md) | Uploads (CDN) | 8 | 8 | ☐ | | [13](13-push-preferences.md) | Push Preferences | 1 | 1 | ☐ | | [14](14-banned-users.md) | Banned Users — split out of 08 | 1 | 1 | ☐ | +| [15](15-custom-data-rename.md) | `extraData` → `custom`, every model at once | — | — | ☐ | -**Coverage:** 85 hand-written methods across 11 files, and all 129 generated operations, each claimed by exactly +**Coverage:** 71 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). @@ -142,7 +143,9 @@ includes a named response that carries only `duration` today, such as `HideChann | --- | --- | --- | | `DeviceV1JsonConverter` | `OwnUser.devices` | [09](09-users.md) | | `userGroupsFromV1Json` | `Message.mentionedGroups` | [10](10-messages.md) | -| `DataSerializable` | `UserGroup`, `UserGroupMember` (`fromData`, `toData`) | [10](10-messages.md) | +| `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) | How v1 JSON decodes `User` once it becomes a plain model is decided in [01-foundation](01-foundation.md): until @@ -167,6 +170,8 @@ surfaces before it reaches `Message` and `ChannelState`: SDK. - **09** is last. Every group before it maps users through `user_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. +- **15** renames `extraData` to `custom` on every model at once. The groups keep `extraData`, so the SDK stays + consistent until then; it runs after 09. ## Prerequisites diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 2ece18d044..765929b96c 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -305,19 +305,65 @@ def match(path): ), dict( num='05', slug='polls', title='Polls', - hand=['polls_api.dart'], + hand=[], match=owns('/api/v2/polls', '/api/v2/chat/messages/{message_id}/polls'), goal='First group with real domain models and persistence behind it.', - decisions=[ - '`Poll`, `PollOption` and `PollVote` are public and persisted; adopting generated shapes means ' - 'touching `stream_chat_persistence` in the same PR.', - '`VotingVisibility` is ours; the generated equivalent is an inline per-operation enum.', - 'One generated `PollResponse` answers `createPoll`, `getPoll`, `updatePoll` and `updatePollPartial`, ' - 'where v10 has `CreatePollResponse`, `GetPollResponse` and `UpdatePollResponse`. The v10 envelopes ' - 'stay; decide with the user how the mapper names its conversions, since one `toModel()` cannot return ' - 'three types. `PollOptionResponse` (create, get, update an option) and `PollVoteResponse` (cast, remove ' - 'a vote) have the same shape.', - ], + decisions=[], + taken=textwrap.dedent("""\ + - **`Poll`, `PollOption` and `PollVote` keep their v10 names, fields and defaults,** as `@freezed` + classes with no JSON, mapped from `PollResponseData`, `PollOptionResponseData` and + `PollVoteResponseData` in `lib/src/repository/mapper/polls_mapper.dart`. The uuid default `id` and the + `DateTime.now()` timestamps stay; `latestVotes`, `ownVotes`, `ownAnswers` and `PollVote.isAnswer` + become getters. + - **Equality now includes `extraData`.** v10's `Equatable` props left it out of `Poll` and `PollOption`; + freezed compares every field, and so does `Message`, whose props include its poll. Accepted as a + behavioural break and flagged for review. + - **The eight v10 envelopes collapse to the three the server returns:** `PollResponse` (create, get, + update, partial update), `PollOptionResponse` (create, get, update an option) and `PollVoteResponse` + (cast, remove a vote), each with one `toModel()`. `QueryPollsResponse` and `QueryPollVotesResponse` + keep their names. A rename break beyond the [sanctioned ones](README.md#domain-models), approved for + this group and flagged for review. `PollVoteResponse.vote` is nullable, as the spec marks it. + - **The fields only the generated types carry stay out:** the `*_i18n` maps, `is_answer` (derived from + `answerText`) and `PollVoteResponse.poll`. Adding them is additive. The i18n keys are known fields + (`topLevelFields`), so neither the mapper nor the v1 decoder puts them in `extraData`, and a poll + compares equal whichever path delivered it. + - **Write methods keep taking `Poll` and `PollOption`.** The mapper sends only the writable settings; + the vote summary, `createdBy` and timestamps are not sent. Dedicated request types were considered + and rejected: the draft a caller builds must stay a `Poll`, because a message carries one. Flagged + for review. + - **Option ids are not sent on create.** `PollOptionInput` has none; the server assigns them and + rejects a client id as reserved (confirmed live: v10 answered 400). **`updatePoll` and + `updatePollOption` fail without a request when an option has no id,** since the server requires one. + - **`queryPolls` and `queryPollVotes` take `limit`, `next` and `prev`** instead of `PaginationParams`. + The generated requests carry only those, and the backend never read the offset or id/date cursors on + these queries. `limit` defaults to 10, `PaginationParams`' default, so an omitted limit pages as in + v10. The responses expose `prev`, so the parameter is usable. Flagged for review. + - **`VotingVisibility` becomes an extension type over its wire string,** following `PushProvider`. An + unknown value is kept rather than rejected, in the mapper, the v1 decoder and persistence. + - **An answer's `optionId` stays `""`,** as the server sends it and v10 decoded it. + - **`custom` becomes `extraData` without the keys named after the model's own fields** + (`Poll.topLevelFields`, `PollOption.topLevelFields`), matching v1's flat JSON, which shadowed them. + - **Every public poll method returns a `Result`,** on `StreamChatClient` and `Channel`. `deletePoll` and + `deletePollOption` answer `DurationResponse` and return `Result`. `closePoll` stays a + `partialUpdatePoll` that sets `is_closed`. + - **`Channel.sendPoll` returns `Result`.** A `createPoll` failure passes through + and no message is sent; a `StreamException` thrown by `sendMessage` (still v1) is caught into a + `Failure`. + - **A missing option or vote id is a `Failure(StreamClientException)`, not an `ArgumentError`,** in + `Channel.castPollVote` and `removePollVote`, matching the migrated never-throws contract. + - **v1 JSON keeps decoding through hand-written converters** (`PollV1JsonConverter`, + `PollVoteV1JsonConverter`) on `Message.poll`, `DraftMessage.poll`, `Event.poll` and `Event.pollVote`. + The WebSocket is v1-only and sends custom data flat, with no `custom` key, so the generated + `fromJson` would throw on it; the converters mirror v10's `fromJson` and tolerate missing keys. + `Event` writes these fields too, so its converters run both ways. + - **`PollOption` gets the `@DataSerializable` codec** for `polls.options`. The stored format nests + custom data under `extra_data` where v10 flattened it, so `schemaVersion` is bumped. + - **`extraData` keeps its name.** Renaming it to `custom` is a migration-wide step for every model at + once — [group 15](15-custom-data-rename.md). + - **Verified live** against the demo app with a before/after harness covering every endpoint, the + error cases and the WebSocket events, and in the sample app on web. + """), + done=DONE.replace('- [ ]', '- [x]'), risks=[ 'Vote operations live under `/chat/messages/{message_id}/polls/...`, not `/polls` — easy to miss when ' 'grepping by path.', @@ -689,6 +735,30 @@ def match(path): '`StreamChatApi.moderation` getter.', ], ), + dict( + num='15', slug='custom-data-rename', title='`extraData` → `custom`', + hand=[], + match=owns(), + goal='Rename `extraData` to `custom` on every public model at once, matching the generated client.', + decisions=[ + '**Whether to rename at all.** Every model names its custom data `extraData` today; the generated ' + 'client calls it `custom`. Renaming one model at a time would leave the SDK inconsistent, so the ' + 'groups keep `extraData` and this step decides for every model together.', + '**How to stage it.** Add `custom` beside a deprecated `extraData`, or rename in one break.', + ], + done=textwrap.dedent("""\ + - [ ] Every public model names its custom data the same way. + - [ ] The mappers and the v1 decoders read and write the renamed field. + - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section. + - [ ] CHANGELOG entry under `🛑️ Breaking` for each break; PR title `refactor(llc)!:`. + - [ ] Decisions recorded in this file, and the status box ticked in `README.md`. + """), + risks=[ + '`Serializer`, the models\' `topLevelFields` and the persistence `extra_data` columns name the ' + 'concept too.', + 'Runs after group 09 at the earliest, once `User` is restructured.', + ], + ), ] diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index b6fe47a83b..ac43fee2c2 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -79,6 +79,16 @@ - `StreamChatApi.general.getAppSettings` is removed; call `StreamChatClient.getAppSettings` instead. - `StreamChatApi.guest` is removed; call `StreamChatClient.connectGuestUser` instead. - `ConnectGuestUserResponse` is removed from the public API. +- `StreamChatClient`'s poll methods and `Channel.sendPoll`, `updatePoll`, `deletePoll`, `closePoll`, `createPollOption`, `castPollVote`, `addPollAnswer`, `removePollVote` and `queryPollVotes` return a `Result` instead of throwing; `deletePoll` and `deletePollOption` return a `Result`. +- `CreatePollResponse`, `GetPollResponse` and `UpdatePollResponse` are replaced by `PollResponse`; `CreatePollOptionResponse`, `GetPollOptionResponse` and `UpdatePollOptionResponse` by `PollOptionResponse`; and `CastPollVoteResponse` and `RemovePollVoteResponse` by `PollVoteResponse`. +- `queryPolls` and `queryPollVotes` take `limit` (10 when omitted), `next` and `prev` instead of `PaginationParams`, and their responses carry a `prev` cursor. +- `Channel.castPollVote` and `removePollVote` return a failure instead of throwing an `ArgumentError` for an option or vote without an id; `updatePoll` and `updatePollOption` do the same for an option without one. +- `VotingVisibility` is an extension type over its wire string instead of an enum. +- `Poll`, `PollOption`, `PollVote` and the poll responses no longer decode from or encode to JSON; `PollOption` gains `fromData` and `toData`, which read and write only the format `stream_chat_persistence` stores it in. +- The poll responses are immutable, built through a const constructor, and their `duration` is a non-nullable `String`; `PollVoteResponse.vote` is nullable. +- `Poll`, `PollOption` and `PollVote` no longer extend `Equatable`, so `props` is removed; they still compare by value, now including `extraData`. +- `copyWith` on `Poll`, `PollOption` and `PollVote` sets a field passed as `null` instead of keeping it; on `Poll` a `null` `id` generates a new one, and a `null` `createdAt` or `updatedAt` on `Poll` and `PollVote` becomes the current time. +- `StreamChatApi.polls` is removed; its endpoints are reached through `StreamChatClient`. 🐞 Fixed diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index 05c70a1908..5d95914843 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -1444,44 +1444,60 @@ class Channel { final _pollLock = Lock(); - /// Send a message with a poll to this channel. + /// Creates [poll] and sends it to this channel in a new message. /// - /// Optionally provide a [messageText] to send a message along with the poll. - Future sendPoll( + /// The message carries [messageText] as its text, if given. If the poll cannot be created, no message is sent and + /// the failure is returned; if the message cannot be sent, the poll stays created and the failure is returned. + Future> sendPoll( Poll poll, { String? messageText, }) async { _checkInitialized(); - final res = await _pollLock.synchronized(() => _client.createPoll(poll)); - return sendMessage( - Message( - text: messageText, - poll: res.poll, - pollId: res.poll.id, - ), - ); + final created = await _pollLock.synchronized(() => _client.createPoll(poll)); + + return switch (created) { + Success(data: PollResponse(poll: final createdPoll)) => _sendPollMessage(createdPoll, messageText), + Failure(:final error, :final stackTrace) => Result.failure(error, stackTrace), + }; } - /// Updates the [poll] in this channel. - Future updatePoll(Poll poll) { + Future> _sendPollMessage(Poll poll, String? messageText) async { + try { + final message = Message(text: messageText, poll: poll, pollId: poll.id); + return Result.success(await sendMessage(message)); + } on StreamException catch (error, stackTrace) { + return Result.failure(error, stackTrace); + } catch (error, stackTrace) { + final exception = StreamClientException(message: 'Failed to send the poll message', cause: error); + return Result.failure(exception, stackTrace); + } + } + + /// Replaces the settings and options of [poll] with the ones it carries. + /// + /// An option left out of [poll] is removed from it. Every option must already exist and carry its id; if one does + /// not, the call returns a failure without updating the poll. New options are added with [createPollOption]. + Future> updatePoll(Poll poll) { _checkInitialized(); return _pollLock.synchronized(() => _client.updatePoll(poll)); } - /// Deletes the given [poll] from this channel. - Future deletePoll(Poll poll) { + /// Deletes [poll]. + Future> deletePoll(Poll poll) { _checkInitialized(); return _pollLock.synchronized(() => _client.deletePoll(poll.id)); } - /// Close the given [poll]. - Future closePoll(Poll poll) { + /// Closes [poll], so it accepts no more votes or answers. + Future> closePoll(Poll poll) { _checkInitialized(); return _pollLock.synchronized(() => _client.closePoll(poll.id)); } - /// Create a new poll option for the given [poll]. - Future createPollOption( + /// Adds [option] to [poll]. + /// + /// The option gets a new id, which the returned option carries. + Future> createPollOption( Poll poll, PollOption option, ) { @@ -1493,8 +1509,11 @@ class Channel { final _pollVoteLock = Lock(); - /// Cast a vote on the given [poll] with the given [option]. - Future castPollVote( + /// Votes for [option] of [poll], which was sent in [message]. + /// + /// If the poll allows only one vote per user, the vote replaces the previous one. The [option] must carry its id; + /// if it does not, the call returns a failure without voting. + Future> castPollVote( Message message, Poll poll, PollOption option, @@ -1503,7 +1522,9 @@ class Channel { final optionId = option.id; if (optionId == null) { - throw ArgumentError('Option id cannot be null'); + return const Result.failure( + StreamClientException(message: 'The poll option has no id, so it cannot be voted for.'), + ); } return _pollVoteLock.synchronized( @@ -1515,8 +1536,10 @@ class Channel { ); } - /// Add a new answer to the given [poll]. - Future addPollAnswer( + /// Leaves [answerText] as an answer on [poll], which was sent in [message]. + /// + /// Each user has one answer per poll; a new one replaces the previous one. + Future> addPollAnswer( Message message, Poll poll, { required String answerText, @@ -1531,17 +1554,19 @@ class Channel { ); } - /// Remove a vote on the given [poll] with the given [vote]. - Future removePollVote( + /// Removes [vote], a vote or answer on [poll], which was sent in [message]. + /// + /// The [vote] must carry its id; if it does not, the call returns a failure without removing anything. + Future> removePollVote( Message message, Poll poll, PollVote vote, - ) { + ) async { _checkInitialized(); final voteId = vote.id; if (voteId == null) { - throw ArgumentError('Vote id cannot be null'); + return const Result.failure(StreamClientException(message: 'The poll vote has no id, so it cannot be removed.')); } return _pollVoteLock.synchronized( @@ -1553,20 +1578,26 @@ class Channel { ); } - /// Query the poll votes for the given [pollId] with the given [filter] and - /// [sort] options. - Future queryPollVotes( + /// Fetches one page of the votes and answers of the poll [pollId] matching [filter], ordered by [sort]. + /// + /// Up to [limit] votes are returned. The next page is fetched by passing the `next` cursor of a response as [next], + /// and the previous page by passing its `prev` cursor as [prev]; pass at most one of the two. + Future> queryPollVotes( String pollId, { PollVoteFilter? filter, List? sort, - PaginationParams pagination = const PaginationParams(), + int limit = 10, + String? next, + String? prev, }) { _checkInitialized(); return _client.queryPollVotes( pollId, filter: filter, sort: sort, - pagination: pagination, + limit: limit, + next: next, + prev: prev, ); } diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 879326c657..20a2405046 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -66,6 +66,11 @@ import '../core/models/response/get_user_group_response.dart'; import '../core/models/response/list_devices_response.dart'; import '../core/models/response/list_user_groups_response.dart'; import '../core/models/response/og_attachment_response.dart'; +import '../core/models/response/poll_option_response.dart'; +import '../core/models/response/poll_response.dart'; +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/response/remove_user_group_members_response.dart'; import '../core/models/response/search_roles_response.dart'; import '../core/models/response/search_user_groups_response.dart'; @@ -83,6 +88,7 @@ import '../repository/app_settings_repository.dart'; import '../repository/devices_repository.dart'; import '../repository/general_repository.dart'; import '../repository/moderation_repository.dart'; +import '../repository/polls_repository.dart'; import '../repository/roles_repository.dart'; import '../repository/user_groups_repository.dart'; import '../ws/connect_request.dart'; @@ -186,6 +192,7 @@ class StreamChatClient { _rolesRepository = RolesRepository(api); _devicesRepository = DevicesRepository(api); _userGroupsRepository = UserGroupsRepository(api); + _pollsRepository = PollsRepository(api); _generalRepository = GeneralRepository(api); _moderationRepository = ModerationRepository(api); _appSettingsManager = AppSettingsManager(AppSettingsRepository(api)); @@ -228,6 +235,7 @@ class StreamChatClient { late final RolesRepository _rolesRepository; late final DevicesRepository _devicesRepository; late final UserGroupsRepository _userGroupsRepository; + late final PollsRepository _pollsRepository; late final GeneralRepository _generalRepository; late final ModerationRepository _moderationRepository; late final AppSettingsManager _appSettingsManager; @@ -1625,115 +1633,114 @@ class StreamChatClient { threadId, ); - /// Creates a new Poll - Future createPoll(Poll poll) => _chatApi.polls.createPoll(poll); + /// Creates [poll] with its settings and options. + /// + /// The ids of the options are left out: every option gets a new one, which the returned poll carries. A poll is + /// shown to the members of a channel once it is sent in a message; [Channel.sendPoll] does both. + Future> createPoll(Poll poll) => _pollsRepository.createPoll(poll); - /// Retrieves a Poll by [pollId] - Future getPoll(String pollId) => _chatApi.polls.getPoll(pollId); + /// Fetches the poll [pollId]. + Future> getPoll(String pollId) => _pollsRepository.getPoll(pollId); - /// Updates a Poll - Future updatePoll(Poll poll) => _chatApi.polls.updatePoll(poll); + /// Replaces the settings and options of [poll] with the ones it carries. + /// + /// An option left out of [poll] is removed from it. Every option must already exist and carry its id; if one does + /// not, the call returns a failure without updating the poll. New options are added with [createPollOption]. + Future> updatePoll(Poll poll) => _pollsRepository.updatePoll(poll); - /// Partially updates a Poll by [pollId]. + /// Changes some of the settings of the poll [pollId], leaving the others as they are. /// - /// Use [set] to define values to be set. - /// Use [unset] to define values to be unset. - Future partialUpdatePoll( + /// The fields in [set] take their new values and the fields named in [unset] are cleared. A key that is not a + /// setting of the poll is stored in its custom data. + Future> partialUpdatePoll( String pollId, { Map? set, List? unset, - }) => _chatApi.polls.partialUpdatePoll( - pollId, - set: set, - unset: unset, - ); + }) => _pollsRepository.partialUpdatePoll(pollId, set: set, unset: unset); - /// Deletes the Poll by [pollId]. - Future deletePoll(String pollId) => _chatApi.polls.deletePoll(pollId); + /// Deletes the poll [pollId]. + Future> deletePoll(String pollId) => _pollsRepository.deletePoll(pollId); - /// Marks the Poll [pollId] as closed. - Future closePoll(String pollId) => partialUpdatePoll( - pollId, - set: { - 'is_closed': true, - }, - ); + /// Closes the poll [pollId], so it accepts no more votes or answers. + Future> closePoll(String pollId) => partialUpdatePoll(pollId, set: {'is_closed': true}); - /// Creates a new Poll Option for the Poll [pollId]. - Future createPollOption( + /// Adds [option] to the poll [pollId]. + /// + /// The option gets a new id, which the returned option carries. + Future> createPollOption( String pollId, PollOption option, - ) => _chatApi.polls.createPollOption(pollId, option); + ) => _pollsRepository.createPollOption(pollId, option); - /// Retrieves a Poll Option by [optionId] for the Poll [pollId]. - Future getPollOption( + /// Fetches the option [optionId] of the poll [pollId]. + Future> getPollOption( String pollId, String optionId, - ) => _chatApi.polls.getPollOption(pollId, optionId); + ) => _pollsRepository.getPollOption(pollId, optionId); - /// Updates a Poll Option for the Poll [pollId]. - Future updatePollOption( + /// Replaces the text and custom data of [option], an existing option of the poll [pollId]. + /// + /// The [option] is found by its id; if it has none, the call returns a failure without updating anything. + Future> updatePollOption( String pollId, PollOption option, - ) => _chatApi.polls.updatePollOption(pollId, option); + ) => _pollsRepository.updatePollOption(pollId, option); - /// Deletes a Poll Option by [optionId] for the Poll [pollId]. - Future deletePollOption( + /// Removes the option [optionId] from the poll [pollId]. + Future> deletePollOption( String pollId, String optionId, - ) => _chatApi.polls.deletePollOption(pollId, optionId); + ) => _pollsRepository.deletePollOption(pollId, optionId); - /// Cast a [vote] for the Poll [pollId]. - Future castPollVote( + /// Votes for the option [optionId] of the poll [pollId], which was sent in the message [messageId]. + /// + /// If the poll allows only one vote per user, the vote replaces the previous one. + Future> castPollVote( String messageId, String pollId, { required String optionId, - }) { - final vote = PollVote(optionId: optionId); - return _chatApi.polls.castPollVote(messageId, pollId, vote); - } + }) => _pollsRepository.castPollVote(messageId, pollId, optionId: optionId); - /// Adds a answer with [answerText] for the Poll [pollId]. - Future addPollAnswer( + /// Leaves [answerText] as an answer on the poll [pollId], which was sent in the message [messageId]. + /// + /// Each user has one answer per poll; a new one replaces the previous one. + Future> addPollAnswer( String messageId, String pollId, { required String answerText, - }) { - final vote = PollVote(answerText: answerText); - return _chatApi.polls.castPollVote(messageId, pollId, vote); - } + }) => _pollsRepository.addPollAnswer(messageId, pollId, answerText: answerText); - /// Removes a vote by [voteId] for the Poll [pollId]. - Future removePollVote( + /// Removes the vote or answer [voteId] from the poll [pollId], which was sent in the message [messageId]. + Future> removePollVote( String messageId, String pollId, String voteId, - ) => _chatApi.polls.removePollVote(messageId, pollId, voteId); + ) => _pollsRepository.removePollVote(messageId, pollId, voteId); - /// Queries Polls with the given [filter] and [sort] options. - Future queryPolls({ + /// Fetches one page of the polls matching [filter], ordered by [sort]. + /// + /// Up to [limit] polls are returned. The next page is fetched by passing the `next` cursor of a response as [next], + /// and the previous page by passing its `prev` cursor as [prev]; pass at most one of the two. + Future> queryPolls({ PollFilter? filter, List? sort, - PaginationParams pagination = const PaginationParams(), - }) => _chatApi.polls.queryPolls( - filter: filter, - sort: sort, - pagination: pagination, - ); + int limit = 10, + String? next, + String? prev, + }) => _pollsRepository.queryPolls(filter: filter, sort: sort, limit: limit, next: next, prev: prev); - /// Queries Poll Votes for the Poll [pollId] with the given [filter] - /// and [sort] options. - Future queryPollVotes( + /// Fetches one page of the votes and answers of the poll [pollId] matching [filter], ordered by [sort]. + /// + /// Up to [limit] votes are returned. The next page is fetched by passing the `next` cursor of a response as [next], + /// and the previous page by passing its `prev` cursor as [prev]; pass at most one of the two. + Future> queryPollVotes( String pollId, { PollVoteFilter? filter, List? sort, - PaginationParams pagination = const PaginationParams(), - }) => _chatApi.polls.queryPollVotes( - pollId, - filter: filter, - sort: sort, - pagination: pagination, - ); + int limit = 10, + String? next, + 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]); diff --git a/packages/stream_chat/lib/src/core/api/polls_api.dart b/packages/stream_chat/lib/src/core/api/polls_api.dart deleted file mode 100644 index edb6059d6f..0000000000 --- a/packages/stream_chat/lib/src/core/api/polls_api.dart +++ /dev/null @@ -1,197 +0,0 @@ -import 'dart:convert'; - -import '../http/stream_http_client.dart'; -import '../models/poll.dart'; -import '../models/poll_option.dart'; -import '../models/poll_vote.dart'; -import 'requests.dart'; -import 'responses.dart'; - -/// Defines the api dedicated to polls operations -class PollsApi { - /// Initialize a new polls api - const PollsApi(this._client); - - final StreamHttpClient _client; - - /// Creates a new poll with the given [poll] data. - Future createPoll( - Poll poll, - ) async { - final response = await _client.post( - '/polls', - data: jsonEncode(poll), - ); - - return CreatePollResponse.fromJson(response.data); - } - - /// Retrieves a poll by its [id]. - Future getPoll( - String id, - ) async { - final response = await _client.get( - '/polls/$id', - ); - - return GetPollResponse.fromJson(response.data); - } - - /// Updates the poll with the given [poll] data. - Future updatePoll( - Poll poll, - ) async { - final response = await _client.put( - '/polls', - data: jsonEncode(poll), - ); - - return UpdatePollResponse.fromJson(response.data); - } - - /// Partially updates the poll with the given [id]. - /// - /// The [set] and [unset] parameters are used to update the poll. - Future partialUpdatePoll( - String id, { - Map? set, - List? unset, - }) async { - final response = await _client.patch( - '/polls/$id', - data: jsonEncode({ - if (set != null) 'set': set, - if (unset != null) 'unset': unset, - }), - ); - - return UpdatePollResponse.fromJson(response.data); - } - - /// Deletes the poll with the given [id]. - Future deletePoll(String id) async { - final response = await _client.delete( - '/polls/$id', - ); - - return EmptyResponse.fromJson(response.data); - } - - /// Creates a new poll option with the given [option] data. - Future createPollOption( - String pollId, - PollOption option, - ) async { - final response = await _client.post( - '/polls/$pollId/options', - data: jsonEncode(option), - ); - - return CreatePollOptionResponse.fromJson(response.data); - } - - /// Retrieves a poll option by its [optionId]. - Future getPollOption( - String pollId, - String optionId, - ) async { - final response = await _client.get( - '/polls/$pollId/options/$optionId', - ); - - return GetPollOptionResponse.fromJson(response.data); - } - - /// Updates the poll option with the given [option] data. - Future updatePollOption( - String pollId, - PollOption option, - ) async { - final response = await _client.put( - '/polls/$pollId/options', - data: jsonEncode(option), - ); - - return UpdatePollOptionResponse.fromJson(response.data); - } - - /// Removes the poll option with the given [optionId]. - Future deletePollOption( - String pollId, - String optionId, - ) async { - final response = await _client.delete( - '/polls/$pollId/options/$optionId', - ); - - return EmptyResponse.fromJson(response.data); - } - - /// Casts a vote on the poll with the given [pollId] and [vote] data. - Future castPollVote( - String messageId, - String pollId, - PollVote vote, - ) async { - final response = await _client.post( - '/messages/$messageId/polls/$pollId/vote', - data: jsonEncode({ - 'vote': vote, - }), - ); - - return CastPollVoteResponse.fromJson(response.data); - } - - /// Removes the vote with the given [voteId] from the poll with the - /// given [pollId]. - Future removePollVote( - String messageId, - String pollId, - String voteId, - ) async { - final response = await _client.delete( - '/messages/$messageId/polls/$pollId/vote/$voteId', - ); - - return RemovePollVoteResponse.fromJson(response.data); - } - - /// Queries polls with the given [filter], [sort], and [pagination] - /// parameters. - Future queryPolls({ - PollFilter? filter, - List? sort, - PaginationParams pagination = const PaginationParams(), - }) async { - final response = await _client.post( - '/polls/query', - data: jsonEncode({ - if (filter != null) 'filter': filter.toJson(), - if (sort != null) 'sort': sort.map((e) => e.toJson()).toList(), - ...pagination.toJson(), - }), - ); - - return QueryPollsResponse.fromJson(response.data); - } - - /// Queries poll votes with the given [pollId], [filter], [sort], and - Future queryPollVotes( - String pollId, { - PollVoteFilter? filter, - List? sort, - PaginationParams pagination = const PaginationParams(), - }) async { - final response = await _client.post( - '/polls/$pollId/votes', - data: jsonEncode({ - if (filter != null) 'filter': filter.toJson(), - if (sort != null) 'sort': sort.map((e) => e.toJson()).toList(), - ...pagination.toJson(), - }), - ); - - return QueryPollVotesResponse.fromJson(response.data); - } -} diff --git a/packages/stream_chat/lib/src/core/api/responses.dart b/packages/stream_chat/lib/src/core/api/responses.dart index 2112329cab..673f79c569 100644 --- a/packages/stream_chat/lib/src/core/api/responses.dart +++ b/packages/stream_chat/lib/src/core/api/responses.dart @@ -9,9 +9,6 @@ import '../models/location.dart'; import '../models/member.dart'; import '../models/message.dart'; import '../models/message_reminder.dart'; -import '../models/poll.dart'; -import '../models/poll_option.dart'; -import '../models/poll_vote.dart'; import '../models/predefined_filter.dart'; import '../models/push_preference.dart'; import '../models/reaction.dart'; @@ -467,114 +464,6 @@ class BlockedUsersResponse extends _BaseResponse { static BlockedUsersResponse fromJson(Map json) => _$BlockedUsersResponseFromJson(json); } -/// Model response for [StreamChatClient.createPoll] api call -@JsonSerializable(createToJson: false) -class CreatePollResponse extends _BaseResponse { - /// Created poll - late Poll poll; - - /// Create a new instance from a json - static CreatePollResponse fromJson(Map json) => _$CreatePollResponseFromJson(json); -} - -/// Model response for [StreamChatClient.getPoll] api call -@JsonSerializable(createToJson: false) -class GetPollResponse extends _BaseResponse { - /// Fetched poll - late Poll poll; - - /// Create a new instance from a json - static GetPollResponse fromJson(Map json) => _$GetPollResponseFromJson(json); -} - -/// Model response for [StreamChatClient.updatePoll] api call -@JsonSerializable(createToJson: false) -class UpdatePollResponse extends _BaseResponse { - /// Updated poll - late Poll poll; - - /// Create a new instance from a json - static UpdatePollResponse fromJson(Map json) => _$UpdatePollResponseFromJson(json); -} - -/// Model response for [StreamChatClient.createPollOption] api call -@JsonSerializable(createToJson: false) -class CreatePollOptionResponse extends _BaseResponse { - /// Created poll option - late PollOption pollOption; - - /// Create a new instance from a json - static CreatePollOptionResponse fromJson(Map json) => _$CreatePollOptionResponseFromJson(json); -} - -/// Model response for [StreamChatClient.getPollOption] api call -@JsonSerializable(createToJson: false) -class GetPollOptionResponse extends _BaseResponse { - /// Fetched poll option - late PollOption pollOption; - - /// Create a new instance from a json - static GetPollOptionResponse fromJson(Map json) => _$GetPollOptionResponseFromJson(json); -} - -/// Model response for [StreamChatClient.updatePollOption] api call -@JsonSerializable(createToJson: false) -class UpdatePollOptionResponse extends _BaseResponse { - /// Updated poll option - late PollOption pollOption; - - /// Create a new instance from a json - static UpdatePollOptionResponse fromJson(Map json) => _$UpdatePollOptionResponseFromJson(json); -} - -/// Model response for [StreamChatClient.castPollVote] api call -@JsonSerializable(createToJson: false) -class CastPollVoteResponse extends _BaseResponse { - /// Casted vote - late PollVote vote; - - /// Create a new instance from a json - static CastPollVoteResponse fromJson(Map json) => _$CastPollVoteResponseFromJson(json); -} - -/// Model response for [StreamChatClient.removePollVote] api call -@JsonSerializable(createToJson: false) -class RemovePollVoteResponse extends EmptyResponse { - /// Deleted vote - late PollVote vote; - - /// Create a new instance from a json - static RemovePollVoteResponse fromJson(Map json) => _$RemovePollVoteResponseFromJson(json); -} - -/// Model response for [StreamChatClient.queryPolls] api call -@JsonSerializable(createToJson: false) -class QueryPollsResponse extends _BaseResponse { - /// List of polls returned by the query - @JsonKey(defaultValue: []) - late List polls; - - /// Poll id of where to start searching from for next [results] - late String? next; - - /// Create a new instance from a json - static QueryPollsResponse fromJson(Map json) => _$QueryPollsResponseFromJson(json); -} - -/// Model response for [StreamChatClient.queryPollVotes] api call -@JsonSerializable(createToJson: false) -class QueryPollVotesResponse extends _BaseResponse { - /// List of poll votes returned by the query - @JsonKey(defaultValue: []) - late List votes; - - /// Poll vote id of where to start searching from for next [results] - late String? next; - - /// Create a new instance from a json - static QueryPollVotesResponse fromJson(Map json) => _$QueryPollVotesResponseFromJson(json); -} - /// Model response for [StreamChatClient.getThread] api call @JsonSerializable(createToJson: false) class GetThreadResponse extends _BaseResponse { diff --git a/packages/stream_chat/lib/src/core/api/responses.g.dart b/packages/stream_chat/lib/src/core/api/responses.g.dart index 9daf6f4192..77367f24a1 100644 --- a/packages/stream_chat/lib/src/core/api/responses.g.dart +++ b/packages/stream_chat/lib/src/core/api/responses.g.dart @@ -25,67 +25,48 @@ SyncResponse _$SyncResponseFromJson(Map json) => SyncResponse() ..duration = json['duration'] as String? ..events = (json['events'] as List?)?.map((e) => Event.fromJson(e as Map)).toList() ?? []; -QueryChannelsResponse _$QueryChannelsResponseFromJson( - Map json, -) => QueryChannelsResponse() +QueryChannelsResponse _$QueryChannelsResponseFromJson(Map json) => QueryChannelsResponse() ..duration = json['duration'] as String? ..channels = (json['channels'] as List?)?.map((e) => ChannelState.fromJson(e as Map)).toList() ?? [] ..predefinedFilter = json['predefined_filter'] == null ? null - : PredefinedFilter.fromJson( - json['predefined_filter'] as Map, - ); + : PredefinedFilter.fromJson(json['predefined_filter'] as Map); -TranslateMessageResponse _$TranslateMessageResponseFromJson( - Map json, -) => TranslateMessageResponse() +TranslateMessageResponse _$TranslateMessageResponseFromJson(Map json) => TranslateMessageResponse() ..duration = json['duration'] as String? ..message = Message.fromJson(json['message'] as Map); -QueryMembersResponse _$QueryMembersResponseFromJson( - Map json, -) => QueryMembersResponse() +QueryMembersResponse _$QueryMembersResponseFromJson(Map json) => QueryMembersResponse() ..duration = json['duration'] as String? ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList() ?? []; -PartialUpdateMemberResponse _$PartialUpdateMemberResponseFromJson( - Map json, -) => PartialUpdateMemberResponse() - ..duration = json['duration'] as String? - ..channelMember = Member.fromJson( - json['channel_member'] as Map, - ); +PartialUpdateMemberResponse _$PartialUpdateMemberResponseFromJson(Map json) => + PartialUpdateMemberResponse() + ..duration = json['duration'] as String? + ..channelMember = Member.fromJson(json['channel_member'] as Map); QueryUsersResponse _$QueryUsersResponseFromJson(Map json) => QueryUsersResponse() ..duration = json['duration'] as String? ..users = (json['users'] as List?)?.map((e) => User.fromJson(e as Map)).toList() ?? []; -QueryBannedUsersResponse _$QueryBannedUsersResponseFromJson( - Map json, -) => QueryBannedUsersResponse() +QueryBannedUsersResponse _$QueryBannedUsersResponseFromJson(Map json) => QueryBannedUsersResponse() ..duration = json['duration'] as String? ..bans = (json['bans'] as List?)?.map((e) => BannedUser.fromJson(e as Map)).toList() ?? []; -QueryReactionsResponse _$QueryReactionsResponseFromJson( - Map json, -) => QueryReactionsResponse() +QueryReactionsResponse _$QueryReactionsResponseFromJson(Map json) => QueryReactionsResponse() ..duration = json['duration'] as String? ..reactions = (json['reactions'] as List?)?.map((e) => Reaction.fromJson(e as Map)).toList() ?? [] ..next = json['next'] as String?; -QueryRepliesResponse _$QueryRepliesResponseFromJson( - Map json, -) => QueryRepliesResponse() +QueryRepliesResponse _$QueryRepliesResponseFromJson(Map json) => QueryRepliesResponse() ..duration = json['duration'] as String? ..messages = (json['messages'] as List?)?.map((e) => Message.fromJson(e as Map)).toList() ?? []; -SendAttachmentResponse _$SendAttachmentResponseFromJson( - Map json, -) => SendAttachmentResponse() +SendAttachmentResponse _$SendAttachmentResponseFromJson(Map json) => SendAttachmentResponse() ..duration = json['duration'] as String? ..file = json['file'] as String?; @@ -94,9 +75,7 @@ SendFileResponse _$SendFileResponseFromJson(Map json) => SendFi ..file = json['file'] as String? ..thumbUrl = json['thumb_url'] as String?; -SendReactionResponse _$SendReactionResponseFromJson( - Map json, -) => SendReactionResponse() +SendReactionResponse _$SendReactionResponseFromJson(Map json) => SendReactionResponse() ..duration = json['duration'] as String? ..message = Message.fromJson(json['message'] as Map) ..reaction = Reaction.fromJson(json['reaction'] as Map); @@ -104,14 +83,10 @@ SendReactionResponse _$SendReactionResponseFromJson( 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)), - ) ?? + (json['users'] as Map?)?.map((k, e) => MapEntry(k, User.fromJson(e as Map))) ?? {}; -UpdateMessageResponse _$UpdateMessageResponseFromJson( - Map json, -) => UpdateMessageResponse() +UpdateMessageResponse _$UpdateMessageResponseFromJson(Map json) => UpdateMessageResponse() ..duration = json['duration'] as String? ..message = Message.fromJson(json['message'] as Map); @@ -124,9 +99,7 @@ GetMessageResponse _$GetMessageResponseFromJson(Map json) => Ge ..message = Message.fromJson(json['message'] as Map) ..channel = json['channel'] == null ? null : ChannelModel.fromJson(json['channel'] as Map); -SearchMessagesResponse _$SearchMessagesResponseFromJson( - Map json, -) => SearchMessagesResponse() +SearchMessagesResponse _$SearchMessagesResponseFromJson(Map json) => SearchMessagesResponse() ..duration = json['duration'] as String? ..results = (json['results'] as List?) @@ -136,39 +109,30 @@ SearchMessagesResponse _$SearchMessagesResponseFromJson( ..next = json['next'] as String? ..previous = json['previous'] as String?; -GetMessagesByIdResponse _$GetMessagesByIdResponseFromJson( - Map json, -) => GetMessagesByIdResponse() +GetMessagesByIdResponse _$GetMessagesByIdResponseFromJson(Map json) => GetMessagesByIdResponse() ..duration = json['duration'] as String? ..messages = (json['messages'] as List?)?.map((e) => Message.fromJson(e as Map)).toList() ?? []; -UpdateChannelResponse _$UpdateChannelResponseFromJson( - Map json, -) => UpdateChannelResponse() +UpdateChannelResponse _$UpdateChannelResponseFromJson(Map json) => UpdateChannelResponse() ..duration = json['duration'] as String? ..channel = ChannelModel.fromJson(json['channel'] as Map) ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList() ..message = json['message'] == null ? null : Message.fromJson(json['message'] as Map); -PartialUpdateChannelResponse _$PartialUpdateChannelResponseFromJson( - Map json, -) => PartialUpdateChannelResponse() - ..duration = json['duration'] as String? - ..channel = ChannelModel.fromJson(json['channel'] as Map) - ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList(); +PartialUpdateChannelResponse _$PartialUpdateChannelResponseFromJson(Map json) => + PartialUpdateChannelResponse() + ..duration = json['duration'] as String? + ..channel = ChannelModel.fromJson(json['channel'] as Map) + ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList(); -InviteMembersResponse _$InviteMembersResponseFromJson( - Map json, -) => InviteMembersResponse() +InviteMembersResponse _$InviteMembersResponseFromJson(Map json) => InviteMembersResponse() ..duration = json['duration'] as String? ..channel = ChannelModel.fromJson(json['channel'] as Map) ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList() ?? [] ..message = json['message'] == null ? null : Message.fromJson(json['message'] as Map); -RemoveMembersResponse _$RemoveMembersResponseFromJson( - Map json, -) => RemoveMembersResponse() +RemoveMembersResponse _$RemoveMembersResponseFromJson(Map json) => RemoveMembersResponse() ..duration = json['duration'] as String? ..channel = ChannelModel.fromJson(json['channel'] as Map) ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList() ?? [] @@ -184,17 +148,13 @@ AddMembersResponse _$AddMembersResponseFromJson(Map json) => Ad ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList() ?? [] ..message = json['message'] == null ? null : Message.fromJson(json['message'] as Map); -AcceptInviteResponse _$AcceptInviteResponseFromJson( - Map json, -) => AcceptInviteResponse() +AcceptInviteResponse _$AcceptInviteResponseFromJson(Map json) => AcceptInviteResponse() ..duration = json['duration'] as String? ..channel = ChannelModel.fromJson(json['channel'] as Map) ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList() ?? [] ..message = json['message'] == null ? null : Message.fromJson(json['message'] as Map); -RejectInviteResponse _$RejectInviteResponseFromJson( - Map json, -) => RejectInviteResponse() +RejectInviteResponse _$RejectInviteResponseFromJson(Map json) => RejectInviteResponse() ..duration = json['duration'] as String? ..channel = ChannelModel.fromJson(json['channel'] as Map) ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList() ?? [] @@ -203,9 +163,7 @@ RejectInviteResponse _$RejectInviteResponseFromJson( EmptyResponse _$EmptyResponseFromJson(Map json) => EmptyResponse()..duration = json['duration'] as String?; -ChannelStateResponse _$ChannelStateResponseFromJson( - Map json, -) => ChannelStateResponse() +ChannelStateResponse _$ChannelStateResponseFromJson(Map json) => ChannelStateResponse() ..duration = json['duration'] as String? ..channel = ChannelModel.fromJson(json['channel'] as Map) ..messages = @@ -220,86 +178,20 @@ UserBlockResponse _$UserBlockResponseFromJson(Map json) => User ..blockedUserId = json['blocked_user_id'] as String? ?? '' ..createdAt = DateTime.parse(json['created_at'] as String); -BlockedUsersResponse _$BlockedUsersResponseFromJson( - Map json, -) => BlockedUsersResponse() +BlockedUsersResponse _$BlockedUsersResponseFromJson(Map json) => BlockedUsersResponse() ..duration = json['duration'] as String? ..blocks = (json['blocks'] as List?)?.map((e) => UserBlock.fromJson(e as Map)).toList() ?? []; -CreatePollResponse _$CreatePollResponseFromJson(Map json) => CreatePollResponse() - ..duration = json['duration'] as String? - ..poll = Poll.fromJson(json['poll'] as Map); - -GetPollResponse _$GetPollResponseFromJson(Map json) => GetPollResponse() - ..duration = json['duration'] as String? - ..poll = Poll.fromJson(json['poll'] as Map); - -UpdatePollResponse _$UpdatePollResponseFromJson(Map json) => UpdatePollResponse() - ..duration = json['duration'] as String? - ..poll = Poll.fromJson(json['poll'] as Map); - -CreatePollOptionResponse _$CreatePollOptionResponseFromJson( - Map json, -) => CreatePollOptionResponse() - ..duration = json['duration'] as String? - ..pollOption = PollOption.fromJson( - json['poll_option'] as Map, - ); - -GetPollOptionResponse _$GetPollOptionResponseFromJson( - Map json, -) => GetPollOptionResponse() - ..duration = json['duration'] as String? - ..pollOption = PollOption.fromJson( - json['poll_option'] as Map, - ); - -UpdatePollOptionResponse _$UpdatePollOptionResponseFromJson( - Map json, -) => UpdatePollOptionResponse() - ..duration = json['duration'] as String? - ..pollOption = PollOption.fromJson( - json['poll_option'] as Map, - ); - -CastPollVoteResponse _$CastPollVoteResponseFromJson( - Map json, -) => CastPollVoteResponse() - ..duration = json['duration'] as String? - ..vote = PollVote.fromJson(json['vote'] as Map); - -RemovePollVoteResponse _$RemovePollVoteResponseFromJson( - Map json, -) => RemovePollVoteResponse() - ..duration = json['duration'] as String? - ..vote = PollVote.fromJson(json['vote'] as Map); - -QueryPollsResponse _$QueryPollsResponseFromJson(Map json) => QueryPollsResponse() - ..duration = json['duration'] as String? - ..polls = (json['polls'] as List?)?.map((e) => Poll.fromJson(e as Map)).toList() ?? [] - ..next = json['next'] as String?; - -QueryPollVotesResponse _$QueryPollVotesResponseFromJson( - Map json, -) => QueryPollVotesResponse() - ..duration = json['duration'] as String? - ..votes = (json['votes'] as List?)?.map((e) => PollVote.fromJson(e as Map)).toList() ?? [] - ..next = json['next'] as String?; - GetThreadResponse _$GetThreadResponseFromJson(Map json) => GetThreadResponse() ..duration = json['duration'] as String? ..thread = Thread.fromJson(json['thread'] as Map); -UpdateThreadResponse _$UpdateThreadResponseFromJson( - Map json, -) => UpdateThreadResponse() +UpdateThreadResponse _$UpdateThreadResponseFromJson(Map json) => UpdateThreadResponse() ..duration = json['duration'] as String? ..thread = Thread.fromJson(json['thread'] as Map); -QueryThreadsResponse _$QueryThreadsResponseFromJson( - Map json, -) => QueryThreadsResponse() +QueryThreadsResponse _$QueryThreadsResponseFromJson(Map json) => QueryThreadsResponse() ..duration = json['duration'] as String? ..threads = (json['threads'] as List?)?.map((e) => Thread.fromJson(e as Map)).toList() ?? [] ..next = json['next'] as String?; @@ -317,34 +209,22 @@ QueryDraftsResponse _$QueryDraftsResponseFromJson(Map json) => ..drafts = (json['drafts'] as List?)?.map((e) => Draft.fromJson(e as Map)).toList() ?? [] ..next = json['next'] as String?; -CreateReminderResponse _$CreateReminderResponseFromJson( - Map json, -) => CreateReminderResponse() +CreateReminderResponse _$CreateReminderResponseFromJson(Map json) => CreateReminderResponse() ..duration = json['duration'] as String? - ..reminder = MessageReminder.fromJson( - json['reminder'] as Map, - ); + ..reminder = MessageReminder.fromJson(json['reminder'] as Map); -UpdateReminderResponse _$UpdateReminderResponseFromJson( - Map json, -) => UpdateReminderResponse() +UpdateReminderResponse _$UpdateReminderResponseFromJson(Map json) => UpdateReminderResponse() ..duration = json['duration'] as String? - ..reminder = MessageReminder.fromJson( - json['reminder'] as Map, - ); + ..reminder = MessageReminder.fromJson(json['reminder'] as Map); -QueryRemindersResponse _$QueryRemindersResponseFromJson( - Map json, -) => QueryRemindersResponse() +QueryRemindersResponse _$QueryRemindersResponseFromJson(Map json) => QueryRemindersResponse() ..duration = json['duration'] as String? ..reminders = (json['reminders'] as List?)?.map((e) => MessageReminder.fromJson(e as Map)).toList() ?? [] ..next = json['next'] as String?; -GetUnreadCountResponse _$GetUnreadCountResponseFromJson( - Map json, -) => GetUnreadCountResponse() +GetUnreadCountResponse _$GetUnreadCountResponseFromJson(Map json) => GetUnreadCountResponse() ..duration = json['duration'] as String? ..totalUnreadCount = (json['total_unread_count'] as num).toInt() ..totalUnreadThreadsCount = (json['total_unread_threads_count'] as num).toInt() @@ -361,31 +241,24 @@ GetUnreadCountResponse _$GetUnreadCountResponseFromJson( .map((e) => UnreadCountsThread.fromJson(e as Map)) .toList(); -UpsertPushPreferencesResponse _$UpsertPushPreferencesResponseFromJson( - Map json, -) => UpsertPushPreferencesResponse() - ..duration = json['duration'] as String? - ..userPreferences = _userPreferencesFromJson( - json['user_preferences'] as Map?, - ) - ..userChannelPreferences = - (json['user_channel_preferences'] as Map?)?.map( - (k, e) => MapEntry( - k, - (e as Map).map( +UpsertPushPreferencesResponse _$UpsertPushPreferencesResponseFromJson(Map json) => + UpsertPushPreferencesResponse() + ..duration = json['duration'] as String? + ..userPreferences = _userPreferencesFromJson(json['user_preferences'] as Map?) + ..userChannelPreferences = + (json['user_channel_preferences'] as Map?)?.map( (k, e) => MapEntry( k, - ChannelPushPreference.fromJson(e as Map), + (e as Map).map( + (k, e) => MapEntry(k, ChannelPushPreference.fromJson(e as Map)), + ), ), - ), - ), - ) ?? - {}; - -GetActiveLiveLocationsResponse _$GetActiveLiveLocationsResponseFromJson( - Map json, -) => GetActiveLiveLocationsResponse() - ..duration = json['duration'] as String? - ..activeLiveLocations = (json['active_live_locations'] as List) - .map((e) => Location.fromJson(e as Map)) - .toList(); + ) ?? + {}; + +GetActiveLiveLocationsResponse _$GetActiveLiveLocationsResponseFromJson(Map json) => + GetActiveLiveLocationsResponse() + ..duration = json['duration'] as String? + ..activeLiveLocations = (json['active_live_locations'] as List) + .map((e) => Location.fromJson(e as Map)) + .toList(); diff --git a/packages/stream_chat/lib/src/core/api/stream_chat_api.dart b/packages/stream_chat/lib/src/core/api/stream_chat_api.dart index c241201b83..653974a131 100644 --- a/packages/stream_chat/lib/src/core/api/stream_chat_api.dart +++ b/packages/stream_chat/lib/src/core/api/stream_chat_api.dart @@ -7,7 +7,6 @@ import 'channel_api.dart'; import 'general_api.dart'; import 'message_api.dart'; import 'moderation_api.dart'; -import 'polls_api.dart'; import 'push_preferences_api.dart'; import 'reminders_api.dart'; import 'threads_api.dart'; @@ -50,10 +49,6 @@ class StreamChatApi { MessageApi get message => _message ??= MessageApi(_client); MessageApi? _message; - /// Api dedicated to polls operations - PollsApi get polls => _polls ??= PollsApi(_client); - PollsApi? _polls; - /// Api dedicated to threads operations ThreadsApi get threads => _threads ??= ThreadsApi(_client); ThreadsApi? _threads; diff --git a/packages/stream_chat/lib/src/core/models/converters/v1_json_converters.dart b/packages/stream_chat/lib/src/core/models/converters/v1_json_converters.dart index ea45f042f5..eec5e19d8e 100644 --- a/packages/stream_chat/lib/src/core/models/converters/v1_json_converters.dart +++ b/packages/stream_chat/lib/src/core/models/converters/v1_json_converters.dart @@ -2,10 +2,16 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:meta/meta.dart'; import 'package:stream_core/stream_core.dart' show StreamDateTimeConverter; +import '../../util/serializer.dart'; import '../device.dart'; +import '../poll.dart'; +import '../poll_option.dart'; +import '../poll_vote.dart'; import '../push_provider.dart'; +import '../user.dart'; import '../user_group.dart'; import '../user_group_member.dart'; +import '../voting_visibility.dart'; // Converters for plain models embedded in json_serializable models that still decode v1 JSON. Each one is deleted // by the migration group that turns its parent into a plain model. @@ -65,3 +71,123 @@ UserGroupMember _userGroupMemberFromV1Json(Map json) => UserGro isAdmin: json['is_admin'] as bool, userId: json['user_id'] as String, ); + +/// Converts a [Poll] to and from its v1 keys. +/// +/// Custom data sits at the root of the v1 map, beside the known keys. +// TODO(openapi-migration): remove once WebSocket events and messages decode v2 payloads +@internal +class PollV1JsonConverter implements JsonConverter> { + /// Creates a new [PollV1JsonConverter]. + const PollV1JsonConverter(); + + @override + Poll fromJson(Map json) => Poll( + id: json['id'] as String?, + name: json['name'] as String, + description: json['description'] as String?, + options: (json['options'] as List) + .map((option) => _pollOptionFromV1Json(option as Map)) + .toList(), + votingVisibility: switch (json['voting_visibility']) { + final String value => VotingVisibility(value), + _ => VotingVisibility.public, + }, + enforceUniqueVote: json['enforce_unique_vote'] as bool? ?? true, + maxVotesAllowed: (json['max_votes_allowed'] as num?)?.toInt(), + allowAnswers: json['allow_answers'] as bool? ?? false, + latestAnswers: _pollVotesFromV1Json(json['latest_answers']) ?? const [], + answersCount: (json['answers_count'] as num?)?.toInt() ?? 0, + allowUserSuggestedOptions: json['allow_user_suggested_options'] as bool? ?? false, + isClosed: json['is_closed'] as bool? ?? false, + createdAt: _optionalDateTime(json['created_at']), + updatedAt: _optionalDateTime(json['updated_at']), + voteCountsByOption: + (json['vote_counts_by_option'] as Map?)?.map( + (optionId, count) => MapEntry(optionId, (count as num).toInt()), + ) ?? + const {}, + voteCount: (json['vote_count'] as num?)?.toInt() ?? 0, + latestVotesByOption: + (json['latest_votes_by_option'] as Map?)?.map( + (optionId, votes) => MapEntry(optionId, _pollVotesFromV1Json(votes)!), + ) ?? + const {}, + createdById: json['created_by_id'] as String?, + createdBy: switch (json['created_by']) { + final Map user => User.fromJson(user), + _ => null, + }, + ownVotesAndAnswers: _pollVotesFromV1Json(json['own_votes']) ?? const [], + extraData: _customData(json, Poll.topLevelFields), + ); + + // Writes the keys the poll settings use, with custom data at the root; the vote summary is not written. + @override + Map toJson(Poll poll) => { + ...poll.extraData, + 'id': poll.id, + 'name': poll.name, + 'description': poll.description, + 'options': poll.options.map(_pollOptionToV1Json).toList(), + 'voting_visibility': poll.votingVisibility.value, + 'enforce_unique_vote': poll.enforceUniqueVote, + 'max_votes_allowed': poll.maxVotesAllowed, + 'allow_user_suggested_options': poll.allowUserSuggestedOptions, + 'allow_answers': poll.allowAnswers, + 'is_closed': poll.isClosed, + }; +} + +/// Converts a [PollVote] to and from its v1 keys. +// TODO(openapi-migration): remove once WebSocket events decode v2 payloads +@internal +class PollVoteV1JsonConverter implements JsonConverter> { + /// Creates a new [PollVoteV1JsonConverter]. + const PollVoteV1JsonConverter(); + + @override + PollVote fromJson(Map json) => _pollVoteFromV1Json(json); + + // Writes only the keys that identify the vote and what it selects. + @override + Map toJson(PollVote vote) => { + 'id': ?vote.id, + 'option_id': ?vote.optionId, + 'answer_text': ?vote.answerText, + }; +} + +PollOption _pollOptionFromV1Json(Map json) => PollOption( + id: json['id'] as String?, + text: json['text'] as String, + extraData: _customData(json, PollOption.topLevelFields), +); + +Map _pollOptionToV1Json(PollOption option) => { + ...option.extraData, + 'id': ?option.id, + 'text': option.text, +}; + +List? _pollVotesFromV1Json(Object? json) => + (json as List?)?.map((vote) => _pollVoteFromV1Json(vote as Map)).toList(); + +PollVote _pollVoteFromV1Json(Map json) => PollVote( + id: json['id'] as String?, + pollId: json['poll_id'] as String?, + optionId: json['option_id'] as String?, + answerText: json['answer_text'] as String?, + createdAt: _optionalDateTime(json['created_at']), + updatedAt: _optionalDateTime(json['updated_at']), + userId: json['user_id'] as String?, + user: switch (json['user']) { + final Map user => User.fromJson(user), + _ => null, + }, +); + +DateTime? _optionalDateTime(Object? json) => json == null ? null : _dateTime.fromJson(json); + +Map _customData(Map json, List topLevelFields) => + Serializer.moveToExtraDataFromRoot(json, topLevelFields)['extra_data'] as Map; 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 000bb9f168..01f370abcc 100644 --- a/packages/stream_chat/lib/src/core/models/draft_message.dart +++ b/packages/stream_chat/lib/src/core/models/draft_message.dart @@ -4,6 +4,7 @@ import 'package:uuid/uuid.dart'; import '../util/serializer.dart'; import 'attachment.dart'; +import 'converters/v1_json_converters.dart'; import 'message.dart'; import 'poll.dart'; import 'user.dart'; @@ -84,6 +85,7 @@ class DraftMessage extends Equatable { /// The poll associated with the message. @JsonKey(includeToJson: false) + @PollV1JsonConverter() final Poll? poll; /// The ID of the poll, if a poll is associated with the message. diff --git a/packages/stream_chat/lib/src/core/models/draft_message.g.dart b/packages/stream_chat/lib/src/core/models/draft_message.g.dart index 511ec1822e..27e1e0a332 100644 --- a/packages/stream_chat/lib/src/core/models/draft_message.g.dart +++ b/packages/stream_chat/lib/src/core/models/draft_message.g.dart @@ -24,7 +24,7 @@ DraftMessage _$DraftMessageFromJson(Map json) => DraftMessage( quotedMessageId: json['quoted_message_id'] as String?, silent: json['silent'] as bool? ?? false, command: json['command'] as String?, - poll: json['poll'] == null ? null : Poll.fromJson(json['poll'] as Map), + poll: _$JsonConverterFromJson, Poll>(json['poll'], const PollV1JsonConverter().fromJson), pollId: json['poll_id'] as String?, extraData: json['extra_data'] as Map? ?? const {}, ); @@ -42,3 +42,6 @@ Map _$DraftMessageToJson(DraftMessage instance) => (Object? json, Value? Function(Json json) fromJson) => + json == null ? null : fromJson(json as Json); diff --git a/packages/stream_chat/lib/src/core/models/message.dart b/packages/stream_chat/lib/src/core/models/message.dart index c1d92247e9..0a21660809 100644 --- a/packages/stream_chat/lib/src/core/models/message.dart +++ b/packages/stream_chat/lib/src/core/models/message.dart @@ -301,6 +301,7 @@ class Message extends Equatable { /// The poll associated with this message. @JsonKey(includeToJson: false) + @PollV1JsonConverter() final Poll? poll; /// The ID of the [poll] associated with this message. diff --git a/packages/stream_chat/lib/src/core/models/message.g.dart b/packages/stream_chat/lib/src/core/models/message.g.dart index b5d5d2b59d..ad14f0dda3 100644 --- a/packages/stream_chat/lib/src/core/models/message.g.dart +++ b/packages/stream_chat/lib/src/core/models/message.g.dart @@ -55,18 +55,14 @@ Message _$MessageFromJson(Map json) => Message( pinnedAt: json['pinned_at'] == null ? null : DateTime.parse(json['pinned_at'] as String), pinExpires: json['pin_expires'] == null ? null : DateTime.parse(json['pin_expires'] as String), pinnedBy: json['pinned_by'] == null ? null : User.fromJson(json['pinned_by'] as Map), - poll: json['poll'] == null ? null : Poll.fromJson(json['poll'] as Map), + poll: _$JsonConverterFromJson, Poll>(json['poll'], const PollV1JsonConverter().fromJson), pollId: json['poll_id'] as String?, extraData: json['extra_data'] as Map? ?? const {}, - i18n: (json['i18n'] as Map?)?.map( - (k, e) => MapEntry(k, e as String), - ), + i18n: (json['i18n'] as Map?)?.map((k, e) => MapEntry(k, e as String)), restrictedVisibility: (json['restricted_visibility'] as List?)?.map((e) => e as String).toList(), moderation: Message._moderationReadValue(json, 'moderation') == null ? null - : Moderation.fromJson( - Message._moderationReadValue(json, 'moderation') as Map, - ), + : Moderation.fromJson(Message._moderationReadValue(json, 'moderation') as Map), draft: json['draft'] == null ? null : Draft.fromJson(json['draft'] as Map), reminder: json['reminder'] == null ? null : MessageReminder.fromJson(json['reminder'] as Map), channelRole: Message._channelRoleReadValue(json, 'channel_role') as String?, @@ -96,3 +92,6 @@ Map _$MessageToJson(Message instance) => { 'shared_location': ?instance.sharedLocation?.toJson(), 'extra_data': instance.extraData, }; + +Value? _$JsonConverterFromJson(Object? json, Value? Function(Json json) fromJson) => + json == null ? null : fromJson(json as Json); diff --git a/packages/stream_chat/lib/src/core/models/poll.dart b/packages/stream_chat/lib/src/core/models/poll.dart index 9a4365aa8e..91f405df96 100644 --- a/packages/stream_chat/lib/src/core/models/poll.dart +++ b/packages/stream_chat/lib/src/core/models/poll.dart @@ -1,42 +1,27 @@ import 'package:collection/collection.dart'; -import 'package:equatable/equatable.dart'; -import 'package:json_annotation/json_annotation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:stream_core/stream_core.dart' show Filter, FilterField, Standard, Sort, SortField, normalizeStringForSort; import 'package:uuid/uuid.dart'; -import '../util/serializer.dart'; import 'poll_option.dart'; import 'poll_vote.dart'; import 'user.dart'; +import 'voting_visibility.dart'; -part 'poll.g.dart'; +part 'poll.freezed.dart'; -class _NullConst { - const _NullConst(); -} - -const _nullConst = _NullConst(); - -/// {@template streamVotingVisibility} -/// Represents the visibility of the voting process. -/// {@endtemplate} -enum VotingVisibility { - /// The voting process is anonymous. - @JsonValue('anonymous') - anonymous, - - /// The voting process is public. - @JsonValue('public') - public, -} - -/// {@template streamPoll} -/// A model class representing a poll. -/// {@endtemplate} -@JsonSerializable() -class Poll extends Equatable { - /// {@macro streamPoll} +/// A question with a set of options that the members of a channel vote on. +/// +/// A poll is sent in a message. Besides its settings it carries a summary of +/// the votes so far: the counts per option, the latest votes and answers, and +/// the votes of the current user. +@freezed +class Poll with _$Poll { + /// Creates a new [Poll]. + /// + /// An [id] is generated when omitted, and [createdAt] and [updatedAt] + /// default to the current time. Poll({ String? id, required this.name, @@ -63,164 +48,117 @@ class Poll extends Equatable { createdAt = createdAt ?? DateTime.now(), updatedAt = updatedAt ?? DateTime.now(); - /// Create a new instance from a json - factory Poll.fromJson(Map json) => - _$PollFromJson(Serializer.moveToExtraDataFromRoot(json, topLevelFields)); - - /// The unique identifier of the poll. + /// The unique identifier of this poll. + @override final String id; - /// The name of the poll. + /// The question this poll asks. + @override final String name; - /// The description of the poll. + /// A longer explanation of the question. + @override final String? description; - /// The list of options available for the poll. + /// The options that can be voted for. + @override final List options; - /// Represents the visibility of the voting process. + /// Who can see which option each user voted for. /// /// Defaults to [VotingVisibility.public]. + @override final VotingVisibility votingVisibility; - /// If true, only unique votes are allowed. + /// Whether each user may vote for one option only. /// - /// Defaults to false. + /// Voting for another option replaces the previous vote. Defaults to true. + @override final bool enforceUniqueVote; - /// The maximum number of votes allowed per user. + /// The maximum number of options each user may vote for, or null for no + /// limit. + @override final int? maxVotesAllowed; - /// If true, users can suggest their own options. + /// Whether users may add their own options to this poll. /// /// Defaults to false. + @override final bool allowUserSuggestedOptions; - /// If true, users can provide their own answers/comments. + /// Whether users may leave a free-form answer. /// /// Defaults to false. + @override final bool allowAnswers; - /// Indicates if the poll is closed. + /// Whether this poll no longer accepts votes. + @override final bool isClosed; - /// The total number of answers received by the poll. - @JsonKey(includeToJson: false) + /// The total number of answers left on this poll. + @override final int answersCount; - /// Map of vote counts by option. - @JsonKey(includeToJson: false) + /// The number of votes each option received, keyed by option id. + @override final Map voteCountsByOption; - /// Map of latest votes by option. - @JsonKey(includeToJson: false) + /// The most recent votes for each option, keyed by option id. + /// + /// Empty for an anonymous poll. + @override final Map> latestVotesByOption; - /// List of votes received by the poll. + /// The most recent votes across all options. /// - /// Note: This does not include the answers provided by the users, - /// see [latestAnswers] for that. - late final latestVotes = [...latestVotesByOption.values.flattened]; + /// Answers are not included; see [latestAnswers] for those. + List get latestVotes => [...latestVotesByOption.values.flattened]; - /// List of latest answers received by the poll. - @JsonKey(includeToJson: false) + /// The most recent answers left on this poll. + @override final List latestAnswers; - /// List of votes casted by the current user. - /// - /// Contains both votes and answers. - @JsonKey(name: 'own_votes', includeToJson: false) + /// The votes and answers of the current user. + @override final List ownVotesAndAnswers; - /// The total number of votes received by the poll. - @JsonKey(includeToJson: false) + /// The total number of votes cast on this poll. + @override final int voteCount; - /// List of votes casted by the current user. + /// The votes of the current user. /// - /// Note: This does not include the answers provided by the user, - /// see [ownAnswers] for that. - late final ownVotes = [...ownVotesAndAnswers.where((it) => !it.isAnswer)]; + /// Answers are not included; see [ownAnswers] for those. + List get ownVotes => [...ownVotesAndAnswers.where((it) => !it.isAnswer)]; - /// List of answers provided by the current user. + /// The answers of the current user. /// - /// Note: This does not include the votes casted by the user, - /// see [ownVotes] for that. - late final ownAnswers = [...ownVotesAndAnswers.where((it) => it.isAnswer)]; + /// Votes are not included; see [ownVotes] for those. + List get ownAnswers => [...ownVotesAndAnswers.where((it) => it.isAnswer)]; - /// The id of the user who created the poll. - @JsonKey(includeToJson: false) + /// The unique identifier of the user who created this poll. + @override final String? createdById; - /// The user who created the poll. - @JsonKey(includeToJson: false) + /// The user who created this poll. + @override final User? createdBy; - /// The date when the poll was created. - @JsonKey(includeToJson: false) + /// The date this poll was created. + @override final DateTime createdAt; - /// The date when the poll was last updated. - @JsonKey(includeToJson: false) + /// The date this poll was last changed. + @override final DateTime updatedAt; - /// Map of custom poll extraData + /// Custom data attached to this poll. + @override final Map extraData; - /// Serialize to json - Map toJson() => Serializer.moveFromExtraDataToRoot(_$PollToJson(this)); - - /// Creates a copy of [Poll] with specified attributes overridden. - Poll copyWith({ - String? id, - String? name, - String? description, - List? options, - VotingVisibility? votingVisibility, - bool? enforceUniqueVote, - Object? maxVotesAllowed = _nullConst, - bool? allowUserSuggestedOptions, - bool? allowAnswers, - bool? isClosed, - Map? voteCountsByOption, - List? ownVotesAndAnswers, - int? voteCount, - int? answersCount, - Map>? latestVotesByOption, - List? latestAnswers, - String? createdById, - User? createdBy, - DateTime? createdAt, - DateTime? updatedAt, - Map? extraData, - }) => Poll( - id: id ?? this.id, - name: name ?? this.name, - description: description ?? this.description, - options: options ?? this.options, - votingVisibility: votingVisibility ?? this.votingVisibility, - enforceUniqueVote: enforceUniqueVote ?? this.enforceUniqueVote, - maxVotesAllowed: maxVotesAllowed == _nullConst ? this.maxVotesAllowed : maxVotesAllowed as int?, - allowUserSuggestedOptions: allowUserSuggestedOptions ?? this.allowUserSuggestedOptions, - allowAnswers: allowAnswers ?? this.allowAnswers, - isClosed: isClosed ?? this.isClosed, - voteCountsByOption: voteCountsByOption ?? this.voteCountsByOption, - ownVotesAndAnswers: ownVotesAndAnswers ?? this.ownVotesAndAnswers, - voteCount: voteCount ?? this.voteCount, - answersCount: answersCount ?? this.answersCount, - latestVotesByOption: latestVotesByOption ?? this.latestVotesByOption, - latestAnswers: latestAnswers ?? this.latestAnswers, - createdById: createdById ?? this.createdById, - createdBy: createdBy ?? this.createdBy, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - extraData: extraData ?? this.extraData, - ); - - /// Known top level fields. - /// - /// Useful for [Serializer] methods. + /// The keys a poll carries besides its custom data. static const topLevelFields = [ 'id', 'name', @@ -243,30 +181,8 @@ class Poll extends Equatable { 'latest_answers', 'created_by_id', 'created_by', - ]; - - @override - List get props => [ - id, - name, - description, - options, - votingVisibility, - enforceUniqueVote, - maxVotesAllowed, - allowUserSuggestedOptions, - allowAnswers, - isClosed, - voteCountsByOption, - ownVotesAndAnswers, - voteCount, - answersCount, - latestVotesByOption, - latestAnswers, - createdById, - createdBy, - createdAt, - updatedAt, + 'name_i18n', + 'description_i18n', ]; } @@ -354,7 +270,7 @@ class PollFilterField extends FilterField { /// **Supported operators:** `$eq` static final votingVisibility = PollFilterField( 'voting_visibility', - (it) => _$VotingVisibilityEnumMap[it.votingVisibility], + (it) => it.votingVisibility.value, ); /// Filters polls by their creation date. diff --git a/packages/stream_chat/lib/src/core/models/poll.freezed.dart b/packages/stream_chat/lib/src/core/models/poll.freezed.dart new file mode 100644 index 0000000000..c573136c33 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/poll.freezed.dart @@ -0,0 +1,257 @@ +// 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 'poll.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$Poll { + String get id; + String get name; + String? get description; + List get options; + VotingVisibility get votingVisibility; + bool get enforceUniqueVote; + int? get maxVotesAllowed; + bool get allowUserSuggestedOptions; + bool get allowAnswers; + bool get isClosed; + int get answersCount; + Map get voteCountsByOption; + Map> get latestVotesByOption; + List get latestAnswers; + List get ownVotesAndAnswers; + int get voteCount; + String? get createdById; + User? get createdBy; + DateTime get createdAt; + DateTime get updatedAt; + Map get extraData; + + /// Create a copy of Poll + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $PollCopyWith get copyWith => _$PollCopyWithImpl(this as Poll, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is Poll && + (identical(other.id, id) || other.id == id) && + (identical(other.name, name) || other.name == name) && + (identical(other.description, description) || other.description == description) && + const DeepCollectionEquality().equals(other.options, options) && + (identical(other.votingVisibility, votingVisibility) || other.votingVisibility == votingVisibility) && + (identical(other.enforceUniqueVote, enforceUniqueVote) || other.enforceUniqueVote == enforceUniqueVote) && + (identical(other.maxVotesAllowed, maxVotesAllowed) || other.maxVotesAllowed == maxVotesAllowed) && + (identical(other.allowUserSuggestedOptions, allowUserSuggestedOptions) || + other.allowUserSuggestedOptions == allowUserSuggestedOptions) && + (identical(other.allowAnswers, allowAnswers) || other.allowAnswers == allowAnswers) && + (identical(other.isClosed, isClosed) || other.isClosed == isClosed) && + (identical(other.answersCount, answersCount) || other.answersCount == answersCount) && + const DeepCollectionEquality().equals(other.voteCountsByOption, voteCountsByOption) && + const DeepCollectionEquality().equals(other.latestVotesByOption, latestVotesByOption) && + const DeepCollectionEquality().equals(other.latestAnswers, latestAnswers) && + const DeepCollectionEquality().equals(other.ownVotesAndAnswers, ownVotesAndAnswers) && + (identical(other.voteCount, voteCount) || other.voteCount == voteCount) && + (identical(other.createdById, createdById) || other.createdById == createdById) && + (identical(other.createdBy, createdBy) || other.createdBy == createdBy) && + (identical(other.createdAt, createdAt) || other.createdAt == createdAt) && + (identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt) && + const DeepCollectionEquality().equals(other.extraData, extraData)); + } + + @override + int get hashCode => Object.hashAll([ + runtimeType, + id, + name, + description, + const DeepCollectionEquality().hash(options), + votingVisibility, + enforceUniqueVote, + maxVotesAllowed, + allowUserSuggestedOptions, + allowAnswers, + isClosed, + answersCount, + const DeepCollectionEquality().hash(voteCountsByOption), + const DeepCollectionEquality().hash(latestVotesByOption), + const DeepCollectionEquality().hash(latestAnswers), + const DeepCollectionEquality().hash(ownVotesAndAnswers), + voteCount, + createdById, + createdBy, + createdAt, + updatedAt, + const DeepCollectionEquality().hash(extraData), + ]); + + @override + String toString() { + return 'Poll(id: $id, name: $name, description: $description, options: $options, votingVisibility: $votingVisibility, enforceUniqueVote: $enforceUniqueVote, maxVotesAllowed: $maxVotesAllowed, allowUserSuggestedOptions: $allowUserSuggestedOptions, allowAnswers: $allowAnswers, isClosed: $isClosed, answersCount: $answersCount, voteCountsByOption: $voteCountsByOption, latestVotesByOption: $latestVotesByOption, latestAnswers: $latestAnswers, ownVotesAndAnswers: $ownVotesAndAnswers, voteCount: $voteCount, createdById: $createdById, createdBy: $createdBy, createdAt: $createdAt, updatedAt: $updatedAt, extraData: $extraData)'; + } +} + +/// @nodoc +abstract mixin class $PollCopyWith<$Res> { + factory $PollCopyWith(Poll value, $Res Function(Poll) _then) = _$PollCopyWithImpl; + @useResult + $Res call({ + String? id, + String name, + String? description, + List options, + VotingVisibility votingVisibility, + bool enforceUniqueVote, + int? maxVotesAllowed, + bool allowAnswers, + List latestAnswers, + int answersCount, + bool allowUserSuggestedOptions, + bool isClosed, + DateTime? createdAt, + DateTime? updatedAt, + Map voteCountsByOption, + int voteCount, + Map> latestVotesByOption, + String? createdById, + User? createdBy, + List ownVotesAndAnswers, + Map extraData, + }); +} + +/// @nodoc +class _$PollCopyWithImpl<$Res> implements $PollCopyWith<$Res> { + _$PollCopyWithImpl(this._self, this._then); + + final Poll _self; + final $Res Function(Poll) _then; + + /// Create a copy of Poll + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = freezed, + Object? name = null, + Object? description = freezed, + Object? options = null, + Object? votingVisibility = null, + Object? enforceUniqueVote = null, + Object? maxVotesAllowed = freezed, + Object? allowAnswers = null, + Object? latestAnswers = null, + Object? answersCount = null, + Object? allowUserSuggestedOptions = null, + Object? isClosed = null, + Object? createdAt = freezed, + Object? updatedAt = freezed, + Object? voteCountsByOption = null, + Object? voteCount = null, + Object? latestVotesByOption = null, + Object? createdById = freezed, + Object? createdBy = freezed, + Object? ownVotesAndAnswers = null, + Object? extraData = null, + }) { + return _then( + Poll( + id: freezed == id + ? _self.id! + : id // ignore: cast_nullable_to_non_nullable + as String?, + name: null == name + ? _self.name + : name // ignore: cast_nullable_to_non_nullable + as String, + description: freezed == description + ? _self.description + : description // ignore: cast_nullable_to_non_nullable + as String?, + options: null == options + ? _self.options + : options // ignore: cast_nullable_to_non_nullable + as List, + votingVisibility: null == votingVisibility + ? _self.votingVisibility + : votingVisibility // ignore: cast_nullable_to_non_nullable + as VotingVisibility, + enforceUniqueVote: null == enforceUniqueVote + ? _self.enforceUniqueVote + : enforceUniqueVote // ignore: cast_nullable_to_non_nullable + as bool, + maxVotesAllowed: freezed == maxVotesAllowed + ? _self.maxVotesAllowed + : maxVotesAllowed // ignore: cast_nullable_to_non_nullable + as int?, + allowAnswers: null == allowAnswers + ? _self.allowAnswers + : allowAnswers // ignore: cast_nullable_to_non_nullable + as bool, + latestAnswers: null == latestAnswers + ? _self.latestAnswers + : latestAnswers // ignore: cast_nullable_to_non_nullable + as List, + answersCount: null == answersCount + ? _self.answersCount + : answersCount // ignore: cast_nullable_to_non_nullable + as int, + allowUserSuggestedOptions: null == allowUserSuggestedOptions + ? _self.allowUserSuggestedOptions + : allowUserSuggestedOptions // ignore: cast_nullable_to_non_nullable + as bool, + isClosed: null == isClosed + ? _self.isClosed + : isClosed // ignore: cast_nullable_to_non_nullable + as bool, + createdAt: freezed == createdAt + ? _self.createdAt! + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + updatedAt: freezed == updatedAt + ? _self.updatedAt! + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + voteCountsByOption: null == voteCountsByOption + ? _self.voteCountsByOption + : voteCountsByOption // ignore: cast_nullable_to_non_nullable + as Map, + voteCount: null == voteCount + ? _self.voteCount + : voteCount // ignore: cast_nullable_to_non_nullable + as int, + latestVotesByOption: null == latestVotesByOption + ? _self.latestVotesByOption + : latestVotesByOption // ignore: cast_nullable_to_non_nullable + as Map>, + createdById: freezed == createdById + ? _self.createdById + : createdById // ignore: cast_nullable_to_non_nullable + as String?, + createdBy: freezed == createdBy + ? _self.createdBy + : createdBy // ignore: cast_nullable_to_non_nullable + as User?, + ownVotesAndAnswers: null == ownVotesAndAnswers + ? _self.ownVotesAndAnswers + : ownVotesAndAnswers // ignore: cast_nullable_to_non_nullable + as List, + extraData: null == extraData + ? _self.extraData + : extraData // ignore: cast_nullable_to_non_nullable + as Map, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/poll.g.dart b/packages/stream_chat/lib/src/core/models/poll.g.dart deleted file mode 100644 index 01af8c574f..0000000000 --- a/packages/stream_chat/lib/src/core/models/poll.g.dart +++ /dev/null @@ -1,70 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'poll.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -Poll _$PollFromJson(Map json) => Poll( - id: json['id'] as String?, - name: json['name'] as String, - description: json['description'] as String?, - options: (json['options'] as List).map((e) => PollOption.fromJson(e as Map)).toList(), - votingVisibility: - $enumDecodeNullable( - _$VotingVisibilityEnumMap, - json['voting_visibility'], - ) ?? - VotingVisibility.public, - enforceUniqueVote: json['enforce_unique_vote'] as bool? ?? true, - maxVotesAllowed: (json['max_votes_allowed'] as num?)?.toInt(), - allowAnswers: json['allow_answers'] as bool? ?? false, - latestAnswers: - (json['latest_answers'] as List?)?.map((e) => PollVote.fromJson(e as Map)).toList() ?? - const [], - answersCount: (json['answers_count'] as num?)?.toInt() ?? 0, - allowUserSuggestedOptions: json['allow_user_suggested_options'] as bool? ?? false, - isClosed: json['is_closed'] as bool? ?? false, - 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), - voteCountsByOption: - (json['vote_counts_by_option'] as Map?)?.map( - (k, e) => MapEntry(k, (e as num).toInt()), - ) ?? - const {}, - voteCount: (json['vote_count'] as num?)?.toInt() ?? 0, - latestVotesByOption: - (json['latest_votes_by_option'] as Map?)?.map( - (k, e) => MapEntry( - k, - (e as List).map((e) => PollVote.fromJson(e as Map)).toList(), - ), - ) ?? - const {}, - createdById: json['created_by_id'] as String?, - createdBy: json['created_by'] == null ? null : User.fromJson(json['created_by'] as Map), - ownVotesAndAnswers: - (json['own_votes'] as List?)?.map((e) => PollVote.fromJson(e as Map)).toList() ?? - const [], - extraData: json['extra_data'] as Map? ?? const {}, -); - -Map _$PollToJson(Poll instance) => { - 'id': instance.id, - 'name': instance.name, - 'description': instance.description, - 'options': instance.options.map((e) => e.toJson()).toList(), - 'voting_visibility': _$VotingVisibilityEnumMap[instance.votingVisibility]!, - 'enforce_unique_vote': instance.enforceUniqueVote, - 'max_votes_allowed': instance.maxVotesAllowed, - 'allow_user_suggested_options': instance.allowUserSuggestedOptions, - 'allow_answers': instance.allowAnswers, - 'is_closed': instance.isClosed, - 'extra_data': instance.extraData, -}; - -const _$VotingVisibilityEnumMap = { - VotingVisibility.anonymous: 'anonymous', - VotingVisibility.public: 'public', -}; diff --git a/packages/stream_chat/lib/src/core/models/poll_option.dart b/packages/stream_chat/lib/src/core/models/poll_option.dart index ac5d5279df..c89099c560 100644 --- a/packages/stream_chat/lib/src/core/models/poll_option.dart +++ b/packages/stream_chat/lib/src/core/models/poll_option.dart @@ -1,64 +1,51 @@ -import 'package:equatable/equatable.dart'; -import 'package:json_annotation/json_annotation.dart'; -import '../util/serializer.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; -part 'poll_option.g.dart'; - -class _NullConst { - const _NullConst(); -} +import '../../db/data_serializable.dart'; -const _nullConst = _NullConst(); +part 'poll_option.freezed.dart'; +part 'poll_option.g.dart'; -/// {@template streamPollOption} -/// A model class representing a poll option. -/// {@endtemplate} -@JsonSerializable() -class PollOption extends Equatable { - /// {@macro streamPollOption} +/// One of the choices a poll offers to vote on. +@freezed +// TODO(openapi-migration): remove in group 10 +@DataSerializable(includeIfNull: false) +class PollOption with _$PollOption { + /// Creates a new [PollOption]. + /// + /// An option that is about to be added to a poll has no [id] yet; one is + /// assigned when the poll or the option is created. const PollOption({ this.id, required this.text, this.extraData = const {}, }); - /// Create a new instance from a json - factory PollOption.fromJson(Map json) => _$PollOptionFromJson( - Serializer.moveToExtraDataFromRoot(json, topLevelFields), - ); + /// Creates a [PollOption] from the offline-database format written by [toData]. + /// + /// It is not a codec for API payloads. + factory PollOption.fromData(Map json) => _$PollOptionFromJson(json); - /// The unique identifier of the poll option. - @JsonKey(includeIfNull: false) + /// The unique identifier of this option, or null before it is created. + @override final String? id; - /// The text describing the poll option. + /// The text shown for this option. + @override final String text; - /// Map of custom poll option extraData + /// Custom data attached to this option. + @override final Map extraData; - /// Serialize to json - Map toJson() => Serializer.moveFromExtraDataToRoot(_$PollOptionToJson(this)); - - /// Creates a copy of [PollOption] with specified attributes overridden. - PollOption copyWith({ - Object? id = _nullConst, - String? text, - Map? extraData, - }) => PollOption( - id: id == _nullConst ? this.id : id as String?, - text: text ?? this.text, - extraData: extraData ?? this.extraData, - ); - - /// Known top level fields. - /// - /// Useful for [Serializer] methods. + /// The keys an option carries besides its custom data. static const topLevelFields = [ 'id', 'text', + 'text_i18n', ]; - @override - List get props => [id, text]; + /// Serializes this option to the format `stream_chat_persistence` stores. + /// + /// It is not a codec for API payloads. + Map toData() => _$PollOptionToJson(this); } diff --git a/packages/stream_chat/lib/src/core/models/poll_option.freezed.dart b/packages/stream_chat/lib/src/core/models/poll_option.freezed.dart new file mode 100644 index 0000000000..239f118a07 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/poll_option.freezed.dart @@ -0,0 +1,81 @@ +// 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 'poll_option.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$PollOption { + String? get id; + String get text; + Map get extraData; + + /// Create a copy of PollOption + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $PollOptionCopyWith get copyWith => _$PollOptionCopyWithImpl(this as PollOption, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is PollOption && + (identical(other.id, id) || other.id == id) && + (identical(other.text, text) || other.text == text) && + const DeepCollectionEquality().equals(other.extraData, extraData)); + } + + @override + int get hashCode => Object.hash(runtimeType, id, text, const DeepCollectionEquality().hash(extraData)); + + @override + String toString() { + return 'PollOption(id: $id, text: $text, extraData: $extraData)'; + } +} + +/// @nodoc +abstract mixin class $PollOptionCopyWith<$Res> { + factory $PollOptionCopyWith(PollOption value, $Res Function(PollOption) _then) = _$PollOptionCopyWithImpl; + @useResult + $Res call({String? id, String text, Map extraData}); +} + +/// @nodoc +class _$PollOptionCopyWithImpl<$Res> implements $PollOptionCopyWith<$Res> { + _$PollOptionCopyWithImpl(this._self, this._then); + + final PollOption _self; + final $Res Function(PollOption) _then; + + /// Create a copy of PollOption + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? id = freezed, Object? text = null, Object? extraData = null}) { + return _then( + PollOption( + id: freezed == id + ? _self.id + : id // ignore: cast_nullable_to_non_nullable + as String?, + text: null == text + ? _self.text + : text // ignore: cast_nullable_to_non_nullable + as String, + extraData: null == extraData + ? _self.extraData + : extraData // ignore: cast_nullable_to_non_nullable + as Map, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/poll_vote.dart b/packages/stream_chat/lib/src/core/models/poll_vote.dart index 013e746215..8ed1137bab 100644 --- a/packages/stream_chat/lib/src/core/models/poll_vote.dart +++ b/packages/stream_chat/lib/src/core/models/poll_vote.dart @@ -1,17 +1,20 @@ -import 'package:equatable/equatable.dart'; -import 'package:json_annotation/json_annotation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:stream_core/stream_core.dart' show Filter, FilterField, Sort, SortField; import 'user.dart'; -part 'poll_vote.g.dart'; +part 'poll_vote.freezed.dart'; -/// {@template streamPollVote} -/// A model class representing a poll vote. -/// {@endtemplate} -@JsonSerializable() -class PollVote extends Equatable { - /// {@macro streamPollVote} +/// A user's vote for an option of a poll, or their free-form answer to it. +/// +/// A vote carries an [optionId] and an answer carries [answerText]; [isAnswer] +/// tells the two apart. +@freezed +class PollVote with _$PollVote { + /// Creates a new [PollVote]. + /// + /// Either [optionId] or [answerText] must be given. [createdAt] and + /// [updatedAt] default to the current time. PollVote({ this.id, this.pollId, @@ -25,85 +28,49 @@ class PollVote extends Equatable { optionId != null || answerText != null, 'Either optionId or answerText must be provided', ), - isAnswer = answerText != null, createdAt = createdAt ?? DateTime.now(), updatedAt = updatedAt ?? DateTime.now(); - /// Create a new instance from a json - factory PollVote.fromJson(Map json) => _$PollVoteFromJson(json); - - /// The unique identifier of the poll vote. - @JsonKey(includeIfNull: false) + /// The unique identifier of this vote, or null before it is cast. + @override final String? id; - /// The unique identifier of the option selected in the poll. - @JsonKey(includeIfNull: false) + /// The unique identifier of the poll this vote belongs to. + @override + final String? pollId; + + /// The unique identifier of the option this vote selects. + /// + /// An answer selects no option. + @override final String? optionId; - /// The text of the answer provided in the poll. - @JsonKey(includeIfNull: false) + /// The text of this answer, or null for a vote. + @override final String? answerText; - /// If true, the vote is an answer. - @JsonKey(includeToJson: false) - final bool isAnswer; - - /// The unique identifier of the poll the vote belongs to. - @JsonKey(includeToJson: false) - final String? pollId; - - /// The date when the poll vote was created. - @JsonKey(includeToJson: false) + /// The date this vote was cast. + @override final DateTime createdAt; - /// The date when the poll vote was last updated. - @JsonKey(includeToJson: false) + /// The date this vote was last changed. + @override final DateTime updatedAt; - /// The unique identifier of the user who voted. - @JsonKey(includeToJson: false) + /// The unique identifier of the user who cast this vote. + /// + /// Null on someone else's vote in an anonymous poll. + @override final String? userId; - /// The user who casted the vote. - @JsonKey(includeToJson: false) + /// The user who cast this vote. + /// + /// Null on someone else's vote in an anonymous poll. + @override final User? user; - /// Serialize to json - Map toJson() => _$PollVoteToJson(this); - - /// Creates a copy of [PollVote] with specified attributes overridden. - PollVote copyWith({ - String? id, - String? pollId, - String? optionId, - String? answerText, - DateTime? createdAt, - DateTime? updatedAt, - String? userId, - User? user, - }) => PollVote( - id: id ?? this.id, - pollId: pollId ?? this.pollId, - optionId: optionId ?? this.optionId, - answerText: answerText ?? this.answerText, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - userId: userId ?? this.userId, - user: user ?? this.user, - ); - - @override - List get props => [ - id, - pollId, - optionId, - isAnswer, - answerText, - createdAt, - updatedAt, - userId, - user, - ]; + /// Whether this is a free-form answer rather than a vote for an option. + bool get isAnswer => answerText != null; } /// A filter for a poll vote query. diff --git a/packages/stream_chat/lib/src/core/models/poll_vote.freezed.dart b/packages/stream_chat/lib/src/core/models/poll_vote.freezed.dart new file mode 100644 index 0000000000..0b759fbc82 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/poll_vote.freezed.dart @@ -0,0 +1,129 @@ +// 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 'poll_vote.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$PollVote { + String? get id; + String? get pollId; + String? get optionId; + String? get answerText; + DateTime get createdAt; + DateTime get updatedAt; + String? get userId; + User? get user; + + /// Create a copy of PollVote + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $PollVoteCopyWith get copyWith => _$PollVoteCopyWithImpl(this as PollVote, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is PollVote && + (identical(other.id, id) || other.id == id) && + (identical(other.pollId, pollId) || other.pollId == pollId) && + (identical(other.optionId, optionId) || other.optionId == optionId) && + (identical(other.answerText, answerText) || other.answerText == answerText) && + (identical(other.createdAt, createdAt) || other.createdAt == createdAt) && + (identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt) && + (identical(other.userId, userId) || other.userId == userId) && + (identical(other.user, user) || other.user == user)); + } + + @override + int get hashCode => Object.hash(runtimeType, id, pollId, optionId, answerText, createdAt, updatedAt, userId, user); + + @override + String toString() { + return 'PollVote(id: $id, pollId: $pollId, optionId: $optionId, answerText: $answerText, createdAt: $createdAt, updatedAt: $updatedAt, userId: $userId, user: $user)'; + } +} + +/// @nodoc +abstract mixin class $PollVoteCopyWith<$Res> { + factory $PollVoteCopyWith(PollVote value, $Res Function(PollVote) _then) = _$PollVoteCopyWithImpl; + @useResult + $Res call({ + String? id, + String? pollId, + String? optionId, + String? answerText, + DateTime? createdAt, + DateTime? updatedAt, + String? userId, + User? user, + }); +} + +/// @nodoc +class _$PollVoteCopyWithImpl<$Res> implements $PollVoteCopyWith<$Res> { + _$PollVoteCopyWithImpl(this._self, this._then); + + final PollVote _self; + final $Res Function(PollVote) _then; + + /// Create a copy of PollVote + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = freezed, + Object? pollId = freezed, + Object? optionId = freezed, + Object? answerText = freezed, + Object? createdAt = freezed, + Object? updatedAt = freezed, + Object? userId = freezed, + Object? user = freezed, + }) { + return _then( + PollVote( + id: freezed == id + ? _self.id + : id // ignore: cast_nullable_to_non_nullable + as String?, + pollId: freezed == pollId + ? _self.pollId + : pollId // ignore: cast_nullable_to_non_nullable + as String?, + optionId: freezed == optionId + ? _self.optionId + : optionId // ignore: cast_nullable_to_non_nullable + as String?, + answerText: freezed == answerText + ? _self.answerText + : answerText // ignore: cast_nullable_to_non_nullable + as String?, + createdAt: freezed == createdAt + ? _self.createdAt! + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + updatedAt: freezed == updatedAt + ? _self.updatedAt! + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + userId: freezed == userId + ? _self.userId + : userId // ignore: cast_nullable_to_non_nullable + as String?, + user: freezed == user + ? _self.user + : user // ignore: cast_nullable_to_non_nullable + as User?, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/poll_vote.g.dart b/packages/stream_chat/lib/src/core/models/poll_vote.g.dart deleted file mode 100644 index 093ed39588..0000000000 --- a/packages/stream_chat/lib/src/core/models/poll_vote.g.dart +++ /dev/null @@ -1,24 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'poll_vote.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -PollVote _$PollVoteFromJson(Map json) => PollVote( - id: json['id'] as String?, - pollId: json['poll_id'] as String?, - optionId: json['option_id'] as String?, - answerText: json['answer_text'] 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), - userId: json['user_id'] as String?, - user: json['user'] == null ? null : User.fromJson(json['user'] as Map), -); - -Map _$PollVoteToJson(PollVote instance) => { - 'id': ?instance.id, - 'option_id': ?instance.optionId, - 'answer_text': ?instance.answerText, -}; diff --git a/packages/stream_chat/lib/src/core/models/response/poll_option_response.dart b/packages/stream_chat/lib/src/core/models/response/poll_option_response.dart new file mode 100644 index 0000000000..66d4ad99fd --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/poll_option_response.dart @@ -0,0 +1,23 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../poll_option.dart'; + +part 'poll_option_response.freezed.dart'; + +/// The result of creating, fetching or updating an option of a poll. +@freezed +class PollOptionResponse with _$PollOptionResponse { + /// Creates a new [PollOptionResponse]. + const PollOptionResponse({ + required this.duration, + required this.pollOption, + }); + + /// How long the request took to handle, such as `4.21ms`. + @override + final String duration; + + /// The option as it stands after the request. + @override + final PollOption pollOption; +} diff --git a/packages/stream_chat/lib/src/core/models/response/poll_option_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/poll_option_response.freezed.dart new file mode 100644 index 0000000000..9245946901 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/poll_option_response.freezed.dart @@ -0,0 +1,77 @@ +// 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 'poll_option_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$PollOptionResponse { + String get duration; + PollOption get pollOption; + + /// Create a copy of PollOptionResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $PollOptionResponseCopyWith get copyWith => + _$PollOptionResponseCopyWithImpl(this as PollOptionResponse, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is PollOptionResponse && + (identical(other.duration, duration) || other.duration == duration) && + (identical(other.pollOption, pollOption) || other.pollOption == pollOption)); + } + + @override + int get hashCode => Object.hash(runtimeType, duration, pollOption); + + @override + String toString() { + return 'PollOptionResponse(duration: $duration, pollOption: $pollOption)'; + } +} + +/// @nodoc +abstract mixin class $PollOptionResponseCopyWith<$Res> { + factory $PollOptionResponseCopyWith(PollOptionResponse value, $Res Function(PollOptionResponse) _then) = + _$PollOptionResponseCopyWithImpl; + @useResult + $Res call({String duration, PollOption pollOption}); +} + +/// @nodoc +class _$PollOptionResponseCopyWithImpl<$Res> implements $PollOptionResponseCopyWith<$Res> { + _$PollOptionResponseCopyWithImpl(this._self, this._then); + + final PollOptionResponse _self; + final $Res Function(PollOptionResponse) _then; + + /// Create a copy of PollOptionResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? duration = null, Object? pollOption = null}) { + return _then( + PollOptionResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + pollOption: null == pollOption + ? _self.pollOption + : pollOption // ignore: cast_nullable_to_non_nullable + as PollOption, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/response/poll_response.dart b/packages/stream_chat/lib/src/core/models/response/poll_response.dart new file mode 100644 index 0000000000..b21cf96a08 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/poll_response.dart @@ -0,0 +1,23 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../poll.dart'; + +part 'poll_response.freezed.dart'; + +/// The result of creating, fetching or updating a poll. +@freezed +class PollResponse with _$PollResponse { + /// Creates a new [PollResponse]. + const PollResponse({ + required this.duration, + required this.poll, + }); + + /// How long the request took to handle, such as `4.21ms`. + @override + final String duration; + + /// The poll as it stands after the request. + @override + final Poll poll; +} diff --git a/packages/stream_chat/lib/src/core/models/response/poll_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/poll_response.freezed.dart new file mode 100644 index 0000000000..241268a0d7 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/poll_response.freezed.dart @@ -0,0 +1,76 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'poll_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$PollResponse { + String get duration; + Poll get poll; + + /// Create a copy of PollResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $PollResponseCopyWith get copyWith => + _$PollResponseCopyWithImpl(this as PollResponse, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is PollResponse && + (identical(other.duration, duration) || other.duration == duration) && + (identical(other.poll, poll) || other.poll == poll)); + } + + @override + int get hashCode => Object.hash(runtimeType, duration, poll); + + @override + String toString() { + return 'PollResponse(duration: $duration, poll: $poll)'; + } +} + +/// @nodoc +abstract mixin class $PollResponseCopyWith<$Res> { + factory $PollResponseCopyWith(PollResponse value, $Res Function(PollResponse) _then) = _$PollResponseCopyWithImpl; + @useResult + $Res call({String duration, Poll poll}); +} + +/// @nodoc +class _$PollResponseCopyWithImpl<$Res> implements $PollResponseCopyWith<$Res> { + _$PollResponseCopyWithImpl(this._self, this._then); + + final PollResponse _self; + final $Res Function(PollResponse) _then; + + /// Create a copy of PollResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? duration = null, Object? poll = null}) { + return _then( + PollResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + poll: null == poll + ? _self.poll + : poll // ignore: cast_nullable_to_non_nullable + as Poll, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/response/poll_vote_response.dart b/packages/stream_chat/lib/src/core/models/response/poll_vote_response.dart new file mode 100644 index 0000000000..151b4c3d75 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/poll_vote_response.dart @@ -0,0 +1,23 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../poll_vote.dart'; + +part 'poll_vote_response.freezed.dart'; + +/// The result of casting or removing a vote or an answer on a poll. +@freezed +class PollVoteResponse with _$PollVoteResponse { + /// Creates a new [PollVoteResponse]. + const PollVoteResponse({ + required this.duration, + this.vote, + }); + + /// How long the request took to handle, such as `4.21ms`. + @override + final String duration; + + /// The vote that was cast or removed. + @override + final PollVote? vote; +} diff --git a/packages/stream_chat/lib/src/core/models/response/poll_vote_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/poll_vote_response.freezed.dart new file mode 100644 index 0000000000..88e7d5cb72 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/poll_vote_response.freezed.dart @@ -0,0 +1,77 @@ +// 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 'poll_vote_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$PollVoteResponse { + String get duration; + PollVote? get vote; + + /// Create a copy of PollVoteResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $PollVoteResponseCopyWith get copyWith => + _$PollVoteResponseCopyWithImpl(this as PollVoteResponse, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is PollVoteResponse && + (identical(other.duration, duration) || other.duration == duration) && + (identical(other.vote, vote) || other.vote == vote)); + } + + @override + int get hashCode => Object.hash(runtimeType, duration, vote); + + @override + String toString() { + return 'PollVoteResponse(duration: $duration, vote: $vote)'; + } +} + +/// @nodoc +abstract mixin class $PollVoteResponseCopyWith<$Res> { + factory $PollVoteResponseCopyWith(PollVoteResponse value, $Res Function(PollVoteResponse) _then) = + _$PollVoteResponseCopyWithImpl; + @useResult + $Res call({String duration, PollVote? vote}); +} + +/// @nodoc +class _$PollVoteResponseCopyWithImpl<$Res> implements $PollVoteResponseCopyWith<$Res> { + _$PollVoteResponseCopyWithImpl(this._self, this._then); + + final PollVoteResponse _self; + final $Res Function(PollVoteResponse) _then; + + /// Create a copy of PollVoteResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? duration = null, Object? vote = freezed}) { + return _then( + PollVoteResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + vote: freezed == vote + ? _self.vote + : vote // ignore: cast_nullable_to_non_nullable + as PollVote?, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/response/query_poll_votes_response.dart b/packages/stream_chat/lib/src/core/models/response/query_poll_votes_response.dart new file mode 100644 index 0000000000..0a8c30cd83 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/query_poll_votes_response.dart @@ -0,0 +1,33 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../poll_vote.dart'; + +part 'query_poll_votes_response.freezed.dart'; + +/// One page of the votes and answers of a poll matching a query. +@freezed +class QueryPollVotesResponse with _$QueryPollVotesResponse { + /// Creates a new [QueryPollVotesResponse]. + const QueryPollVotesResponse({ + required this.duration, + required this.votes, + this.next, + this.prev, + }); + + /// How long the request took to handle, such as `4.21ms`. + @override + final String duration; + + /// The votes and answers on this page. + @override + final List votes; + + /// The cursor for the next page, or null on the last page. + @override + final String? next; + + /// The cursor for the previous page, or null on the first page. + @override + final String? prev; +} diff --git a/packages/stream_chat/lib/src/core/models/response/query_poll_votes_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/query_poll_votes_response.freezed.dart new file mode 100644 index 0000000000..45de37e4f4 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/query_poll_votes_response.freezed.dart @@ -0,0 +1,89 @@ +// 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 'query_poll_votes_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$QueryPollVotesResponse { + String get duration; + List get votes; + String? get next; + String? get prev; + + /// Create a copy of QueryPollVotesResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $QueryPollVotesResponseCopyWith get copyWith => + _$QueryPollVotesResponseCopyWithImpl(this as QueryPollVotesResponse, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is QueryPollVotesResponse && + (identical(other.duration, duration) || other.duration == duration) && + const DeepCollectionEquality().equals(other.votes, votes) && + (identical(other.next, next) || other.next == next) && + (identical(other.prev, prev) || other.prev == prev)); + } + + @override + int get hashCode => Object.hash(runtimeType, duration, const DeepCollectionEquality().hash(votes), next, prev); + + @override + String toString() { + return 'QueryPollVotesResponse(duration: $duration, votes: $votes, next: $next, prev: $prev)'; + } +} + +/// @nodoc +abstract mixin class $QueryPollVotesResponseCopyWith<$Res> { + factory $QueryPollVotesResponseCopyWith(QueryPollVotesResponse value, $Res Function(QueryPollVotesResponse) _then) = + _$QueryPollVotesResponseCopyWithImpl; + @useResult + $Res call({String duration, List votes, String? next, String? prev}); +} + +/// @nodoc +class _$QueryPollVotesResponseCopyWithImpl<$Res> implements $QueryPollVotesResponseCopyWith<$Res> { + _$QueryPollVotesResponseCopyWithImpl(this._self, this._then); + + final QueryPollVotesResponse _self; + final $Res Function(QueryPollVotesResponse) _then; + + /// Create a copy of QueryPollVotesResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? duration = null, Object? votes = null, Object? next = freezed, Object? prev = freezed}) { + return _then( + QueryPollVotesResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + votes: null == votes + ? _self.votes + : votes // ignore: cast_nullable_to_non_nullable + as List, + next: freezed == next + ? _self.next + : next // ignore: cast_nullable_to_non_nullable + as String?, + prev: freezed == prev + ? _self.prev + : prev // ignore: cast_nullable_to_non_nullable + as String?, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/response/query_polls_response.dart b/packages/stream_chat/lib/src/core/models/response/query_polls_response.dart new file mode 100644 index 0000000000..da87101885 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/query_polls_response.dart @@ -0,0 +1,33 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../poll.dart'; + +part 'query_polls_response.freezed.dart'; + +/// One page of the polls matching a query. +@freezed +class QueryPollsResponse with _$QueryPollsResponse { + /// Creates a new [QueryPollsResponse]. + const QueryPollsResponse({ + required this.duration, + required this.polls, + this.next, + this.prev, + }); + + /// How long the request took to handle, such as `4.21ms`. + @override + final String duration; + + /// The polls on this page. + @override + final List polls; + + /// The cursor for the next page, or null on the last page. + @override + final String? next; + + /// The cursor for the previous page, or null on the first page. + @override + final String? prev; +} diff --git a/packages/stream_chat/lib/src/core/models/response/query_polls_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/query_polls_response.freezed.dart new file mode 100644 index 0000000000..fa1b10518c --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/query_polls_response.freezed.dart @@ -0,0 +1,89 @@ +// 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 'query_polls_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$QueryPollsResponse { + String get duration; + List get polls; + String? get next; + String? get prev; + + /// Create a copy of QueryPollsResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $QueryPollsResponseCopyWith get copyWith => + _$QueryPollsResponseCopyWithImpl(this as QueryPollsResponse, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is QueryPollsResponse && + (identical(other.duration, duration) || other.duration == duration) && + const DeepCollectionEquality().equals(other.polls, polls) && + (identical(other.next, next) || other.next == next) && + (identical(other.prev, prev) || other.prev == prev)); + } + + @override + int get hashCode => Object.hash(runtimeType, duration, const DeepCollectionEquality().hash(polls), next, prev); + + @override + String toString() { + return 'QueryPollsResponse(duration: $duration, polls: $polls, next: $next, prev: $prev)'; + } +} + +/// @nodoc +abstract mixin class $QueryPollsResponseCopyWith<$Res> { + factory $QueryPollsResponseCopyWith(QueryPollsResponse value, $Res Function(QueryPollsResponse) _then) = + _$QueryPollsResponseCopyWithImpl; + @useResult + $Res call({String duration, List polls, String? next, String? prev}); +} + +/// @nodoc +class _$QueryPollsResponseCopyWithImpl<$Res> implements $QueryPollsResponseCopyWith<$Res> { + _$QueryPollsResponseCopyWithImpl(this._self, this._then); + + final QueryPollsResponse _self; + final $Res Function(QueryPollsResponse) _then; + + /// Create a copy of QueryPollsResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? duration = null, Object? polls = null, Object? next = freezed, Object? prev = freezed}) { + return _then( + QueryPollsResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + polls: null == polls + ? _self.polls + : polls // ignore: cast_nullable_to_non_nullable + as List, + next: freezed == next + ? _self.next + : next // ignore: cast_nullable_to_non_nullable + as String?, + prev: freezed == prev + ? _self.prev + : prev // ignore: cast_nullable_to_non_nullable + as String?, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/voting_visibility.dart b/packages/stream_chat/lib/src/core/models/voting_visibility.dart new file mode 100644 index 0000000000..d3853a6b39 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/voting_visibility.dart @@ -0,0 +1,11 @@ +/// Who can see which option each user voted for in a poll. +/// +/// A visibility without a constant here can still be named by wrapping its +/// value: `VotingVisibility('public')`. +extension type const VotingVisibility(String value) implements String { + /// Votes are counted, but who cast them is hidden from other users. + static const anonymous = VotingVisibility('anonymous'); + + /// Everyone can see who voted for which option. + static const public = VotingVisibility('public'); +} diff --git a/packages/stream_chat/lib/src/repository/mapper/polls_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/polls_mapper.dart new file mode 100644 index 0000000000..8feadb680d --- /dev/null +++ b/packages/stream_chat/lib/src/repository/mapper/polls_mapper.dart @@ -0,0 +1,182 @@ +import '../../../open_api/api.dart' as api; +import '../../core/models/poll.dart'; +import '../../core/models/poll_option.dart'; +import '../../core/models/poll_vote.dart'; +import '../../core/models/response/poll_option_response.dart'; +import '../../core/models/response/poll_response.dart'; +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'; + +/// Maps a generated [api.PollResponseData] to a [Poll]. +extension PollResponseDataMapper on api.PollResponseData { + /// Converts this poll into a [Poll]. + /// + /// [custom] becomes [Poll.extraData], without the keys named after a field of the poll's own, so a custom field + /// never stands in for one of them. + Poll toModel() => Poll( + id: id, + name: name, + description: description, + options: [for (final option in options) option.toModel()], + votingVisibility: VotingVisibility(votingVisibility), + enforceUniqueVote: enforceUniqueVote, + maxVotesAllowed: maxVotesAllowed, + allowAnswers: allowAnswers, + latestAnswers: [for (final answer in latestAnswers) answer.toModel()], + answersCount: answersCount, + allowUserSuggestedOptions: allowUserSuggestedOptions, + isClosed: isClosed ?? false, + createdAt: createdAt, + updatedAt: updatedAt, + voteCountsByOption: voteCountsByOption, + voteCount: voteCount, + latestVotesByOption: { + for (final MapEntry(key: optionId, value: votes) in latestVotesByOption.entries) + optionId: [for (final vote in votes) vote.toModel()], + }, + createdById: createdById, + createdBy: createdBy?.toModel(), + ownVotesAndAnswers: [for (final vote in ownVotes) vote.toModel()], + extraData: _withoutKeys(custom, Poll.topLevelFields), + ); +} + +/// Maps a generated [api.PollOptionResponseData] to a [PollOption]. +extension PollOptionResponseDataMapper on api.PollOptionResponseData { + /// Converts this option into a [PollOption]. + /// + /// [custom] becomes [PollOption.extraData], without the keys named after a field of the option's own. + PollOption toModel() => PollOption( + id: id, + text: text, + extraData: _withoutKeys(custom, PollOption.topLevelFields), + ); +} + +/// Maps a generated [api.PollVoteResponseData] to a [PollVote]. +extension PollVoteResponseDataMapper on api.PollVoteResponseData { + /// Converts this vote into a [PollVote]. + PollVote toModel() => PollVote( + id: id, + pollId: pollId, + optionId: optionId, + answerText: answerText, + createdAt: createdAt, + updatedAt: updatedAt, + userId: userId, + user: user?.toModel(), + ); +} + +/// Maps a generated [api.PollResponse] to a [PollResponse]. +extension PollResponseMapper on api.PollResponse { + /// Converts this response into a [PollResponse]. + PollResponse toModel() => PollResponse(duration: duration, poll: poll.toModel()); +} + +/// Maps a generated [api.PollOptionResponse] to a [PollOptionResponse]. +extension PollOptionResponseMapper on api.PollOptionResponse { + /// Converts this response into a [PollOptionResponse]. + PollOptionResponse toModel() => PollOptionResponse(duration: duration, pollOption: pollOption.toModel()); +} + +/// Maps a generated [api.PollVoteResponse] to a [PollVoteResponse]. +extension PollVoteResponseMapper on api.PollVoteResponse { + /// Converts this response into a [PollVoteResponse]. + PollVoteResponse toModel() => PollVoteResponse(duration: duration, vote: vote?.toModel()); +} + +/// Maps a generated [api.QueryPollsResponse] to a [QueryPollsResponse]. +extension QueryPollsResponseMapper on api.QueryPollsResponse { + /// Converts this response into a [QueryPollsResponse]. + QueryPollsResponse toModel() => QueryPollsResponse( + duration: duration, + polls: [for (final poll in polls) poll.toModel()], + next: next, + prev: prev, + ); +} + +/// Maps a generated [api.PollVotesResponse] to a [QueryPollVotesResponse]. +extension PollVotesResponseMapper on api.PollVotesResponse { + /// Converts this response into a [QueryPollVotesResponse]. + QueryPollVotesResponse toModel() => QueryPollVotesResponse( + duration: duration, + votes: [for (final vote in votes) vote.toModel()], + next: next, + prev: prev, + ); +} + +/// Maps a [Poll] to the generated requests that create and update it. +/// +/// Only the settings of the poll are sent; the vote summary is not. +extension PollRequestMapper on Poll { + /// Converts this poll into an [api.CreatePollRequest]. + /// + /// The ids of the [options] are left out: every option gets a new one when the poll is created. + api.CreatePollRequest toCreatePollRequest() => api.CreatePollRequest( + id: id, + name: name, + description: description, + options: [for (final option in options) option.toPollOptionInput()], + votingVisibility: api.CreatePollRequestVotingVisibility.fromJson(votingVisibility), + enforceUniqueVote: enforceUniqueVote, + maxVotesAllowed: maxVotesAllowed, + allowAnswers: allowAnswers, + allowUserSuggestedOptions: allowUserSuggestedOptions, + isClosed: isClosed, + custom: extraData, + ); + + /// Converts this poll into an [api.UpdatePollRequest], or null when one of its [options] has no id. + api.UpdatePollRequest? toUpdatePollRequest() { + final options = [for (final option in this.options) option.toPollOptionRequest()]; + if (options.contains(null)) return null; + + return api.UpdatePollRequest( + id: id, + name: name, + description: description, + options: options.nonNulls.toList(), + votingVisibility: api.UpdatePollRequestVotingVisibility.fromJson(votingVisibility), + enforceUniqueVote: enforceUniqueVote, + maxVotesAllowed: maxVotesAllowed, + allowAnswers: allowAnswers, + allowUserSuggestedOptions: allowUserSuggestedOptions, + isClosed: isClosed, + custom: extraData, + ); + } +} + +/// Maps a [PollOption] to the generated requests that create and update it. +extension PollOptionRequestMapper on PollOption { + /// Converts this option into an [api.PollOptionInput], an option of a poll being created. + api.PollOptionInput toPollOptionInput() => api.PollOptionInput(text: text, custom: extraData); + + /// Converts this option into an [api.PollOptionRequest], an option of a poll being updated, or null when it has + /// no [id]. + api.PollOptionRequest? toPollOptionRequest() => switch (id) { + final id? => api.PollOptionRequest(id: id, text: text, custom: extraData), + null => null, + }; + + /// Converts this option into an [api.CreatePollOptionRequest]. + api.CreatePollOptionRequest toCreatePollOptionRequest() => api.CreatePollOptionRequest( + text: text, + custom: extraData, + ); + + /// Converts this option into an [api.UpdatePollOptionRequest], or null when it has no [id]. + api.UpdatePollOptionRequest? toUpdatePollOptionRequest() => switch (id) { + final id? => api.UpdatePollOptionRequest(id: id, text: text, custom: extraData), + null => null, + }; +} + +Map _withoutKeys(Map custom, List keys) => + {...custom}..removeWhere((key, _) => keys.contains(key)); diff --git a/packages/stream_chat/lib/src/repository/polls_repository.dart b/packages/stream_chat/lib/src/repository/polls_repository.dart new file mode 100644 index 0000000000..b61a1225ab --- /dev/null +++ b/packages/stream_chat/lib/src/repository/polls_repository.dart @@ -0,0 +1,194 @@ +import 'package:stream_core/stream_core.dart' show PatternMatching, Result, Sort, StreamClientException; + +import '../../open_api/api.dart' as api; +import '../core/models/poll.dart'; +import '../core/models/poll_option.dart'; +import '../core/models/poll_vote.dart'; +import '../core/models/response/poll_option_response.dart'; +import '../core/models/response/poll_response.dart'; +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 'mapper/polls_mapper.dart'; +import 'mapper/result_mapper.dart'; + +/// Repository dedicated to poll operations. +class PollsRepository { + /// Initialize a new polls repository. + const PollsRepository(this._api); + + final api.DefaultApi _api; + + /// Creates [poll] with its settings and options. + Future> createPoll(Poll poll) async { + final result = await _api.createPoll(createPollRequest: poll.toCreatePollRequest()); + + return result.map((response) => response.toModel()); + } + + /// Fetches the poll [pollId]. + Future> getPoll(String pollId) async { + final result = await _api.getPoll(pollId: pollId); + + return result.map((response) => response.toModel()); + } + + /// Replaces the settings and options of [poll]. + /// + /// Every option must have an id; without one the call fails without a request. + Future> updatePoll(Poll poll) async { + final request = poll.toUpdatePollRequest(); + if (request == null) return const Result.failure(StreamClientException(message: _optionsWithoutIdMessage)); + + final result = await _api.updatePoll(updatePollRequest: request); + + return result.map((response) => response.toModel()); + } + + /// Sets the fields in [set] and removes the fields named in [unset] on the poll [pollId]. + Future> partialUpdatePoll( + String pollId, { + Map? set, + List? unset, + }) async { + final result = await _api.updatePollPartial( + pollId: pollId, + updatePollPartialRequest: api.UpdatePollPartialRequest(set: set, unset: unset), + ); + + return result.map((response) => response.toModel()); + } + + /// Deletes the poll [pollId]. + Future> deletePoll(String pollId) async { + final result = await _api.deletePoll(pollId: pollId); + + return result.ignoreValue(); + } + + /// Adds [option] to the poll [pollId]. + Future> createPollOption(String pollId, PollOption option) async { + final result = await _api.createPollOption( + pollId: pollId, + createPollOptionRequest: option.toCreatePollOptionRequest(), + ); + + return result.map((response) => response.toModel()); + } + + /// Fetches the option [optionId] of the poll [pollId]. + Future> getPollOption(String pollId, String optionId) async { + final result = await _api.getPollOption(pollId: pollId, optionId: optionId); + + return result.map((response) => response.toModel()); + } + + /// Replaces the text and custom data of [option] in the poll [pollId]. + /// + /// The [option] must have an id; without one the call fails without a request. + Future> updatePollOption(String pollId, PollOption option) async { + final request = option.toUpdatePollOptionRequest(); + if (request == null) return const Result.failure(StreamClientException(message: _optionWithoutIdMessage)); + + final result = await _api.updatePollOption(pollId: pollId, updatePollOptionRequest: request); + + return result.map((response) => response.toModel()); + } + + /// Removes the option [optionId] from the poll [pollId]. + Future> deletePollOption(String pollId, String optionId) async { + final result = await _api.deletePollOption(pollId: pollId, optionId: optionId); + + return result.ignoreValue(); + } + + /// Casts a vote for the option [optionId] of the poll [pollId], sent in the message [messageId]. + Future> castPollVote( + String messageId, + String pollId, { + required String optionId, + }) => _castPollVote(messageId, pollId, api.VoteData(optionId: optionId)); + + /// Leaves [answerText] as an answer on the poll [pollId], sent in the message [messageId]. + Future> addPollAnswer( + String messageId, + String pollId, { + required String answerText, + }) => _castPollVote(messageId, pollId, api.VoteData(answerText: answerText)); + + Future> _castPollVote(String messageId, String pollId, api.VoteData vote) async { + final result = await _api.castPollVote( + messageId: messageId, + pollId: pollId, + castPollVoteRequest: api.CastPollVoteRequest(vote: vote), + ); + + return result.map((response) => response.toModel()); + } + + /// Removes the vote or answer [voteId] from the poll [pollId], sent in the message [messageId]. + Future> removePollVote(String messageId, String pollId, String voteId) async { + final result = await _api.deletePollVote(messageId: messageId, pollId: pollId, voteId: voteId); + + return result.map((response) => response.toModel()); + } + + /// Fetches one page of the polls matching [filter], ordered by [sort]. + /// + /// [next] and [prev] are the cursors a previous page returned; pass at most one of them. + Future> queryPolls({ + PollFilter? filter, + List? sort, + int? limit, + String? next, + String? prev, + }) async { + final result = await _api.queryPolls( + queryPollsRequest: api.QueryPollsRequest( + filter: filter?.toJson(), + sort: sort?.map(_sortParam).toList(), + limit: limit, + next: next, + prev: prev, + ), + ); + + return result.map((response) => response.toModel()); + } + + /// Fetches one page of the votes and answers of the poll [pollId] matching [filter], ordered by [sort]. + /// + /// [next] and [prev] are the cursors a previous page returned; pass at most one of them. + Future> queryPollVotes( + String pollId, { + PollVoteFilter? filter, + List? sort, + int? limit, + String? next, + String? prev, + }) async { + final result = await _api.queryPollVotes( + pollId: pollId, + queryPollVotesRequest: api.QueryPollVotesRequest( + filter: filter?.toJson(), + sort: sort?.map(_sortParam).toList(), + limit: limit, + next: next, + prev: prev, + ), + ); + + return result.map((response) => response.toModel()); + } +} + +api.SortParamRequest _sortParam(Sort sort) { + final json = sort.toJson(); + return api.SortParamRequest(field: json['field'] as String?, direction: json['direction'] as int?); +} + +const _optionWithoutIdMessage = 'The poll option has no id. Only an option that was already created can be updated.'; + +const _optionsWithoutIdMessage = + 'An option of the poll has no id. Every option of an updated poll must already exist; add new options with ' + '`createPollOption`.'; diff --git a/packages/stream_chat/lib/src/ws/events/event.dart b/packages/stream_chat/lib/src/ws/events/event.dart index 7f505a8672..42a85cf6d5 100644 --- a/packages/stream_chat/lib/src/ws/events/event.dart +++ b/packages/stream_chat/lib/src/ws/events/event.dart @@ -1,6 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; import '../../../stream_chat.dart'; +import '../../core/models/converters/v1_json_converters.dart'; import '../../core/util/serializer.dart'; part 'event.g.dart'; @@ -97,9 +98,11 @@ class Event extends WsEvent { final Message? message; /// The poll sent with the event + @PollV1JsonConverter() final Poll? poll; /// The poll vote sent with the event + @PollVoteV1JsonConverter() final PollVote? pollVote; /// The channel sent with the event diff --git a/packages/stream_chat/lib/src/ws/events/event.g.dart b/packages/stream_chat/lib/src/ws/events/event.g.dart index fea0eb682b..076e043ca9 100644 --- a/packages/stream_chat/lib/src/ws/events/event.g.dart +++ b/packages/stream_chat/lib/src/ws/events/event.g.dart @@ -15,8 +15,11 @@ Event _$EventFromJson(Map json) => Event( me: json['me'] == null ? null : OwnUser.fromJson(json['me'] as Map), user: json['user'] == null ? null : User.fromJson(json['user'] as Map), message: json['message'] == null ? null : Message.fromJson(json['message'] as Map), - poll: json['poll'] == null ? null : Poll.fromJson(json['poll'] as Map), - pollVote: json['poll_vote'] == null ? null : PollVote.fromJson(json['poll_vote'] as Map), + poll: _$JsonConverterFromJson, Poll>(json['poll'], const PollV1JsonConverter().fromJson), + pollVote: _$JsonConverterFromJson, PollVote>( + json['poll_vote'], + const PollVoteV1JsonConverter().fromJson, + ), totalUnreadCount: (json['total_unread_count'] as num?)?.toInt(), unreadChannels: (json['unread_channels'] as num?)?.toInt(), reaction: json['reaction'] == null ? null : Reaction.fromJson(json['reaction'] as Map), @@ -31,11 +34,7 @@ Event _$EventFromJson(Map json) => Event( parentId: json['parent_id'] as String?, hardDelete: json['hard_delete'] as bool?, deletedForMe: json['deleted_for_me'] as bool?, - aiState: $enumDecodeNullable( - _$AITypingStateEnumMap, - json['ai_state'], - unknownValue: AITypingState.idle, - ), + aiState: $enumDecodeNullable(_$AITypingStateEnumMap, json['ai_state'], unknownValue: AITypingState.idle), aiMessage: json['ai_message'] as String?, messageId: json['message_id'] as String?, thread: json['thread'] == null ? null : Thread.fromJson(json['thread'] as Map), @@ -48,14 +47,10 @@ Event _$EventFromJson(Map json) => Event( reminder: json['reminder'] == null ? null : MessageReminder.fromJson(json['reminder'] as Map), pushPreference: json['push_preference'] == null ? null - : PushPreference.fromJson( - json['push_preference'] as Map, - ), + : PushPreference.fromJson(json['push_preference'] as Map), channelPushPreference: json['channel_push_preference'] == null ? null - : ChannelPushPreference.fromJson( - json['channel_push_preference'] as Map, - ), + : ChannelPushPreference.fromJson(json['channel_push_preference'] as Map), channelMemberCount: (json['channel_member_count'] as num?)?.toInt(), channelMessageCount: (json['channel_message_count'] as num?)?.toInt(), watcherCount: (json['watcher_count'] as num?)?.toInt(), @@ -77,8 +72,11 @@ Map _$EventToJson(Event instance) => { 'me': ?instance.me?.toJson(), 'user': ?instance.user?.toJson(), 'message': ?instance.message?.toJson(), - 'poll': ?instance.poll?.toJson(), - 'poll_vote': ?instance.pollVote?.toJson(), + 'poll': ?_$JsonConverterToJson, Poll>(instance.poll, const PollV1JsonConverter().toJson), + 'poll_vote': ?_$JsonConverterToJson, PollVote>( + instance.pollVote, + const PollVoteV1JsonConverter().toJson, + ), 'channel': ?instance.channel?.toJson(), 'member': ?instance.member?.toJson(), 'reaction': ?instance.reaction?.toJson(), @@ -110,6 +108,9 @@ Map _$EventToJson(Event instance) => { 'extra_data': instance.extraData, }; +Value? _$JsonConverterFromJson(Object? json, Value? Function(Json json) fromJson) => + json == null ? null : fromJson(json as Json); + const _$AITypingStateEnumMap = { AITypingState.idle: 'AI_STATE_IDLE', AITypingState.error: 'AI_STATE_ERROR', @@ -117,3 +118,6 @@ const _$AITypingStateEnumMap = { AITypingState.thinking: 'AI_STATE_THINKING', AITypingState.generating: 'AI_STATE_GENERATING', }; + +Json? _$JsonConverterToJson(Value? value, Json? Function(Value value) toJson) => + value == null ? null : toJson(value); diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index 65c2364419..51388439c5 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -128,6 +128,11 @@ export 'src/core/models/response/list_devices_response.dart'; export 'src/core/models/response/list_user_groups_response.dart'; export 'src/core/models/response/mute_users_response.dart'; export 'src/core/models/response/og_attachment_response.dart'; +export 'src/core/models/response/poll_option_response.dart'; +export 'src/core/models/response/poll_response.dart'; +export 'src/core/models/response/poll_vote_response.dart'; +export 'src/core/models/response/query_poll_votes_response.dart'; +export 'src/core/models/response/query_polls_response.dart'; export 'src/core/models/response/remove_user_group_members_response.dart'; export 'src/core/models/response/search_roles_response.dart'; export 'src/core/models/response/search_user_groups_response.dart'; @@ -143,6 +148,7 @@ export 'src/core/models/user.dart'; export 'src/core/models/user_block.dart'; export 'src/core/models/user_group.dart'; export 'src/core/models/user_group_member.dart'; +export 'src/core/models/voting_visibility.dart'; export 'src/core/util/extension.dart'; export 'src/core/util/message_rules.dart'; export 'src/db/chat_persistence_client.dart'; diff --git a/packages/stream_chat/test/src/client/channel/channel_polls_test.dart b/packages/stream_chat/test/src/client/channel/channel_polls_test.dart new file mode 100644 index 0000000000..4bb6cc478c --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_polls_test.dart @@ -0,0 +1,332 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../../fakes.dart'; +import '../../mocks.dart'; +import '../../utils.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; + +void main() { + test('Channel.sendPoll creates the poll and sends it in a message', () async { + final client = _client(); + final channel = _initializedChannel(client); + final createdPoll = _poll.copyWith(id: 'created-poll-id'); + when(() => client.createPoll(_poll)).thenAnswer((_) async => Result.success(_pollResponse(createdPoll))); + when( + () => client.sendMessage(any(), _channelId, _channelType), + ).thenAnswer( + (invocation) async => SendMessageResponse()..message = invocation.positionalArguments.first as Message, + ); + + final result = await channel.sendPoll(_poll, messageText: 'Vote please'); + + final sent = verify(() => client.sendMessage(captureAny(), _channelId, _channelType)).captured.single as Message; + expect(sent.text, 'Vote please'); + expect(sent.pollId, 'created-poll-id'); + expect(sent.poll, createdPoll); + expect(result.getOrNull()?.message.id, sent.id); + }); + + test('Channel.sendPoll returns the failure to create the poll without sending a message', () async { + final client = _client(); + final channel = _initializedChannel(client); + const error = StreamClientException(message: 'boom'); + when(() => client.createPoll(_poll)).thenAnswer((_) async => const Result.failure(error)); + + final result = await channel.sendPoll(_poll); + + expect(result.exceptionOrNull(), error); + verifyNever(() => client.sendMessage(any(), any(), any())); + }); + + test('Channel.sendPoll returns the failure to send the message without throwing', () async { + final client = _client(); + final channel = _initializedChannel(client); + final error = apiException(code: StreamErrorCode.notAllowed, statusCode: 403); + when(() => client.createPoll(_poll)).thenAnswer((_) async => Result.success(_pollResponse(_poll))); + when(() => client.sendMessage(any(), _channelId, _channelType)).thenThrow(error); + + final result = await channel.sendPoll(_poll); + + expect(result.exceptionOrNull(), error); + }); + + test('Channel.updatePoll returns the updated poll', () async { + final client = _client(); + final channel = _initializedChannel(client); + when(() => client.updatePoll(_poll)).thenAnswer((_) async => Result.success(_pollResponse(_poll))); + + final result = await channel.updatePoll(_poll); + + expect(result, Result.success(_pollResponse(_poll))); + }); + + test('Channel.deletePoll deletes the poll and returns a success', () async { + final client = _client(); + final channel = _initializedChannel(client); + when(() => client.deletePoll(_poll.id)).thenAnswer((_) async => const Result.success(null)); + + final result = await channel.deletePoll(_poll); + + expect(result, const Result.success(null)); + }); + + test('Channel.closePoll closes the poll and returns it', () async { + final client = _client(); + final channel = _initializedChannel(client); + final closed = _poll.copyWith(isClosed: true); + when(() => client.closePoll(_poll.id)).thenAnswer((_) async => Result.success(_pollResponse(closed))); + + final result = await channel.closePoll(_poll); + + expect(result, Result.success(_pollResponse(closed))); + }); + + test('Channel.createPollOption adds the option to the poll and returns it', () async { + final client = _client(); + final channel = _initializedChannel(client); + const option = PollOption(text: 'Sushi'); + const response = PollOptionResponse( + duration: '4.21ms', + pollOption: PollOption(id: 'sushi', text: 'Sushi'), + ); + when(() => client.createPollOption(_poll.id, option)).thenAnswer((_) async => const Result.success(response)); + + final result = await channel.createPollOption(_poll, option); + + expect(result, const Result.success(response)); + }); + + test('Channel.castPollVote votes for the option and returns the vote', () async { + final client = _client(); + final channel = _initializedChannel(client); + final response = PollVoteResponse(duration: '4.21ms', vote: _vote); + when( + () => client.castPollVote(_message.id, _poll.id, optionId: 'pizza'), + ).thenAnswer((_) async => Result.success(response)); + + final result = await channel.castPollVote(_message, _poll, const PollOption(id: 'pizza', text: 'Pizza')); + + expect(result, Result.success(response)); + }); + + test('Channel.castPollVote returns a failure without voting when the option has no id', () async { + final client = _client(); + final channel = _initializedChannel(client); + + final result = await channel.castPollVote(_message, _poll, const PollOption(text: 'Pizza')); + + expect(result.exceptionOrNull(), isA()); + verifyNever(() => client.castPollVote(any(), any(), optionId: any(named: 'optionId'))); + }); + + test('Channel.addPollAnswer leaves the answer and returns it', () async { + final client = _client(); + final channel = _initializedChannel(client); + final response = PollVoteResponse(duration: '4.21ms', vote: _vote); + when( + () => client.addPollAnswer(_message.id, _poll.id, answerText: 'Anything'), + ).thenAnswer((_) async => Result.success(response)); + + final result = await channel.addPollAnswer(_message, _poll, answerText: 'Anything'); + + expect(result, Result.success(response)); + }); + + test('Channel.removePollVote removes the vote and returns it', () async { + final client = _client(); + final channel = _initializedChannel(client); + final response = PollVoteResponse(duration: '4.21ms', vote: _vote); + when( + () => client.removePollVote(_message.id, _poll.id, 'vote-id'), + ).thenAnswer((_) async => Result.success(response)); + + final result = await channel.removePollVote(_message, _poll, _vote); + + expect(result, Result.success(response)); + }); + + test('Channel.removePollVote returns a failure without removing anything when the vote has no id', () async { + final client = _client(); + final channel = _initializedChannel(client); + + final result = await channel.removePollVote(_message, _poll, PollVote(optionId: 'pizza')); + + expect(result.exceptionOrNull(), isA()); + verifyNever(() => client.removePollVote(any(), any(), any())); + }); + + test('Channel.queryPollVotes forwards the query and returns the matching votes', () async { + final client = _client(); + final channel = _initializedChannel(client); + final filter = Filter.equal(PollVoteFilterField.isAnswer, true); + final sort = [PollVoteSort.desc(PollVoteSortField.createdAt)]; + final response = QueryPollVotesResponse(duration: '4.21ms', votes: [_vote], next: 'after'); + when( + () => client.queryPollVotes(_poll.id, filter: filter, sort: sort, limit: 5, next: 'cursor'), + ).thenAnswer((_) async => Result.success(response)); + + final result = await channel.queryPollVotes(_poll.id, filter: filter, sort: sort, limit: 5, next: 'cursor'); + + expect(result, Result.success(response)); + }); + + test('Channel.queryPollVotes asks for ten votes when no limit is given', () async { + final client = _client(); + final channel = _initializedChannel(client); + const response = QueryPollVotesResponse(duration: '4.21ms', votes: []); + when(() => client.queryPollVotes(_poll.id, limit: 10)).thenAnswer((_) async => const Result.success(response)); + + final result = await channel.queryPollVotes(_poll.id); + + expect(result, const Result.success(response)); + }); + + test('Channel.sendPoll reports an unexpected error sending the message as a client failure', () async { + final client = _client(); + final channel = _initializedChannel(client); + final thrown = StateError('unexpected'); + when(() => client.createPoll(_poll)).thenAnswer((_) async => Result.success(_pollResponse(_poll))); + when(() => client.sendMessage(any(), _channelId, _channelType)).thenThrow(thrown); + + final result = await channel.sendPoll(_poll); + + expect(result.exceptionOrNull(), isA().having((it) => it.cause, 'cause', thrown)); + }); + + test('Channel.updatePoll returns the failure without throwing', () async { + final client = _client(); + final channel = _initializedChannel(client); + when(() => client.updatePoll(_poll)).thenAnswer((_) async => const Result.failure(_error)); + + final result = await channel.updatePoll(_poll); + + expect(result.exceptionOrNull(), _error); + }); + + test('Channel.deletePoll returns the failure without throwing', () async { + final client = _client(); + final channel = _initializedChannel(client); + when(() => client.deletePoll(_poll.id)).thenAnswer((_) async => const Result.failure(_error)); + + final result = await channel.deletePoll(_poll); + + expect(result.exceptionOrNull(), _error); + }); + + test('Channel.closePoll returns the failure without throwing', () async { + final client = _client(); + final channel = _initializedChannel(client); + when(() => client.closePoll(_poll.id)).thenAnswer((_) async => const Result.failure(_error)); + + final result = await channel.closePoll(_poll); + + expect(result.exceptionOrNull(), _error); + }); + + test('Channel.createPollOption returns the failure without throwing', () async { + final client = _client(); + final channel = _initializedChannel(client); + const option = PollOption(text: 'Sushi'); + when(() => client.createPollOption(_poll.id, option)).thenAnswer((_) async => const Result.failure(_error)); + + final result = await channel.createPollOption(_poll, option); + + expect(result.exceptionOrNull(), _error); + }); + + test('Channel.castPollVote returns the failure without throwing', () async { + final client = _client(); + final channel = _initializedChannel(client); + when( + () => client.castPollVote(_message.id, _poll.id, optionId: 'pizza'), + ).thenAnswer((_) async => const Result.failure(_error)); + + final result = await channel.castPollVote(_message, _poll, const PollOption(id: 'pizza', text: 'Pizza')); + + expect(result.exceptionOrNull(), _error); + }); + + test('Channel.addPollAnswer returns the failure without throwing', () async { + final client = _client(); + final channel = _initializedChannel(client); + when( + () => client.addPollAnswer(_message.id, _poll.id, answerText: 'Anything'), + ).thenAnswer((_) async => const Result.failure(_error)); + + final result = await channel.addPollAnswer(_message, _poll, answerText: 'Anything'); + + expect(result.exceptionOrNull(), _error); + }); + + test('Channel.removePollVote returns the failure without throwing', () async { + final client = _client(); + final channel = _initializedChannel(client); + when( + () => client.removePollVote(_message.id, _poll.id, 'vote-id'), + ).thenAnswer((_) async => const Result.failure(_error)); + + final result = await channel.removePollVote(_message, _poll, _vote); + + expect(result.exceptionOrNull(), _error); + }); + + test('Channel.queryPollVotes returns the failure without throwing', () async { + final client = _client(); + final channel = _initializedChannel(client); + when(() => client.queryPollVotes(_poll.id, limit: 10)).thenAnswer((_) async => const Result.failure(_error)); + + final result = await channel.queryPollVotes(_poll.id); + + expect(result.exceptionOrNull(), _error); + }); +} + +const _error = StreamClientException(message: 'boom'); + +final _poll = Poll( + id: 'poll-id', + name: 'Lunch?', + options: const [PollOption(id: 'pizza', text: 'Pizza')], + createdAt: DateTime.utc(2024), + updatedAt: DateTime.utc(2024), +); + +final _message = Message(id: 'message-id', pollId: 'poll-id'); + +final _vote = PollVote( + id: 'vote-id', + pollId: 'poll-id', + optionId: 'pizza', + createdAt: DateTime.utc(2024), + updatedAt: DateTime.utc(2024), +); + +PollResponse _pollResponse(Poll poll) => PollResponse(duration: '4.21ms', poll: poll); + +MockStreamChatClient _client() { + registerFallbackValue(FakeMessage()); + + final client = MockStreamChatClient(); + when(() => client.retryPolicy).thenReturn( + RetryPolicy(shouldRetry: (_, _, _) => false, delayFactor: Duration.zero), + ); + when(() => client.state).thenReturn(FakeClientState()); + when(() => client.moderation).thenReturn(MockModerationClient()); + when(() => client.channelDeliveryReporter.submitForDelivery(any())).thenAnswer((_) async {}); + return client; +} + +Channel _initializedChannel(StreamChatClient client) { + final channel = Channel.fromState( + client, + ChannelState( + channel: ChannelModel(id: _channelId, type: _channelType), + ), + ); + addTearDown(channel.dispose); + return channel; +} diff --git a/packages/stream_chat/test/src/client/client_poll_options_test.dart b/packages/stream_chat/test/src/client/client_poll_options_test.dart new file mode 100644 index 0000000000..50b26a0962 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_poll_options_test.dart @@ -0,0 +1,159 @@ +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 'poll_fixtures.dart'; + +void main() { + test( + 'StreamChatClient.createPollOption sends the option text and custom data and returns the created option', + () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.createPollOption( + pollId: 'poll-id', + createPollOptionRequest: const api.CreatePollOptionRequest(text: 'Pizza', custom: {'color': 'red'}), + ), + ).thenAnswer((_) async => const Result.success(_generatedPizzaResponse)); + final client = pollsClient(defaultApi); + + final result = await client.createPollOption( + 'poll-id', + const PollOption(text: 'Pizza', extraData: {'color': 'red'}), + ); + + expect(result, const Result.success(_pizzaResponse)); + }, + ); + + test('StreamChatClient.createPollOption returns the failure without throwing', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.createPollOption( + pollId: 'poll-id', + createPollOptionRequest: any(named: 'createPollOptionRequest'), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + final client = pollsClient(defaultApi); + + final result = await client.createPollOption('poll-id', const PollOption(text: 'Pizza')); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.getPollOption sends the poll and option ids and returns the option', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.getPollOption(pollId: 'poll-id', optionId: 'pizza'), + ).thenAnswer((_) async => const Result.success(_generatedPizzaResponse)); + final client = pollsClient(defaultApi); + + final result = await client.getPollOption('poll-id', 'pizza'); + + expect(result, const Result.success(_pizzaResponse)); + }); + + test('StreamChatClient.getPollOption returns the failure without throwing', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.getPollOption(pollId: 'poll-id', optionId: 'pizza'), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + final client = pollsClient(defaultApi); + + final result = await client.getPollOption('poll-id', 'pizza'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test( + "StreamChatClient.getPollOption keeps custom fields named like the option's own fields out of its custom data", + () async { + final defaultApi = pollsDefaultApi(); + final response = api.PollOptionResponse( + duration: '4.21ms', + pollOption: generatedPizza.copyWith(custom: const {'color': 'red', 'text': 'custom-text'}), + ); + when( + () => defaultApi.getPollOption(pollId: 'poll-id', optionId: 'pizza'), + ).thenAnswer((_) async => Result.success(response)); + final client = pollsClient(defaultApi); + + final result = await client.getPollOption('poll-id', 'pizza'); + + expect(result.getOrNull()?.pollOption.extraData, const {'color': 'red'}); + }, + ); + + test('StreamChatClient.updatePollOption sends the option and returns the updated option', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.updatePollOption( + pollId: 'poll-id', + updatePollOptionRequest: const api.UpdatePollOptionRequest( + id: 'pizza', + text: 'Pizza', + custom: {'color': 'red'}, + ), + ), + ).thenAnswer((_) async => const Result.success(_generatedPizzaResponse)); + final client = pollsClient(defaultApi); + + final result = await client.updatePollOption('poll-id', pizza); + + expect(result, const Result.success(_pizzaResponse)); + }); + + test('StreamChatClient.updatePollOption returns a failure without a request when the option has no id', () async { + final defaultApi = pollsDefaultApi(); + final client = pollsClient(defaultApi); + + final result = await client.updatePollOption('poll-id', const PollOption(text: 'Pizza')); + + expect(result.exceptionOrNull(), isA()); + verifyZeroInteractions(defaultApi); + }); + + test('StreamChatClient.updatePollOption returns the failure without throwing', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.updatePollOption( + pollId: 'poll-id', + updatePollOptionRequest: any(named: 'updatePollOptionRequest'), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + final client = pollsClient(defaultApi); + + final result = await client.updatePollOption('poll-id', pizza); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.deletePollOption sends the poll and option ids and returns a success', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.deletePollOption(pollId: 'poll-id', optionId: 'pizza'), + ).thenAnswer((_) async => const Result.success(api.DurationResponse(duration: '4.21ms'))); + final client = pollsClient(defaultApi); + + final result = await client.deletePollOption('poll-id', 'pizza'); + + expect(result, const Result.success(null)); + }); + + test('StreamChatClient.deletePollOption returns the failure without throwing', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.deletePollOption(pollId: 'poll-id', optionId: 'pizza'), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + final client = pollsClient(defaultApi); + + final result = await client.deletePollOption('poll-id', 'pizza'); + + expect(result.exceptionOrNull(), pollsApiError); + }); +} + +const _generatedPizzaResponse = api.PollOptionResponse(duration: '4.21ms', pollOption: generatedPizza); + +const _pizzaResponse = PollOptionResponse(duration: '4.21ms', pollOption: pizza); diff --git a/packages/stream_chat/test/src/client/client_poll_votes_test.dart b/packages/stream_chat/test/src/client/client_poll_votes_test.dart new file mode 100644 index 0000000000..f504689fbe --- /dev/null +++ b/packages/stream_chat/test/src/client/client_poll_votes_test.dart @@ -0,0 +1,183 @@ +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 'poll_fixtures.dart'; + +void main() { + test('StreamChatClient.castPollVote sends the selected option and returns the cast vote', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.castPollVote( + messageId: 'message-id', + pollId: 'poll-id', + castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(optionId: 'pizza')), + ), + ).thenAnswer( + (_) async => Result.success(api.PollVoteResponse(duration: '4.21ms', poll: generatedPoll, vote: generatedVote)), + ); + final client = pollsClient(defaultApi); + + final result = await client.castPollVote('message-id', 'poll-id', optionId: 'pizza'); + + expect(result, Result.success(PollVoteResponse(duration: '4.21ms', vote: vote))); + }); + + test('StreamChatClient.castPollVote returns the failure without throwing', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.castPollVote( + messageId: 'message-id', + pollId: 'poll-id', + castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(optionId: 'pizza')), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + final client = pollsClient(defaultApi); + + final result = await client.castPollVote('message-id', 'poll-id', optionId: 'pizza'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.castPollVote returns no vote when the response carries none', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.castPollVote( + messageId: 'message-id', + pollId: 'poll-id', + castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(optionId: 'pizza')), + ), + ).thenAnswer((_) async => const Result.success(api.PollVoteResponse(duration: '4.21ms'))); + final client = pollsClient(defaultApi); + + final result = await client.castPollVote('message-id', 'poll-id', optionId: 'pizza'); + + expect(result, const Result.success(PollVoteResponse(duration: '4.21ms'))); + }); + + test('StreamChatClient.addPollAnswer sends the answer text and returns the answer', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.castPollVote( + messageId: 'message-id', + pollId: 'poll-id', + castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(answerText: 'Anything')), + ), + ).thenAnswer( + (_) async => Result.success(api.PollVoteResponse(duration: '4.21ms', poll: generatedPoll, vote: generatedAnswer)), + ); + final client = pollsClient(defaultApi); + + final result = await client.addPollAnswer('message-id', 'poll-id', answerText: 'Anything'); + + expect(result, Result.success(PollVoteResponse(duration: '4.21ms', vote: answer))); + }); + + test('StreamChatClient.addPollAnswer returns the failure without throwing', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.castPollVote( + messageId: 'message-id', + pollId: 'poll-id', + castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(answerText: 'Anything')), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + final client = pollsClient(defaultApi); + + final result = await client.addPollAnswer('message-id', 'poll-id', answerText: 'Anything'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.removePollVote sends the vote id and returns the removed vote', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.deletePollVote(messageId: 'message-id', pollId: 'poll-id', voteId: 'vote-id'), + ).thenAnswer( + (_) async => Result.success(api.PollVoteResponse(duration: '4.21ms', poll: generatedPoll, vote: generatedVote)), + ); + final client = pollsClient(defaultApi); + + final result = await client.removePollVote('message-id', 'poll-id', 'vote-id'); + + expect(result, Result.success(PollVoteResponse(duration: '4.21ms', vote: vote))); + }); + + test('StreamChatClient.removePollVote returns the failure without throwing', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.deletePollVote(messageId: 'message-id', pollId: 'poll-id', voteId: 'vote-id'), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + final client = pollsClient(defaultApi); + + final result = await client.removePollVote('message-id', 'poll-id', 'vote-id'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.queryPollVotes sends the filter, sort and cursor and returns the matching votes', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.queryPollVotes( + pollId: 'poll-id', + queryPollVotesRequest: const api.QueryPollVotesRequest( + filter: { + 'is_answer': {r'$eq': true}, + }, + sort: [api.SortParamRequest(field: 'created_at', direction: 1)], + limit: 25, + prev: 'prev-cursor', + ), + ), + ).thenAnswer( + (_) async => Result.success( + api.PollVotesResponse(duration: '4.21ms', votes: [generatedVote, generatedAnswer], next: 'after', prev: 'x'), + ), + ); + final client = pollsClient(defaultApi); + + final result = await client.queryPollVotes( + 'poll-id', + filter: Filter.equal(PollVoteFilterField.isAnswer, true), + sort: [PollVoteSort.asc(PollVoteSortField.createdAt)], + limit: 25, + prev: 'prev-cursor', + ); + + expect( + result, + Result.success(QueryPollVotesResponse(duration: '4.21ms', votes: [vote, answer], next: 'after', prev: 'x')), + ); + }); + + test('StreamChatClient.queryPollVotes returns the failure without throwing', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.queryPollVotes( + pollId: 'poll-id', + queryPollVotesRequest: const api.QueryPollVotesRequest(limit: 10), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + final client = pollsClient(defaultApi); + + final result = await client.queryPollVotes('poll-id'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.queryPollVotes sends ten as the default limit', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.queryPollVotes( + pollId: 'poll-id', + queryPollVotesRequest: const api.QueryPollVotesRequest(limit: 10), + ), + ).thenAnswer((_) async => const Result.success(api.PollVotesResponse(duration: '4.21ms', votes: []))); + final client = pollsClient(defaultApi); + + final result = await client.queryPollVotes('poll-id'); + + expect(result, const Result.success(QueryPollVotesResponse(duration: '4.21ms', votes: []))); + }); +} diff --git a/packages/stream_chat/test/src/client/client_polls_test.dart b/packages/stream_chat/test/src/client/client_polls_test.dart new file mode 100644 index 0000000000..e937d36c60 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_polls_test.dart @@ -0,0 +1,337 @@ +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 'poll_fixtures.dart'; + +void main() { + test('StreamChatClient.createPoll sends the poll settings and returns the created poll', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.createPoll(createPollRequest: any(named: 'createPollRequest')), + ).thenAnswer((_) async => Result.success(generatedPollResponse)); + final client = pollsClient(defaultApi); + + final result = await client.createPoll(_newPoll()); + + final request = verify( + () => defaultApi.createPoll(createPollRequest: captureAny(named: 'createPollRequest')), + ).captured.single; + expect( + request, + const api.CreatePollRequest( + id: 'poll-id', + name: 'Lunch?', + description: 'Pick one', + options: [ + api.PollOptionInput(text: 'Pizza', custom: {'color': 'red'}), + api.PollOptionInput(text: 'Sushi', custom: {}), + ], + votingVisibility: api.CreatePollRequestVotingVisibility.anonymous, + enforceUniqueVote: false, + maxVotesAllowed: 2, + allowAnswers: true, + allowUserSuggestedOptions: true, + isClosed: false, + custom: {'topic': 'food'}, + ), + ); + expect(result, Result.success(pollResponse)); + }); + + test('StreamChatClient.createPoll returns the failure without throwing', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.createPoll(createPollRequest: any(named: 'createPollRequest')), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + final client = pollsClient(defaultApi); + + final result = await client.createPoll(_newPoll()); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.getPoll sends the poll id and returns the poll', () async { + final defaultApi = pollsDefaultApi(); + when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(generatedPollResponse)); + final client = pollsClient(defaultApi); + + final result = await client.getPoll('poll-id'); + + expect(result, Result.success(pollResponse)); + }); + + test('StreamChatClient.getPoll returns the failure without throwing', () async { + final defaultApi = pollsDefaultApi(); + when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => const Result.failure(pollsApiError)); + final client = pollsClient(defaultApi); + + final result = await client.getPoll('poll-id'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test( + "StreamChatClient.getPoll keeps custom fields named like the poll's own fields out of its custom data", + () async { + final defaultApi = pollsDefaultApi(); + final response = api.PollResponse( + duration: '4.21ms', + poll: generatedPoll.copyWith(custom: const {'topic': 'food', 'name': 'custom-name', 'own_votes': 'custom'}), + ); + when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(response)); + final client = pollsClient(defaultApi); + + final result = await client.getPoll('poll-id'); + + expect(result.getOrNull()?.poll.extraData, const {'topic': 'food'}); + }, + ); + + test('StreamChatClient.getPoll keeps a voting visibility the SDK does not name', () async { + final defaultApi = pollsDefaultApi(); + final response = api.PollResponse( + duration: '4.21ms', + poll: generatedPoll.copyWith(votingVisibility: api.PollResponseDataVotingVisibility.fromJson('members_only')), + ); + when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(response)); + final client = pollsClient(defaultApi); + + final result = await client.getPoll('poll-id'); + + expect(result.getOrNull()?.poll.votingVisibility, const VotingVisibility('members_only')); + }); + + test('StreamChatClient.getPoll reads a poll that does not say whether it is closed as open', () async { + final defaultApi = pollsDefaultApi(); + final response = api.PollResponse(duration: '4.21ms', poll: generatedPoll.copyWith(isClosed: null)); + when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(response)); + final client = pollsClient(defaultApi); + + final result = await client.getPoll('poll-id'); + + expect(result.getOrNull()?.poll.isClosed, isFalse); + }); + + test('StreamChatClient.updatePoll sends the poll settings and options and returns the updated poll', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.updatePoll(updatePollRequest: any(named: 'updatePollRequest')), + ).thenAnswer((_) async => Result.success(generatedPollResponse)); + final client = pollsClient(defaultApi); + + final result = await client.updatePoll( + _newPoll().copyWith( + options: const [ + PollOption(id: 'pizza', text: 'Pizza', extraData: {'color': 'red'}), + PollOption(id: 'sushi', text: 'Sushi'), + ], + isClosed: true, + ), + ); + + final request = verify( + () => defaultApi.updatePoll(updatePollRequest: captureAny(named: 'updatePollRequest')), + ).captured.single; + expect( + request, + const api.UpdatePollRequest( + id: 'poll-id', + name: 'Lunch?', + description: 'Pick one', + options: [ + api.PollOptionRequest(id: 'pizza', text: 'Pizza', custom: {'color': 'red'}), + api.PollOptionRequest(id: 'sushi', text: 'Sushi', custom: {}), + ], + votingVisibility: api.UpdatePollRequestVotingVisibility.anonymous, + enforceUniqueVote: false, + maxVotesAllowed: 2, + allowAnswers: true, + allowUserSuggestedOptions: true, + isClosed: true, + custom: {'topic': 'food'}, + ), + ); + expect(result, Result.success(pollResponse)); + }); + + test('StreamChatClient.updatePoll returns a failure without a request when an option has no id', () async { + final defaultApi = pollsDefaultApi(); + final client = pollsClient(defaultApi); + + final result = await client.updatePoll(_newPoll()); + + expect(result.exceptionOrNull(), isA()); + verifyZeroInteractions(defaultApi); + }); + + test('StreamChatClient.updatePoll returns the failure without throwing', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.updatePoll(updatePollRequest: any(named: 'updatePollRequest')), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + final client = pollsClient(defaultApi); + + final result = await client.updatePoll(poll); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.partialUpdatePoll sends the fields to set and unset and returns the updated poll', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.updatePollPartial( + pollId: 'poll-id', + updatePollPartialRequest: const api.UpdatePollPartialRequest(set: {'name': 'Dinner?'}, unset: ['description']), + ), + ).thenAnswer((_) async => Result.success(generatedPollResponse)); + final client = pollsClient(defaultApi); + + final result = await client.partialUpdatePoll('poll-id', set: {'name': 'Dinner?'}, unset: ['description']); + + expect(result, Result.success(pollResponse)); + }); + + test('StreamChatClient.partialUpdatePoll returns the failure without throwing', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.updatePollPartial( + pollId: 'poll-id', + updatePollPartialRequest: const api.UpdatePollPartialRequest(set: {'name': 'Dinner?'}), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + final client = pollsClient(defaultApi); + + final result = await client.partialUpdatePoll('poll-id', set: {'name': 'Dinner?'}); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.closePoll marks the poll closed and returns the closed poll', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.updatePollPartial( + pollId: 'poll-id', + updatePollPartialRequest: const api.UpdatePollPartialRequest(set: {'is_closed': true}), + ), + ).thenAnswer((_) async => Result.success(generatedPollResponse)); + final client = pollsClient(defaultApi); + + final result = await client.closePoll('poll-id'); + + expect(result, Result.success(pollResponse)); + }); + + test('StreamChatClient.closePoll returns the failure without throwing', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.updatePollPartial( + pollId: 'poll-id', + updatePollPartialRequest: const api.UpdatePollPartialRequest(set: {'is_closed': true}), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + final client = pollsClient(defaultApi); + + final result = await client.closePoll('poll-id'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.deletePoll sends the poll id and returns a success', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.deletePoll(pollId: 'poll-id'), + ).thenAnswer((_) async => const Result.success(api.DurationResponse(duration: '4.21ms'))); + final client = pollsClient(defaultApi); + + final result = await client.deletePoll('poll-id'); + + expect(result, const Result.success(null)); + }); + + test('StreamChatClient.deletePoll returns the failure without throwing', () async { + final defaultApi = pollsDefaultApi(); + when(() => defaultApi.deletePoll(pollId: 'poll-id')).thenAnswer((_) async => const Result.failure(pollsApiError)); + final client = pollsClient(defaultApi); + + final result = await client.deletePoll('poll-id'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.queryPolls sends the filter, sort and cursor and returns the matching polls', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.queryPolls( + queryPollsRequest: const api.QueryPollsRequest( + filter: { + 'is_closed': {r'$eq': true}, + }, + sort: [api.SortParamRequest(field: 'created_at', direction: -1)], + limit: 10, + next: 'next-cursor', + ), + ), + ).thenAnswer( + (_) async => Result.success( + api.QueryPollsResponse(duration: '4.21ms', polls: [generatedPoll], next: 'after', prev: 'before'), + ), + ); + final client = pollsClient(defaultApi); + + final result = await client.queryPolls( + filter: Filter.equal(PollFilterField.isClosed, true), + sort: [PollSort.desc(PollSortField.createdAt)], + limit: 10, + next: 'next-cursor', + ); + + expect( + result, + Result.success(QueryPollsResponse(duration: '4.21ms', polls: [poll], next: 'after', prev: 'before')), + ); + }); + + test('StreamChatClient.queryPolls sends the previous-page cursor and ten as the default limit', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.queryPolls(queryPollsRequest: const api.QueryPollsRequest(limit: 10, prev: 'prev-cursor')), + ).thenAnswer((_) async => const Result.success(api.QueryPollsResponse(duration: '4.21ms', polls: []))); + final client = pollsClient(defaultApi); + + final result = await client.queryPolls(prev: 'prev-cursor'); + + expect(result, const Result.success(QueryPollsResponse(duration: '4.21ms', polls: []))); + }); + + test('StreamChatClient.queryPolls returns the failure without throwing', () async { + final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.queryPolls(queryPollsRequest: const api.QueryPollsRequest(limit: 10)), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + final client = pollsClient(defaultApi); + + final result = await client.queryPolls(); + + expect(result.exceptionOrNull(), pollsApiError); + }); +} + +// A poll as a caller builds it: options without ids and the vote summary at its defaults. +Poll _newPoll() => Poll( + id: 'poll-id', + name: 'Lunch?', + description: 'Pick one', + options: const [ + PollOption(text: 'Pizza', extraData: {'color': 'red'}), + PollOption(text: 'Sushi'), + ], + votingVisibility: VotingVisibility.anonymous, + enforceUniqueVote: false, + maxVotesAllowed: 2, + allowAnswers: true, + allowUserSuggestedOptions: true, + voteCount: 7, + extraData: const {'topic': 'food'}, +); diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index c3d24bf83f..5b5879c587 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -3387,340 +3387,6 @@ void main() { verifyNoMoreInteractions(fakeChatApi.channel); }); - test('`.createPoll`', () async { - final poll = Poll( - name: 'What is your favorite color?', - options: const [ - PollOption(text: 'Red'), - PollOption(text: 'Blue'), - ], - ); - - when(() => fakeChatApi.polls.createPoll(poll)).thenAnswer( - (_) async => CreatePollResponse()..poll = poll, - ); - - final res = await client.createPoll(poll); - expect(res, isNotNull); - expect(res.poll, poll); - - verify(() => fakeChatApi.polls.createPoll(poll)).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.getPoll`', () async { - const pollId = 'test-poll-id'; - final poll = Poll( - id: pollId, - name: 'What is your favorite color?', - options: const [ - PollOption(text: 'Red'), - PollOption(text: 'Blue'), - ], - ); - - when(() => fakeChatApi.polls.getPoll(pollId)).thenAnswer( - (_) async => GetPollResponse()..poll = poll, - ); - - final res = await client.getPoll(pollId); - expect(res, isNotNull); - expect(res.poll, poll); - - verify(() => fakeChatApi.polls.getPoll(pollId)).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.updatePoll`', () async { - final poll = Poll( - id: 'test-poll-id', - name: 'What is your favorite color?', - options: const [ - PollOption(text: 'Red'), - PollOption(text: 'Blue'), - ], - ); - - when(() => fakeChatApi.polls.updatePoll(poll)).thenAnswer( - (_) async => UpdatePollResponse()..poll = poll, - ); - - final res = await client.updatePoll(poll); - expect(res, isNotNull); - expect(res.poll, poll); - - verify(() => fakeChatApi.polls.updatePoll(poll)).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.partialUpdatePoll`', () async { - const pollId = 'test-poll-id'; - final set = {'name': 'What is your favorite color?'}; - final unset = []; - - final poll = Poll( - id: pollId, - name: set['name']!, - options: const [ - PollOption(text: 'Red'), - PollOption(text: 'Blue'), - ], - ); - - when( - () => fakeChatApi.polls.partialUpdatePoll(pollId, set: set, unset: unset), - ).thenAnswer((_) async => UpdatePollResponse()..poll = poll); - - final res = await client.partialUpdatePoll(pollId, set: set, unset: unset); - expect(res, isNotNull); - expect(res.poll.id, pollId); - expect(res.poll.name, set['name']); - - verify(() => fakeChatApi.polls.partialUpdatePoll(pollId, set: set, unset: unset)).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.deletePoll`', () async { - const pollId = 'test-poll-id'; - - when(() => fakeChatApi.polls.deletePoll(pollId)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.deletePoll(pollId); - expect(res, isNotNull); - - verify(() => fakeChatApi.polls.deletePoll(pollId)).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.closePoll`', () async { - const pollId = 'test-poll-id'; - - when( - () => fakeChatApi.polls.partialUpdatePoll(pollId, set: {'is_closed': true}), - ).thenAnswer((_) async => UpdatePollResponse()); - - final res = await client.closePoll(pollId); - expect(res, isNotNull); - - verify(() => fakeChatApi.polls.partialUpdatePoll(pollId, set: {'is_closed': true})).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.createPollOption`', () async { - const pollId = 'test-poll-id'; - const option = PollOption(text: 'Red'); - - when( - () => fakeChatApi.polls.createPollOption(pollId, option), - ).thenAnswer((_) async => CreatePollOptionResponse()..pollOption = option); - - final res = await client.createPollOption(pollId, option); - expect(res, isNotNull); - expect(res.pollOption, option); - - verify(() => fakeChatApi.polls.createPollOption(pollId, option)).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.getPollOption`', () async { - const pollId = 'test-poll-id'; - const optionId = 'test-option-id'; - const option = PollOption(id: optionId, text: 'Red'); - - when( - () => fakeChatApi.polls.getPollOption(pollId, optionId), - ).thenAnswer((_) async => GetPollOptionResponse()..pollOption = option); - - final res = await client.getPollOption(pollId, optionId); - expect(res, isNotNull); - expect(res.pollOption, option); - - verify(() => fakeChatApi.polls.getPollOption(pollId, optionId)).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.updatePollOption`', () async { - const pollId = 'test-poll-id'; - const option = PollOption(id: 'test-option-id', text: 'Red'); - - when( - () => fakeChatApi.polls.updatePollOption(pollId, option), - ).thenAnswer((_) async => UpdatePollOptionResponse()..pollOption = option); - - final res = await client.updatePollOption(pollId, option); - expect(res, isNotNull); - expect(res.pollOption, option); - - verify(() => fakeChatApi.polls.updatePollOption(pollId, option)).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.deletePollOption`', () async { - const pollId = 'test-poll-id'; - const optionId = 'test-option-id'; - - when(() => fakeChatApi.polls.deletePollOption(pollId, optionId)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.deletePollOption(pollId, optionId); - expect(res, isNotNull); - - verify(() => fakeChatApi.polls.deletePollOption(pollId, optionId)).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.castPollVote`', () async { - const messageId = 'test-message-id'; - const pollId = 'test-poll-id'; - const optionId = 'test-option-id'; - final vote = PollVote(optionId: optionId); - - // Custom matcher to check if the Vote object has the specified id - Matcher matchesVoteOption(String expected) => predicate( - (vote) => vote.optionId == expected, - 'Vote with option $expected', - ); - - when( - () => fakeChatApi.polls.castPollVote(messageId, pollId, any(that: matchesVoteOption(optionId))), - ).thenAnswer((_) async => CastPollVoteResponse()..vote = vote); - - final res = await client.castPollVote(messageId, pollId, optionId: optionId); - expect(res, isNotNull); - expect(res.vote, vote); - - verify(() => fakeChatApi.polls.castPollVote(messageId, pollId, any(that: matchesVoteOption(optionId)))).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.addPollAnswer`', () async { - const messageId = 'test-message-id'; - const pollId = 'test-poll-id'; - const answerText = 'Red'; - final vote = PollVote(answerText: answerText); - - // Custom matcher to check if the Vote object has the specified id - Matcher matchesVoteAnswer(String expected) => predicate( - (vote) => vote.answerText == expected, - 'Vote with answer $expected', - ); - - when( - () => fakeChatApi.polls.castPollVote(messageId, pollId, any(that: matchesVoteAnswer(answerText))), - ).thenAnswer((_) async => CastPollVoteResponse()..vote = vote); - - final res = await client.addPollAnswer(messageId, pollId, answerText: answerText); - expect(res, isNotNull); - expect(res.vote, vote); - - verify( - () => fakeChatApi.polls.castPollVote(messageId, pollId, any(that: matchesVoteAnswer(answerText))), - ).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.removePollVote`', () async { - const messageId = 'test-message-id'; - const pollId = 'test-poll-id'; - const voteId = 'test-vote-id'; - - when( - () => fakeChatApi.polls.removePollVote(messageId, pollId, voteId), - ).thenAnswer((_) async => RemovePollVoteResponse()); - - final res = await client.removePollVote(messageId, pollId, voteId); - expect(res, isNotNull); - - verify(() => fakeChatApi.polls.removePollVote(messageId, pollId, voteId)).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.queryPolls`', () async { - final filter = PollFilter.in_(PollFilterField.id, const ['test-poll-id']); - final sort = [PollSort.desc(PollSortField.createdAt)]; - const pagination = PaginationParams(limit: 20); - - final polls = List.generate( - pagination.limit, - (index) => Poll( - id: 'test-poll-id-$index', - name: 'What is your favorite color?', - options: const [ - PollOption(text: 'Red'), - PollOption(text: 'Blue'), - ], - ), - ); - - when( - () => fakeChatApi.polls.queryPolls( - filter: filter, - sort: sort, - pagination: pagination, - ), - ).thenAnswer( - (_) async => QueryPollsResponse()..polls = polls, - ); - - final res = await client.queryPolls( - filter: filter, - sort: sort, - pagination: pagination, - ); - expect(res, isNotNull); - expect(res.polls.length, polls.length); - - verify( - () => fakeChatApi.polls.queryPolls( - filter: filter, - sort: sort, - pagination: pagination, - ), - ).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.queryPollVotes`', () async { - const pollId = 'test-poll-id'; - final filter = PollVoteFilter.in_(PollVoteFilterField.id, const ['test-vote-id']); - final sort = [PollVoteSort.desc(PollVoteSortField.createdAt)]; - const pagination = PaginationParams(limit: 20); - - final votes = List.generate( - pagination.limit, - (index) => PollVote(id: 'test-vote-id-$index', answerText: 'Red'), - ); - - when( - () => fakeChatApi.polls.queryPollVotes( - pollId, - filter: filter, - sort: sort, - pagination: pagination, - ), - ).thenAnswer( - (_) async => QueryPollVotesResponse()..votes = votes, - ); - - final res = await client.queryPollVotes( - pollId, - filter: filter, - sort: sort, - pagination: pagination, - ); - expect(res, isNotNull); - expect(res.votes.length, votes.length); - - verify( - () => fakeChatApi.polls.queryPollVotes( - pollId, - filter: filter, - sort: sort, - pagination: pagination, - ), - ).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - test('`.updateUser`', () async { final user = User( id: 'test-user-id', diff --git a/packages/stream_chat/test/src/client/poll_fixtures.dart b/packages/stream_chat/test/src/client/poll_fixtures.dart new file mode 100644 index 0000000000..69ae70db62 --- /dev/null +++ b/packages/stream_chat/test/src/client/poll_fixtures.dart @@ -0,0 +1,176 @@ +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'; + +// Fixtures shared by the poll client tests. Every generated object carries a value in every field, including the +// ones the SDK models leave out, so a field the mapping drops or swaps changes the compared object. + +/// A [StreamChatClient] whose generated API is [defaultApi], disposed when the test ends. +StreamChatClient pollsClient(api.DefaultApi defaultApi) { + final client = StreamChatClient('test-api-key', defaultApi: defaultApi); + addTearDown(client.dispose); + return client; +} + +/// A [MockDefaultApi] with the fallback values the poll requests need. +MockDefaultApi pollsDefaultApi() { + registerFallbackValue(const api.CreatePollRequest(name: 'fallback')); + registerFallbackValue(const api.UpdatePollRequest(id: 'fallback', name: 'fallback')); + registerFallbackValue(const api.CreatePollOptionRequest(text: 'fallback')); + registerFallbackValue(const api.UpdatePollOptionRequest(id: 'fallback', text: 'fallback')); + return MockDefaultApi(); +} + +/// A failure the generated API answers with. +const pollsApiError = StreamClientException(message: 'boom'); + +final generatedVoter = api.UserResponse( + avgResponseTime: 12, + banned: false, + blockedUserIds: const ['blocked-user'], + createdAt: DateTime.utc(2024, 1, 1), + custom: const {'favorite_color': 'blue'}, + id: 'luke', + image: 'https://example.com/luke.png', + language: 'en', + lastActive: DateTime.utc(2024, 1, 3), + name: 'Luke', + online: true, + role: 'user', + teams: const ['jedi'], + teamsRole: const {'jedi': 'admin'}, + updatedAt: DateTime.utc(2024, 1, 2), +); + +final voter = User( + id: 'luke', + role: 'user', + name: 'Luke', + image: 'https://example.com/luke.png', + createdAt: DateTime.utc(2024, 1, 1), + updatedAt: DateTime.utc(2024, 1, 2), + lastActive: DateTime.utc(2024, 1, 3), + online: true, + teams: const ['jedi'], + language: 'en', + teamsRole: const {'jedi': 'admin'}, + avgResponseTime: 12, + extraData: const {'favorite_color': 'blue'}, +); + +final generatedVote = api.PollVoteResponseData( + answerTextI18n: const {'fr': 'ignored'}, + createdAt: DateTime.utc(2024, 4, 17, 10), + id: 'vote-id', + isAnswer: false, + optionId: 'pizza', + pollId: 'poll-id', + updatedAt: DateTime.utc(2024, 4, 17, 11), + user: generatedVoter, + userId: 'luke', +); + +final vote = PollVote( + id: 'vote-id', + pollId: 'poll-id', + optionId: 'pizza', + createdAt: DateTime.utc(2024, 4, 17, 10), + updatedAt: DateTime.utc(2024, 4, 17, 11), + userId: 'luke', + user: voter, +); + +// An answer selects no option, which arrives as an empty option id. +final generatedAnswer = api.PollVoteResponseData( + answerText: 'Anything', + answerTextI18n: const {'fr': "N'importe quoi"}, + createdAt: DateTime.utc(2024, 4, 18, 10), + id: 'answer-id', + isAnswer: true, + optionId: '', + pollId: 'poll-id', + updatedAt: DateTime.utc(2024, 4, 18, 11), + user: generatedVoter, + userId: 'luke', +); + +final answer = PollVote( + id: 'answer-id', + pollId: 'poll-id', + optionId: '', + answerText: 'Anything', + createdAt: DateTime.utc(2024, 4, 18, 10), + updatedAt: DateTime.utc(2024, 4, 18, 11), + userId: 'luke', + user: voter, +); + +const generatedPizza = api.PollOptionResponseData( + custom: {'color': 'red'}, + id: 'pizza', + text: 'Pizza', + textI18n: {'it': 'Pizza'}, +); + +const pizza = PollOption(id: 'pizza', text: 'Pizza', extraData: {'color': 'red'}); + +final generatedPoll = api.PollResponseData( + allowAnswers: true, + allowUserSuggestedOptions: true, + answersCount: 1, + createdAt: DateTime.utc(2024, 4, 17), + createdBy: generatedVoter, + createdById: 'luke', + custom: const {'topic': 'food'}, + description: 'Pick one', + descriptionI18n: const {'it': 'Scegline uno'}, + enforceUniqueVote: false, + id: 'poll-id', + isClosed: true, + latestAnswers: [generatedAnswer], + latestVotesByOption: { + 'pizza': [generatedVote], + }, + maxVotesAllowed: 2, + name: 'Lunch?', + nameI18n: const {'it': 'Pranzo?'}, + options: const [generatedPizza], + ownVotes: [generatedVote, generatedAnswer], + updatedAt: DateTime.utc(2024, 4, 18), + voteCount: 1, + voteCountsByOption: const {'pizza': 1}, + votingVisibility: api.PollResponseDataVotingVisibility.anonymous, +); + +final poll = Poll( + id: 'poll-id', + name: 'Lunch?', + description: 'Pick one', + options: const [pizza], + votingVisibility: VotingVisibility.anonymous, + enforceUniqueVote: false, + maxVotesAllowed: 2, + allowAnswers: true, + latestAnswers: [answer], + answersCount: 1, + allowUserSuggestedOptions: true, + isClosed: true, + createdAt: DateTime.utc(2024, 4, 17), + updatedAt: DateTime.utc(2024, 4, 18), + voteCountsByOption: const {'pizza': 1}, + voteCount: 1, + latestVotesByOption: { + 'pizza': [vote], + }, + createdById: 'luke', + createdBy: voter, + ownVotesAndAnswers: [vote, answer], + extraData: const {'topic': 'food'}, +); + +final generatedPollResponse = api.PollResponse(duration: '4.21ms', poll: generatedPoll); + +final pollResponse = PollResponse(duration: '4.21ms', poll: poll); diff --git a/packages/stream_chat/test/src/core/api/polls_api_test.dart b/packages/stream_chat/test/src/core/api/polls_api_test.dart deleted file mode 100644 index 4b893bbef9..0000000000 --- a/packages/stream_chat/test/src/core/api/polls_api_test.dart +++ /dev/null @@ -1,495 +0,0 @@ -import 'dart:convert'; - -import 'package:dio/dio.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat/src/core/api/polls_api.dart'; -import 'package:stream_chat/src/core/api/requests.dart'; -import 'package:stream_chat/src/core/models/poll.dart'; -import 'package:stream_chat/src/core/models/poll_option.dart'; -import 'package:stream_chat/src/core/models/poll_vote.dart'; -import 'package:test/test.dart'; - -import '../../mocks.dart'; - -void main() { - Response successResponse(String path, {Object? data}) => Response( - data: data, - requestOptions: RequestOptions(path: path), - statusCode: 200, - ); - - late final client = MockHttpClient(); - late PollsApi pollsApi; - - setUp(() { - pollsApi = PollsApi(client); - }); - - test('createPoll', () async { - final poll = Poll( - id: 'test-poll-id', - name: 'test-poll-name', - options: const [ - PollOption( - id: 'test-option-id', - text: 'test-option-value', - ), - ], - ); - - const path = '/polls'; - - when( - () => client.post( - path, - data: jsonEncode(poll), - ), - ).thenAnswer( - (_) async => successResponse( - path, - data: { - 'poll': poll.toJson(), - }, - ), - ); - - final res = await pollsApi.createPoll(poll); - - expect(res, isNotNull); - expect(res.poll.id, poll.id); - - verify(() => client.post(path, data: any(named: 'data'))).called(1); - verifyNoMoreInteractions(client); - }); - - test('getPoll', () async { - const pollId = 'test-poll-id'; - - const path = '/polls/$pollId'; - - final poll = Poll( - id: pollId, - name: 'test-poll-name', - options: const [ - PollOption( - id: 'test-option-id', - text: 'test-option-value', - ), - ], - ); - - when(() => client.get(path)).thenAnswer( - (_) async => successResponse( - path, - data: { - 'poll': poll.toJson(), - }, - ), - ); - - final res = await pollsApi.getPoll(pollId); - - expect(res, isNotNull); - expect(res.poll.id, pollId); - - verify(() => client.get(path)).called(1); - verifyNoMoreInteractions(client); - }); - - test('updatePoll', () async { - final poll = Poll( - id: 'test-poll-id', - name: 'test-poll-name', - options: const [ - PollOption( - id: 'test-option-id', - text: 'test-option-value', - ), - ], - ); - - const path = '/polls'; - - when( - () => client.put( - path, - data: jsonEncode(poll), - ), - ).thenAnswer( - (_) async => successResponse( - path, - data: { - 'poll': poll.toJson(), - }, - ), - ); - - final res = await pollsApi.updatePoll(poll); - - expect(res, isNotNull); - expect(res.poll.id, poll.id); - - verify(() => client.put(path, data: any(named: 'data'))).called(1); - verifyNoMoreInteractions(client); - }); - - test('partialUpdatePoll', () async { - const pollId = 'test-poll-id'; - const set = {'name': 'updated-poll-name'}; - const unset = ['key']; - - const path = '/polls/$pollId'; - final poll = Poll( - id: pollId, - name: set['name']!, - options: const [ - PollOption( - id: 'test-option-id', - text: 'test-option-value', - ), - ], - ); - - when( - () => client.patch( - path, - data: jsonEncode({'set': set, 'unset': unset}), - ), - ).thenAnswer( - (_) async => successResponse( - path, - data: { - 'poll': poll.toJson(), - }, - ), - ); - - final res = await pollsApi.partialUpdatePoll( - pollId, - set: set, - unset: unset, - ); - - expect(res, isNotNull); - expect(res.poll.id, pollId); - expect(res.poll.name, set['name']); - - verify( - () => client.patch( - path, - data: jsonEncode({ - 'set': set, - 'unset': unset, - }), - ), - ).called(1); - verifyNoMoreInteractions(client); - }); - - test('deletePoll', () async { - const pollId = 'test-poll-id'; - - const path = '/polls/$pollId'; - - when( - () => client.delete(path), - ).thenAnswer((_) async => successResponse(path, data: {})); - - final res = await pollsApi.deletePoll(pollId); - - expect(res, isNotNull); - - verify(() => client.delete(path)).called(1); - verifyNoMoreInteractions(client); - }); - - test('createPollOption', () async { - const pollId = 'test-poll-id'; - const option = PollOption( - id: 'test-option-id', - text: 'test-option-value', - ); - - const path = '/polls/$pollId/options'; - - when( - () => client.post( - path, - data: jsonEncode(option), - ), - ).thenAnswer( - (_) async => successResponse( - path, - data: { - 'poll_option': option.toJson() - ..addAll({ - 'id': option.id, - }), - }, - ), - ); - - final res = await pollsApi.createPollOption(pollId, option); - - expect(res, isNotNull); - expect(res.pollOption.id, option.id); - - verify(() => client.post(path, data: any(named: 'data'))).called(1); - verifyNoMoreInteractions(client); - }); - - test('getPollOption', () async { - const pollId = 'test-poll-id'; - const optionId = 'test-option-id'; - - const path = '/polls/$pollId/options/$optionId'; - - const option = PollOption( - id: optionId, - text: 'test-option-value', - ); - - when(() => client.get(path)).thenAnswer( - (_) async => successResponse( - path, - data: { - 'poll_option': option.toJson() - ..addAll({ - 'id': option.id, - }), - }, - ), - ); - - final res = await pollsApi.getPollOption(pollId, optionId); - - expect(res, isNotNull); - expect(res.pollOption.id, optionId); - - verify(() => client.get(path)).called(1); - verifyNoMoreInteractions(client); - }); - - test('updatePollOption', () async { - const pollId = 'test-poll-id'; - const option = PollOption( - id: 'test-option-id', - text: 'test-option-value', - ); - - const path = '/polls/$pollId/options'; - - when( - () => client.put( - path, - data: jsonEncode(option), - ), - ).thenAnswer( - (_) async => successResponse( - path, - data: { - 'poll_option': option.toJson() - ..addAll({ - 'id': option.id, - }), - }, - ), - ); - - final res = await pollsApi.updatePollOption(pollId, option); - - expect(res, isNotNull); - expect(res.pollOption.id, option.id); - - verify(() => client.put(path, data: any(named: 'data'))).called(1); - verifyNoMoreInteractions(client); - }); - - test('deletePollOption', () async { - const pollId = 'test-poll-id'; - const optionId = 'test-option-id'; - - const path = '/polls/$pollId/options/$optionId'; - - when( - () => client.delete(path), - ).thenAnswer((_) async => successResponse(path, data: {})); - - final res = await pollsApi.deletePollOption(pollId, optionId); - - expect(res, isNotNull); - - verify(() => client.delete(path)).called(1); - verifyNoMoreInteractions(client); - }); - - test('castPollVote', () async { - const messageId = 'test-message-id'; - const pollId = 'test-poll-id'; - final vote = PollVote( - pollId: 'test-poll-id', - optionId: 'test-option-id', - ); - - const path = '/messages/$messageId/polls/$pollId/vote'; - - when( - () => client.post( - path, - data: jsonEncode({ - 'vote': vote, - }), - ), - ).thenAnswer( - (_) async => successResponse( - path, - data: { - 'vote': vote.toJson(), - }, - ), - ); - - final res = await pollsApi.castPollVote(messageId, pollId, vote); - - expect(res, isNotNull); - expect(res.vote.optionId, vote.optionId); - - verify(() => client.post(path, data: any(named: 'data'))).called(1); - verifyNoMoreInteractions(client); - }); - - test('removePollVote', () async { - const messageId = 'test-message-id'; - const pollId = 'test-poll-id'; - const voteId = 'test-vote-id'; - - const path = '/messages/$messageId/polls/$pollId/vote/$voteId'; - final vote = PollVote( - pollId: 'test-poll-id', - optionId: 'test-option-id', - ); - - when(() => client.delete(path)).thenAnswer( - (_) async => successResponse( - path, - data: { - 'vote': vote.toJson(), - }, - ), - ); - - final res = await pollsApi.removePollVote(messageId, pollId, voteId); - - expect(res, isNotNull); - - verify(() => client.delete(path)).called(1); - verifyNoMoreInteractions(client); - }); - - test('queryPolls', () async { - const path = '/polls/query'; - final filter = PollFilter.in_(PollFilterField.id, const ['p1', 'p2']); - final sort = [PollSort.desc(PollSortField.createdAt)]; - const pagination = PaginationParams(limit: 20); - - final payload = jsonEncode({ - 'filter': filter, - 'sort': sort, - ...pagination.toJson(), - }); - - final polls = List.generate( - pagination.limit, - (index) => Poll( - id: 'test-poll-id=$index', - name: 'test-poll-name=$index', - options: [ - PollOption( - id: 'test-option-id=$index', - text: 'test-option-value=$index', - ), - ], - ), - ); - - when( - () => client.post( - path, - data: payload, - ), - ).thenAnswer( - (_) async => successResponse( - path, - data: { - 'polls': [...polls.map((it) => it.toJson())], - }, - ), - ); - - final res = await pollsApi.queryPolls( - filter: filter, - sort: sort, - pagination: pagination, - ); - - expect(res, isNotNull); - expect(res.polls, hasLength(polls.length)); - - verify( - () => client.post(path, data: any(named: 'data')), - ).called(1); - verifyNoMoreInteractions(client); - }); - - test('queryPollVotes', () async { - const pollId = 'test-poll-id'; - final filter = PollVoteFilter.in_(PollVoteFilterField.id, const ['v1', 'v2']); - final sort = [PollVoteSort.desc(PollVoteSortField.createdAt)]; - const pagination = PaginationParams(limit: 20); - - const path = '/polls/$pollId/votes'; - - final payload = jsonEncode({ - 'filter': filter, - 'sort': sort, - ...pagination.toJson(), - }); - - final votes = List.generate( - pagination.limit, - (index) => PollVote( - pollId: pollId, - optionId: 'test-option-id=$index', - ), - ); - - when( - () => client.post( - path, - data: payload, - ), - ).thenAnswer( - (_) async => successResponse( - path, - data: { - 'votes': [...votes.map((it) => it.toJson())], - }, - ), - ); - - final res = await pollsApi.queryPollVotes( - pollId, - filter: filter, - sort: sort, - pagination: pagination, - ); - - expect(res, isNotNull); - expect(res.votes, hasLength(votes.length)); - - verify( - () => client.post(path, data: any(named: 'data')), - ).called(1); - verifyNoMoreInteractions(client); - }); -} diff --git a/packages/stream_chat/test/src/core/api/stream_chat_api_test.dart b/packages/stream_chat/test/src/core/api/stream_chat_api_test.dart index dd43c93da0..0e4ff0e4e5 100644 --- a/packages/stream_chat/test/src/core/api/stream_chat_api_test.dart +++ b/packages/stream_chat/test/src/core/api/stream_chat_api_test.dart @@ -23,10 +23,6 @@ void main() { expect(streamChatApi.message, isNotNull); }); - test('`.polls`', () { - expect(streamChatApi.polls, isNotNull); - }); - test('`.channel`', () { expect(streamChatApi.channel, isNotNull); }); diff --git a/packages/stream_chat/test/src/core/models/converters/poll_v1_json_test.dart b/packages/stream_chat/test/src/core/models/converters/poll_v1_json_test.dart new file mode 100644 index 0000000000..affb4cac1a --- /dev/null +++ b/packages/stream_chat/test/src/core/models/converters/poll_v1_json_test.dart @@ -0,0 +1,261 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +void main() { + test('Message.fromJson reads every field of a poll', () { + final message = Message.fromJson({'id': 'message-id', 'poll': _fullPollJson()}); + + expect( + message.poll, + Poll( + id: 'poll-id', + name: 'Lunch?', + description: 'Pick one', + options: const [ + PollOption(id: 'pizza', text: 'Pizza', extraData: {'color': 'red'}), + PollOption(id: 'sushi', text: 'Sushi'), + ], + votingVisibility: VotingVisibility.public, + enforceUniqueVote: false, + maxVotesAllowed: 2, + allowAnswers: true, + allowUserSuggestedOptions: true, + isClosed: true, + voteCount: 1, + answersCount: 1, + voteCountsByOption: const {'pizza': 1}, + latestVotesByOption: { + 'pizza': [_vote], + }, + latestAnswers: [_answer], + ownVotesAndAnswers: [_vote, _answer], + createdById: 'luke', + createdBy: User.fromJson(const {'id': 'luke', 'name': 'Luke'}), + createdAt: DateTime.utc(2024, 4, 17, 14, 46, 23), + updatedAt: DateTime.utc(2024, 4, 18), + extraData: const {'topic': 'food'}, + ), + ); + }); + + test('Message.fromJson reads poll dates sent as epoch nanoseconds', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'poll': { + 'id': 'poll-id', + 'name': 'Lunch?', + 'options': [], + 'created_at': 1704067200123456000, + 'updated_at': 1704153600000000000, + }, + }); + + expect(message.poll!.createdAt, DateTime.utc(2024, 1, 1, 0, 0, 0, 123, 456)); + expect(message.poll!.updatedAt, DateTime.utc(2024, 1, 2)); + }); + + test('Message.fromJson gives a poll without its optional keys the default settings', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'poll': {'id': 'poll-id', 'name': 'Lunch?', 'options': []}, + }); + + final poll = message.poll!; + expect(poll.votingVisibility, VotingVisibility.public); + expect(poll.enforceUniqueVote, isTrue); + expect(poll.maxVotesAllowed, isNull); + expect(poll.allowAnswers, isFalse); + expect(poll.allowUserSuggestedOptions, isFalse); + expect(poll.isClosed, isFalse); + expect(poll.voteCountsByOption, isEmpty); + expect(poll.latestVotesByOption, isEmpty); + expect(poll.ownVotesAndAnswers, isEmpty); + expect(poll.extraData, isEmpty); + }); + + test('Message.fromJson keeps the translations of a poll and its options out of their custom data', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'poll': { + 'id': 'poll-id', + 'name': 'Lunch?', + 'name_i18n': {'it': 'Pranzo?'}, + 'description_i18n': {'it': 'Scegline uno'}, + 'options': [ + { + 'id': 'pizza', + 'text': 'Pizza', + 'text_i18n': {'it': 'Pizza'}, + 'color': 'red', + }, + ], + 'topic': 'food', + }, + }); + + expect(message.poll!.extraData, {'topic': 'food'}); + expect(message.poll!.options.single.extraData, {'color': 'red'}); + }); + + test('Message.fromJson keeps a voting visibility the SDK does not name', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'poll': {'id': 'poll-id', 'name': 'Lunch?', 'options': [], 'voting_visibility': 'members_only'}, + }); + + expect(message.poll!.votingVisibility, const VotingVisibility('members_only')); + }); + + test('Message.fromJson reads an answer, which selects no option', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'poll': { + 'id': 'poll-id', + 'name': 'Lunch?', + 'options': [], + 'latest_answers': [_answerJson], + }, + }); + + final answer = message.poll!.latestAnswers.single; + expect(answer.optionId, ''); + expect(answer.isAnswer, isTrue); + }); + + test('Event.fromJson reads the poll and the vote of a vote event', () { + final event = Event.fromJson({ + 'type': EventType.pollVoteCasted, + 'poll': _fullPollJson(), + 'poll_vote': _voteJson, + }); + + expect(event.poll!.id, 'poll-id'); + expect(event.poll!.ownVotesAndAnswers, [_vote, _answer]); + expect(event.pollVote, _vote); + }); + + test('Event.toJson writes the poll settings with the custom data beside them', () { + final event = Event( + type: EventType.pollUpdated, + poll: Poll( + id: 'poll-id', + name: 'Lunch?', + description: 'Pick one', + options: const [ + PollOption(id: 'pizza', text: 'Pizza', extraData: {'color': 'red'}), + ], + voteCount: 3, + extraData: const {'topic': 'food'}, + ), + ); + + expect(event.toJson()['poll'], { + 'topic': 'food', + 'id': 'poll-id', + 'name': 'Lunch?', + 'description': 'Pick one', + 'options': [ + {'color': 'red', 'id': 'pizza', 'text': 'Pizza'}, + ], + 'voting_visibility': 'public', + 'enforce_unique_vote': true, + 'max_votes_allowed': null, + 'allow_user_suggested_options': false, + 'allow_answers': false, + 'is_closed': false, + }); + }); + + test('Event.toJson writes only the id, the option and the answer of a vote', () { + final event = Event(type: EventType.pollVoteCasted, pollVote: _vote); + + expect(event.toJson()['poll_vote'], {'id': 'vote-id', 'option_id': 'pizza'}); + }); + + test('Event.fromJson reads back the poll settings written by toJson', () { + final poll = Poll( + id: 'poll-id', + name: 'Lunch?', + options: const [ + PollOption(id: 'pizza', text: 'Pizza', extraData: {'color': 'red'}), + ], + votingVisibility: VotingVisibility.anonymous, + maxVotesAllowed: 2, + extraData: const {'topic': 'food'}, + ); + + final event = Event.fromJson(Event(type: EventType.pollUpdated, poll: poll).toJson()); + + expect(event.poll!.copyWith(createdAt: poll.createdAt, updatedAt: poll.updatedAt), poll); + }); +} + +final _vote = PollVote( + id: 'vote-id', + pollId: 'poll-id', + optionId: 'pizza', + createdAt: DateTime.utc(2024, 4, 17), + updatedAt: DateTime.utc(2024, 4, 17), + userId: 'luke', + user: User.fromJson(const {'id': 'luke', 'name': 'Luke'}), +); + +const _voteJson = { + 'id': 'vote-id', + 'poll_id': 'poll-id', + 'option_id': 'pizza', + 'created_at': '2024-04-17T00:00:00Z', + 'updated_at': '2024-04-17T00:00:00Z', + 'user_id': 'luke', + 'user': {'id': 'luke', 'name': 'Luke'}, +}; + +final _answer = PollVote( + id: 'answer-id', + pollId: 'poll-id', + optionId: '', + answerText: 'Anything', + createdAt: DateTime.utc(2024, 4, 18), + updatedAt: DateTime.utc(2024, 4, 18), + userId: 'luke', +); + +const _answerJson = { + 'id': 'answer-id', + 'poll_id': 'poll-id', + 'option_id': '', + 'answer_text': 'Anything', + 'is_answer': true, + 'created_at': '2024-04-18T00:00:00Z', + 'updated_at': '2024-04-18T00:00:00Z', + 'user_id': 'luke', +}; + +Map _fullPollJson() => { + 'id': 'poll-id', + 'name': 'Lunch?', + 'description': 'Pick one', + 'options': [ + {'id': 'pizza', 'text': 'Pizza', 'color': 'red'}, + {'id': 'sushi', 'text': 'Sushi'}, + ], + 'voting_visibility': 'public', + 'enforce_unique_vote': false, + 'max_votes_allowed': 2, + 'allow_answers': true, + 'allow_user_suggested_options': true, + 'is_closed': true, + 'vote_count': 1, + 'answers_count': 1, + 'vote_counts_by_option': {'pizza': 1}, + 'latest_votes_by_option': { + 'pizza': [_voteJson], + }, + 'latest_answers': [_answerJson], + 'own_votes': [_voteJson, _answerJson], + 'created_by_id': 'luke', + 'created_by': {'id': 'luke', 'name': 'Luke'}, + 'created_at': '2024-04-17T14:46:23Z', + 'updated_at': '2024-04-18T00:00:00Z', + 'topic': 'food', +}; diff --git a/packages/stream_chat/test/src/core/models/poll_option_test.dart b/packages/stream_chat/test/src/core/models/poll_option_test.dart new file mode 100644 index 0000000000..8d0ea26d07 --- /dev/null +++ b/packages/stream_chat/test/src/core/models/poll_option_test.dart @@ -0,0 +1,32 @@ +import 'package:stream_chat/src/core/models/poll_option.dart'; +import 'package:test/test.dart'; + +void main() { + test('PollOption.toData writes the id, the text and the custom data under their stored keys', () { + const option = PollOption(id: 'pizza', text: 'Pizza', extraData: {'color': 'red'}); + + expect(option.toData(), { + 'id': 'pizza', + 'text': 'Pizza', + 'extra_data': {'color': 'red'}, + }); + }); + + test('PollOption.toData leaves out an id that is null', () { + const option = PollOption(text: 'Pizza'); + + expect(option.toData().keys, unorderedEquals(['text', 'extra_data'])); + }); + + test('PollOption.fromData reads back the option written by toData', () { + const option = PollOption(id: 'pizza', text: 'Pizza', extraData: {'color': 'red'}); + + expect(PollOption.fromData(option.toData()), option); + }); + + test('PollOption compares its custom data for equality', () { + const option = PollOption(id: 'pizza', text: 'Pizza', extraData: {'color': 'red'}); + + expect(option, isNot(option.copyWith(extraData: const {'color': 'green'}))); + }); +} diff --git a/packages/stream_chat/test/src/core/models/poll_test.dart b/packages/stream_chat/test/src/core/models/poll_test.dart index 6125da9dd9..0c88907fce 100644 --- a/packages/stream_chat/test/src/core/models/poll_test.dart +++ b/packages/stream_chat/test/src/core/models/poll_test.dart @@ -1,122 +1,117 @@ -// ignore_for_file: avoid_redundant_argument_values - import 'package:stream_chat/src/core/models/poll.dart'; import 'package:stream_chat/src/core/models/poll_option.dart'; +import 'package:stream_chat/src/core/models/poll_vote.dart'; +import 'package:stream_chat/src/core/models/voting_visibility.dart'; import 'package:test/test.dart'; import '../../utils.dart'; void main() { - group('src/models/message', () { - test('should parse json correctly', () { - final poll = Poll.fromJson(jsonFixture('poll.json')); - - expect(poll.id, '7fd88eb3-fc05-4e89-89af-36c6d8995dda'); - expect(poll.name, 'test'); - expect(poll.description, ''); - expect(poll.votingVisibility, VotingVisibility.public); - expect(poll.enforceUniqueVote, false); - expect(poll.maxVotesAllowed, isNull); - expect(poll.allowUserSuggestedOptions, false); - expect(poll.allowAnswers, false); - expect(poll.isClosed, false); - expect(poll.voteCount, 0); - expect(poll.answersCount, 0); - - expect(poll.createdAt.toIso8601String(), '2024-04-17T14:46:23.001349Z'); - expect(poll.updatedAt.toIso8601String(), '2024-04-17T14:46:23.001349Z'); - - expect(poll.options.length, 1); - final option = poll.options[0]; - expect(option.id, 'option1'); - expect(option.text, 'option1 text'); - - expect(poll.latestVotesByOption, isEmpty); - - expect(poll.ownVotesAndAnswers.length, 1); - final vote = poll.ownVotesAndAnswers[0]; - expect(vote.id, 'luke_skywalker'); - expect(vote.optionId, 'option1'); - expect(vote.pollId, '7fd88eb3-fc05-4e89-89af-36c6d8995dda'); - expect(vote.createdAt.toIso8601String(), '2022-02-03T15:47:10.148169Z'); - expect(vote.updatedAt.toIso8601String(), '2024-03-18T16:44:45.749718Z'); - - // Check createdBy fields - expect(poll.createdById, 'luke_skywalker'); - expect(poll.createdBy, isNotNull); - }); - - test('should serialize to json correctly', () { - final poll = Poll( - id: '7fd88eb3-fc05-4e89-89af-36c6d8995dda', - name: 'test', - options: const [ - PollOption( - text: 'option1 text', - ), - ], - ); - - final json = poll.toJson(); - - expect(json['id'], '7fd88eb3-fc05-4e89-89af-36c6d8995dda'); - expect(json['name'], 'test'); - expect(json['description'], isNull); - expect(json['options'], [ - {'text': 'option1 text'}, - ]); - expect(json['voting_visibility'], 'public'); - expect(json['enforce_unique_vote'], true); - expect(json['max_votes_allowed'], isNull); - expect(json['allow_user_suggested_options'], false); - expect(json['allow_answers'], false); - expect(json['is_closed'], false); - }); - - group('PollSortField', () { - test('id orders alphabetically', () { - expectOrders( - PollSortField.id, - createTestPoll(id: 'a-poll', name: 'A'), - createTestPoll(id: 'b-poll', name: 'B'), - ); - }); - - test('name orders alphabetically, folded', () { - expectOrders( - PollSortField.name, - createTestPoll(name: 'apples'), - createTestPoll(name: 'Bananas'), - ); - }); - - test('createdAt orders older polls first', () { - expectOrders( - PollSortField.createdAt, - createTestPoll(name: 'older', createdAt: DateTime(2023, 6, 10)), - createTestPoll(name: 'newer', createdAt: DateTime(2023, 6, 15)), - ); - }); - - test('updatedAt orders older polls first', () { - expectOrders( - PollSortField.updatedAt, - createTestPoll(name: 'older', updatedAt: DateTime(2023, 6, 10)), - createTestPoll(name: 'newer', updatedAt: DateTime(2023, 6, 15)), - ); - }); - - test('isClosed orders open polls first', () { - expectOrders( - PollSortField.isClosed, - createTestPoll(name: 'open', isClosed: false), - createTestPoll(name: 'closed', isClosed: true), - ); - }); - }); + test('Poll generates an id when none is given', () { + final first = Poll(name: 'Lunch?', options: const []); + final second = Poll(name: 'Lunch?', options: const []); + + expect(first.id, isNotEmpty); + expect(first.id, isNot(second.id)); + }); + + test('Poll compares its custom data for equality', () { + final poll = createTestPoll(id: 'poll-id', name: 'Lunch?', extraData: const {'topic': 'food'}); + + expect(poll, poll.copyWith(extraData: const {'topic': 'food'})); + expect(poll, isNot(poll.copyWith(extraData: const {'topic': 'drinks'}))); + }); + + test('Poll.copyWith can clear the vote limit', () { + final poll = createTestPoll(name: 'Lunch?', maxVotesAllowed: 2); + + expect(poll.copyWith(maxVotesAllowed: null).maxVotesAllowed, isNull); + }); + + test('Poll.latestVotes holds the latest votes of every option', () { + final pizza = _vote(id: 'v1', optionId: 'pizza'); + final sushi = _vote(id: 'v2', optionId: 'sushi'); + final poll = createTestPoll(name: 'Lunch?').copyWith( + latestVotesByOption: { + 'pizza': [pizza], + 'sushi': [sushi], + }, + ); + + expect(poll.latestVotes, unorderedEquals([pizza, sushi])); + }); + + test('Poll.ownVotes leaves out the answers of the current user', () { + final vote = _vote(id: 'v1', optionId: 'pizza'); + final answer = _vote(id: 'a1', answerText: 'Anything'); + final poll = createTestPoll(name: 'Lunch?').copyWith(ownVotesAndAnswers: [vote, answer]); + + expect(poll.ownVotes, [vote]); + }); + + test('Poll.ownAnswers leaves out the votes of the current user', () { + final vote = _vote(id: 'v1', optionId: 'pizza'); + final answer = _vote(id: 'a1', answerText: 'Anything'); + final poll = createTestPoll(name: 'Lunch?').copyWith(ownVotesAndAnswers: [vote, answer]); + + expect(poll.ownAnswers, [answer]); + }); + + test('PollFilterField.votingVisibility reads the visibility value', () { + final poll = createTestPoll(name: 'Lunch?', votingVisibility: VotingVisibility.anonymous); + + expect(PollFilterField.votingVisibility.value(poll), 'anonymous'); + }); + + test('PollSortField.id orders alphabetically', () { + expectOrders( + PollSortField.id, + createTestPoll(id: 'a-poll', name: 'A'), + createTestPoll(id: 'b-poll', name: 'B'), + ); + }); + + test('PollSortField.name orders alphabetically, folded', () { + expectOrders( + PollSortField.name, + createTestPoll(name: 'apples'), + createTestPoll(name: 'Bananas'), + ); + }); + + test('PollSortField.createdAt orders older polls first', () { + expectOrders( + PollSortField.createdAt, + createTestPoll(name: 'older', createdAt: DateTime(2023, 6, 10)), + createTestPoll(name: 'newer', createdAt: DateTime(2023, 6, 15)), + ); + }); + + test('PollSortField.updatedAt orders older polls first', () { + expectOrders( + PollSortField.updatedAt, + createTestPoll(name: 'older', updatedAt: DateTime(2023, 6, 10)), + createTestPoll(name: 'newer', updatedAt: DateTime(2023, 6, 15)), + ); + }); + + test('PollSortField.isClosed orders open polls first', () { + expectOrders( + PollSortField.isClosed, + createTestPoll(name: 'open'), + createTestPoll(name: 'closed', isClosed: true), + ); }); } +PollVote _vote({required String id, String? optionId, String? answerText}) => PollVote( + id: id, + optionId: optionId, + answerText: answerText, + createdAt: DateTime.utc(2024), + updatedAt: DateTime.utc(2024), +); + /// Helper function to create a Poll for testing Poll createTestPoll({ String? id, diff --git a/packages/stream_chat/test/src/core/models/poll_vote_test.dart b/packages/stream_chat/test/src/core/models/poll_vote_test.dart index a43deef1a7..02c315b22f 100644 --- a/packages/stream_chat/test/src/core/models/poll_vote_test.dart +++ b/packages/stream_chat/test/src/core/models/poll_vote_test.dart @@ -5,32 +5,40 @@ import 'package:test/test.dart'; import '../../utils.dart'; void main() { - group('src/models/poll_vote', () { - group('PollVoteSortField', () { - test('id orders alphabetically', () { - expectOrders( - PollVoteSortField.id, - createTestPollVote(id: 'a-vote', optionId: 'o'), - createTestPollVote(id: 'b-vote', optionId: 'o'), - ); - }); - - test('createdAt orders older votes first', () { - expectOrders( - PollVoteSortField.createdAt, - createTestPollVote(optionId: 'o', createdAt: DateTime(2023, 6, 10)), - createTestPollVote(optionId: 'o', createdAt: DateTime(2023, 6, 15)), - ); - }); - - test('updatedAt orders older votes first', () { - expectOrders( - PollVoteSortField.updatedAt, - createTestPollVote(optionId: 'o', updatedAt: DateTime(2023, 6, 10)), - createTestPollVote(optionId: 'o', updatedAt: DateTime(2023, 6, 15)), - ); - }); - }); + test('PollVote.isAnswer is true for a vote that carries answer text', () { + final answer = createTestPollVote(answerText: 'Anything'); + + expect(answer.isAnswer, isTrue); + }); + + test('PollVote.isAnswer is false for a vote for an option', () { + final vote = createTestPollVote(optionId: 'pizza'); + + expect(vote.isAnswer, isFalse); + }); + + test('PollVoteSortField.id orders alphabetically', () { + expectOrders( + PollVoteSortField.id, + createTestPollVote(id: 'a-vote', optionId: 'o'), + createTestPollVote(id: 'b-vote', optionId: 'o'), + ); + }); + + test('PollVoteSortField.createdAt orders older votes first', () { + expectOrders( + PollVoteSortField.createdAt, + createTestPollVote(optionId: 'o', createdAt: DateTime(2023, 6, 10)), + createTestPollVote(optionId: 'o', createdAt: DateTime(2023, 6, 15)), + ); + }); + + test('PollVoteSortField.updatedAt orders older votes first', () { + expectOrders( + PollVoteSortField.updatedAt, + createTestPollVote(optionId: 'o', updatedAt: DateTime(2023, 6, 10)), + createTestPollVote(optionId: 'o', updatedAt: DateTime(2023, 6, 15)), + ); }); } diff --git a/packages/stream_chat/test/src/fakes.dart b/packages/stream_chat/test/src/fakes.dart index cdb28df367..dcd9e225ac 100644 --- a/packages/stream_chat/test/src/fakes.dart +++ b/packages/stream_chat/test/src/fakes.dart @@ -6,7 +6,6 @@ import 'package:stream_chat/src/core/api/channel_api.dart'; import 'package:stream_chat/src/core/api/general_api.dart'; import 'package:stream_chat/src/core/api/message_api.dart'; import 'package:stream_chat/src/core/api/moderation_api.dart'; -import 'package:stream_chat/src/core/api/polls_api.dart'; import 'package:stream_chat/src/core/api/push_preferences_api.dart'; import 'package:stream_chat/src/core/api/user_api.dart'; import 'package:stream_chat/stream_chat.dart'; @@ -134,11 +133,6 @@ class FakeChatApi extends Fake implements StreamChatApi { @override MessageApi get message => _message ??= MockMessageApi(); - @override - PollsApi get polls => _polls ??= MockPollsApi(); - - PollsApi? _polls; - ChannelApi? _channel; @override diff --git a/packages/stream_chat/test/src/mocks.dart b/packages/stream_chat/test/src/mocks.dart index 004e7d7d64..862672b853 100644 --- a/packages/stream_chat/test/src/mocks.dart +++ b/packages/stream_chat/test/src/mocks.dart @@ -10,7 +10,6 @@ import 'package:stream_chat/src/core/api/channel_api.dart'; import 'package:stream_chat/src/core/api/general_api.dart'; import 'package:stream_chat/src/core/api/message_api.dart'; import 'package:stream_chat/src/core/api/moderation_api.dart'; -import 'package:stream_chat/src/core/api/polls_api.dart'; import 'package:stream_chat/src/core/api/push_preferences_api.dart'; import 'package:stream_chat/src/core/api/user_api.dart'; import 'package:stream_chat/src/core/http/stream_http_client.dart'; @@ -46,8 +45,6 @@ class MockUserApi extends Mock implements UserApi {} class MockMessageApi extends Mock implements MessageApi {} -class MockPollsApi extends Mock implements PollsApi {} - class MockChannelApi extends Mock implements ChannelApi {} class MockPushPreferencesApi extends Mock implements PushPreferencesApi {} diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index d9685299c1..e93a6c141f 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -8,6 +8,7 @@ - The sort argument on every list controller is typed `List`, `List` and so on, rather than `SortOrder`. On the controllers that have a default, the `sort` property is non-nullable: leaving it unset selects that default, and `ChannelSort.empty` / `MemberSort.empty` / … query with the ordering the API applies on its own. - `defaultChannelListSort`, `defaultMemberListSort`, `defaultUserListSort`, `defaultDraftListSort`, `defaultMessageReminderListSort` and `defaultPollVoteListSort` are removed. Use `ChannelSort.defaultSort`, `MemberSort.defaultSort` and so on from `stream_chat`. Leaving a controller's sort unset still applies the same ordering. - `StreamChannelListController.muteChannel` and `unmuteChannel` return a `Result` instead of throwing. +- `StreamPollVoteListController` pages with the poll vote query's `limit` and `next` cursor rather than `PaginationParams`. 🐞 Fixed diff --git a/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart index 27800c8d8c..16169ecced 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart @@ -113,61 +113,58 @@ class StreamPollVoteListController extends PagedValueNotifier this.limit * defaultInitialPagedLimitMultiplier, _kDefaultBackendPaginationLimit, ); - try { - final response = await channel.queryPollVotes( - pollId, - sort: _activeSort, - filter: _activeFilter, - pagination: PaginationParams(limit: limit), - ); - - final results = response.votes; - final nextKey = response.next; - value = PagedValue( - items: results, - nextPageKey: nextKey, - ); - - // start listening to events - if (disposed) return; - _subscribeToPollVoteEvents(); - } on StreamChatException catch (error) { - value = PagedValue.error(error); - } catch (error) { - final chatError = StreamClientException(message: 'Failed to load poll votes', cause: error); - value = PagedValue.error(chatError); - } + final result = await _queryPollVotes(limit: limit); + + result.fold( + onSuccess: (response) { + value = PagedValue( + items: response.votes, + nextPageKey: response.next, + ); + + // start listening to events + if (disposed) return; + _subscribeToPollVoteEvents(); + }, + onFailure: (error, _) => value = PagedValue.error(_chatException(error, 'Failed to load poll votes')), + ); } @override Future loadMore(String nextPageKey) async { final previousValue = value.asSuccess; + final result = await _queryPollVotes(limit: limit, next: nextPageKey); + + result.fold( + onSuccess: (response) { + final next = response.next; + value = PagedValue( + items: previousValue.items + response.votes, + nextPageKey: next != null && next.isNotEmpty ? next : null, + ); + }, + onFailure: (error, _) { + value = previousValue.copyWith(error: _chatException(error, 'Failed to load more poll votes')); + }, + ); + } + + // Queries the next page of votes, turning an error the channel throws (for one that is not initialized, say) into a + // failure, so every outcome reaches the controller's value. + Future> _queryPollVotes({required int limit, String? next}) async { try { - final response = await channel.queryPollVotes( - pollId, - sort: _activeSort, - filter: _activeFilter, - pagination: PaginationParams(limit: limit, next: nextPageKey), - ); - - final results = response.votes; - final previousItems = previousValue.items; - final newItems = previousItems + results; - final next = response.next; - final nextKey = next != null && next.isNotEmpty ? next : null; - value = PagedValue( - items: newItems, - nextPageKey: nextKey, - ); - } on StreamChatException catch (error) { - value = previousValue.copyWith(error: error); - } catch (error) { - final chatError = StreamClientException(message: 'Failed to load more poll votes', cause: error); - value = previousValue.copyWith(error: chatError); + return await channel.queryPollVotes(pollId, sort: _activeSort, filter: _activeFilter, limit: limit, next: next); + } catch (error, stackTrace) { + return Result.failure(error, stackTrace); } } + StreamChatException _chatException(Object error, String message) => switch (error) { + final StreamChatException error => error, + _ => StreamClientException(message: message, cause: error), + }; + @override Future refresh({bool resetValue = true}) { if (resetValue) { diff --git a/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart b/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart new file mode 100644 index 0000000000..49894df190 --- /dev/null +++ b/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart @@ -0,0 +1,138 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/stream_chat.dart' hide Success; +import 'package:stream_chat_flutter_core/src/paged_value_notifier.dart'; +import 'package:stream_chat_flutter_core/src/stream_poll_vote_list_controller.dart'; + +import 'mocks.dart'; + +void main() { + test('StreamPollVoteListController.doInitialLoad loads the first page of votes', () async { + final channel = _channelAnswering([ + Result.success(_page([_vote('v1'), _vote('v2')], next: 'cursor')), + ]); + final controller = _controller(channel); + + await controller.doInitialLoad(); + + expect(controller.value, PagedValue(items: [_vote('v1'), _vote('v2')], nextPageKey: 'cursor')); + }); + + test('StreamPollVoteListController.doInitialLoad asks for three pages at once', () async { + final channel = _channelAnswering([Result.success(_page([]))]); + final controller = _controller(channel); + + await controller.doInitialLoad(); + + verify( + () => channel.queryPollVotes( + 'poll-id', + filter: any(named: 'filter'), + sort: any(named: 'sort'), + limit: 30, + ), + ); + }); + + test('StreamPollVoteListController.doInitialLoad turns a failure into the error state', () async { + const error = StreamClientException(message: 'boom'); + final channel = _channelAnswering([const Result.failure(error)]); + final controller = _controller(channel); + + await controller.doInitialLoad(); + + expect(controller.value, const PagedValue.error(error)); + }); + + test('StreamPollVoteListController.doInitialLoad turns an error the channel throws into the error state', () async { + final channel = MockChannel(); + final thrown = StateError('Channel is not initialized'); + when( + () => channel.queryPollVotes( + any(), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + limit: any(named: 'limit'), + next: any(named: 'next'), + prev: any(named: 'prev'), + ), + ).thenThrow(thrown); + final controller = _controller(channel); + + await controller.doInitialLoad(); + + final error = controller.value.mapOrNull(null, error: (it) => it.error); + expect(error, isA().having((it) => it.cause, 'cause', thrown)); + }); + + test('StreamPollVoteListController.loadMore appends the next page of votes', () async { + final channel = _channelAnswering([ + Result.success(_page([_vote('v1')], next: 'cursor')), + Result.success(_page([_vote('v2')])), + ]); + final controller = _controller(channel); + await controller.doInitialLoad(); + + await controller.loadMore('cursor'); + + expect(controller.value, PagedValue(items: [_vote('v1'), _vote('v2')])); + verify( + () => channel.queryPollVotes( + 'poll-id', + filter: any(named: 'filter'), + sort: any(named: 'sort'), + limit: 10, + next: 'cursor', + ), + ); + }); + + test('StreamPollVoteListController.loadMore keeps the loaded votes and records a failure', () async { + const error = StreamClientException(message: 'boom'); + final channel = _channelAnswering([ + Result.success(_page([_vote('v1')], next: 'cursor')), + const Result.failure(error), + ]); + final controller = _controller(channel); + await controller.doInitialLoad(); + + await controller.loadMore('cursor'); + + expect(controller.value, PagedValue(items: [_vote('v1')], nextPageKey: 'cursor', error: error)); + }); +} + +PollVote _vote(String id) => PollVote( + id: id, + pollId: 'poll-id', + optionId: 'pizza', + createdAt: DateTime.utc(2024), + updatedAt: DateTime.utc(2024), +); + +QueryPollVotesResponse _page(List votes, {String? next}) => + QueryPollVotesResponse(duration: '4.21ms', votes: votes, next: next); + +// A channel whose vote queries answer [results] in order. +MockChannel _channelAnswering(List> results) { + final channel = MockChannel(); + final pending = [...results]; + when( + () => channel.queryPollVotes( + any(), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + limit: any(named: 'limit'), + next: any(named: 'next'), + prev: any(named: 'prev'), + ), + ).thenAnswer((_) async => pending.removeAt(0)); + when(channel.on).thenAnswer((_) => const Stream.empty()); + return channel; +} + +StreamPollVoteListController _controller(Channel channel) { + final controller = StreamPollVoteListController(channel: channel, pollId: 'poll-id'); + addTearDown(controller.dispose); + return controller; +} diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md index d0c5c8bf55..8e07641ff0 100644 --- a/packages/stream_chat_persistence/CHANGELOG.md +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -6,6 +6,7 @@ - The sort arguments on `getChannelStates`, `queryChannelStates` and `saveChannelQueries` are typed `List` rather than `SortOrder`. - The filter arguments on `getChannelStates`, `queryChannelStates`, `updateChannelQueries` and `saveChannelQueries` are typed `ChannelFilter?` rather than an untyped `Filter`. - A predefined-filter query whose resolved filter is absent now stores no filter, rather than an empty one. The schema version is bumped for it, so the local cache is rebuilt once on upgrade. +- Poll options are stored in a new format, and a voting visibility the SDK does not name is read back instead of failing. 🔄 Changed diff --git a/packages/stream_chat_persistence/lib/src/converter/voting_visibility_converter.dart b/packages/stream_chat_persistence/lib/src/converter/voting_visibility_converter.dart index 047be71a46..389b55b154 100644 --- a/packages/stream_chat_persistence/lib/src/converter/voting_visibility_converter.dart +++ b/packages/stream_chat_persistence/lib/src/converter/voting_visibility_converter.dart @@ -7,26 +7,8 @@ class VotingVisibilityConverter extends TypeConverter const VotingVisibilityConverter(); @override - VotingVisibility fromSql(String fromDb) { - for (final entry in _votingVisibilityEnumMap.entries) { - if (entry.value == fromDb) { - return entry.key; - } - } - - throw ArgumentError( - '`$fromDb` is not one of the supported values: ' - '${_votingVisibilityEnumMap.values.join(', ')}', - ); - } + VotingVisibility fromSql(String fromDb) => VotingVisibility(fromDb); @override - String toSql(VotingVisibility value) { - return _votingVisibilityEnumMap[value]!; - } + String toSql(VotingVisibility value) => value.value; } - -const _votingVisibilityEnumMap = { - VotingVisibility.anonymous: 'anonymous', - VotingVisibility.public: 'public', -}; 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 1e07f3b072..b8241b86af 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 + 2; + int get schemaVersion => 1100 + 3; // Store DateTime as ISO-8601 text to preserve sub-second precision. @override diff --git a/packages/stream_chat_persistence/lib/src/mapper/poll_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/poll_mapper.dart index f366daf9c2..a022de0d9a 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/poll_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/poll_mapper.dart @@ -16,10 +16,7 @@ extension PollEntityX on PollEntity { id: id, name: name, description: description, - options: options.map((it) { - final json = jsonDecode(it); - return PollOption.fromJson(json); - }).toList(), + options: options.map((it) => PollOption.fromData(jsonDecode(it))).toList(), votingVisibility: votingVisibility, enforceUniqueVote: enforceUniqueVote, maxVotesAllowed: maxVotesAllowed, @@ -48,7 +45,7 @@ extension PollX on Poll { id: id, name: name, description: description, - options: options.map(jsonEncode).toList(), + options: options.map((it) => jsonEncode(it.toData())).toList(), votingVisibility: votingVisibility, enforceUniqueVote: enforceUniqueVote, maxVotesAllowed: maxVotesAllowed, diff --git a/packages/stream_chat_persistence/test/src/converter/voting_visibility_converter_test.dart b/packages/stream_chat_persistence/test/src/converter/voting_visibility_converter_test.dart index f99a44e707..83d11cde32 100644 --- a/packages/stream_chat_persistence/test/src/converter/voting_visibility_converter_test.dart +++ b/packages/stream_chat_persistence/test/src/converter/voting_visibility_converter_test.dart @@ -16,11 +16,8 @@ void main() { expect(converter.fromSql('public'), VotingVisibility.public); }); - test('fromSql throws ArgumentError for invalid String', () { - expect( - () => converter.fromSql('invalid_value'), - throwsA(isA()), - ); + test('fromSql keeps a visibility the SDK does not name', () { + expect(converter.fromSql('members_only'), const VotingVisibility('members_only')); }); }); } diff --git a/packages/stream_chat_persistence/test/src/mapper/poll_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/poll_mapper_test.dart index a4f4ef64d6..fc4ef6a17b 100644 --- a/packages/stream_chat_persistence/test/src/mapper/poll_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/poll_mapper_test.dart @@ -1,5 +1,3 @@ -import 'dart:convert'; - import 'package:flutter_test/flutter_test.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_persistence/src/db/drift_chat_database.dart'; @@ -56,10 +54,10 @@ void main() { votingVisibility: VotingVisibility.public, allowUserSuggestedOptions: true, options: const [ - PollOption(id: 'option-1', text: 'Red'), - PollOption(id: 'option-2', text: 'Blue'), - PollOption(id: 'option-3', text: 'Green'), - ].map(jsonEncode).toList(), + '{"id":"option-1","text":"Red","extra_data":{"hex":"#f00"}}', + '{"id":"option-2","text":"Blue","extra_data":{}}', + '{"id":"option-3","text":"Green","extra_data":{}}', + ], voteCount: voteCountsByOption.values.reduce((a, b) => a + b), voteCountsByOption: voteCountsByOption, createdAt: DateTime.now(), @@ -88,14 +86,11 @@ void main() { expect(poll.enforceUniqueVote, entity.enforceUniqueVote); expect(poll.maxVotesAllowed, entity.maxVotesAllowed); expect(poll.allowUserSuggestedOptions, entity.allowUserSuggestedOptions); - for (var i = 0; i < poll.options.length; i++) { - final pollOption = poll.options[i]; - final entityOptionJson = jsonDecode(entity.options[i]); - final entityOption = PollOption.fromJson(entityOptionJson); - expect(pollOption.id, entityOption.id); - expect(pollOption.text, entityOption.text); - expect(pollOption.extraData, entityOption.extraData); - } + expect(poll.options, const [ + PollOption(id: 'option-1', text: 'Red', extraData: {'hex': '#f00'}), + PollOption(id: 'option-2', text: 'Blue'), + PollOption(id: 'option-3', text: 'Green'), + ]); expect(poll.allowAnswers, entity.allowAnswers); expect(poll.answersCount, entity.answersCount); expect(poll.isClosed, entity.isClosed); @@ -154,7 +149,7 @@ void main() { createdById: currentUser.id, allowUserSuggestedOptions: true, options: const [ - PollOption(id: 'option-1', text: 'Red'), + PollOption(id: 'option-1', text: 'Red', extraData: {'hex': '#f00'}), PollOption(id: 'option-2', text: 'Blue'), PollOption(id: 'option-3', text: 'Green'), ], @@ -176,10 +171,11 @@ void main() { expect(entity.enforceUniqueVote, poll.enforceUniqueVote); expect(entity.maxVotesAllowed, poll.maxVotesAllowed); expect(entity.allowUserSuggestedOptions, poll.allowUserSuggestedOptions); - expect( - entity.options, - poll.options.map((it) => jsonEncode(it.toJson())).toList(), - ); + expect(entity.options, const [ + '{"id":"option-1","text":"Red","extra_data":{"hex":"#f00"}}', + '{"id":"option-2","text":"Blue","extra_data":{}}', + '{"id":"option-3","text":"Green","extra_data":{}}', + ]); expect(entity.allowAnswers, poll.allowAnswers); expect(entity.answersCount, poll.answersCount); expect(entity.isClosed, poll.isClosed); From 040d62f201292532178c06eb84e4c0602088bd8e Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 1 Oct 2026 15:15:42 +0200 Subject: [PATCH 03/32] refactor(llc): address review findings on the polls migration Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 4 +-- packages/stream_chat/CHANGELOG.md | 2 +- .../lib/src/client/channel/channel.dart | 2 +- .../stream_chat/lib/src/client/client.dart | 4 +-- .../models/converters/v1_json_converters.dart | 2 +- .../stream_chat/lib/src/core/models/poll.dart | 8 +++--- .../models/response/poll_vote_response.dart | 2 ++ .../src/core/models/voting_visibility.dart | 2 +- .../lib/src/repository/polls_repository.dart | 4 +-- .../test/src/core/models/poll_test.dart | 19 +++++++++++++ .../test/src/core/models/poll_vote_test.dart | 10 +++++++ .../stream_chat_flutter_core/CHANGELOG.md | 5 +++- ...stream_poll_vote_list_controller_test.dart | 28 +++++++++++++++++++ .../voting_visibility_converter.dart | 2 +- 14 files changed, 78 insertions(+), 16 deletions(-) diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index 8e8f356344..1bac2e8e3d 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -222,7 +222,7 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `CastPollVoteResponse` / `RemovePollVoteResponse` | `PollVoteResponse` | `renamed` | `vote` is nullable | | `queryPolls(pagination: PaginationParams(limit: l, next: n))` / `queryPollVotes(…)` | `queryPolls(limit: l, next: n)` / `queryPollVotes(…)` | `retyped` | `limit` is still 10 when omitted. Pass a response's `prev` as `prev` to page backwards. The other `PaginationParams` fields never had an effect on these queries | | `Channel.castPollVote` / `.removePollVote` throwing `ArgumentError` | a `Failure` carrying a `StreamClientException` | `retyped` | For an option or vote without an id. `updatePoll` and `updatePollOption` fail the same way for an option without one | -| `VotingVisibility` (enum) | `VotingVisibility` (extension type over `String`) | `retyped` | `VotingVisibility.public` and `.anonymous` are unchanged; a `switch` needs a default arm, and `.name` / `.values` are gone — read `.value` | +| `VotingVisibility` (enum) | `VotingVisibility` (extension type over `String`) | `retyped` | `VotingVisibility.public` and `.anonymous` are unchanged; a `switch` needs a default arm, and `.name` / `.values` are gone — read `.rawType` | | `Poll.fromJson` / `.toJson`, `PollOption.fromJson` / `.toJson`, `PollVote.fromJson` / `.toJson` | — | `removed` | The models are plain classes; construct them directly. `Message.poll` and the poll events still decode from the same keys | | — | `PollOption.fromData` / `.toData` | `added` | Read and write only the format the offline database stores; not a codec for API payloads | | `CreatePollResponse()..poll = …` and the other response setters | `PollResponse(duration: …, poll: …)` | `retyped` | The responses are plain classes with a const constructor and final fields | @@ -803,7 +803,7 @@ await channel.queryPollVotes(pollId, limit: 10, next: cursor); **`VotingVisibility` is an extension type over its wire string.** `VotingVisibility.public` and `VotingVisibility.anonymous` read the same, and a visibility the SDK does not name is kept rather than rejected. -A `switch` over it needs a default arm, and `.name` / `.values` are gone — read `.value`. +A `switch` over it needs a default arm, and `.name` / `.values` are gone — read `.rawType`. **`Poll`, `PollOption`, `PollVote` and the responses no longer decode JSON.** Build them with their constructors — `PollResponse(duration: '0ms', poll: poll)` where v10 wrote `CreatePollResponse()..poll = poll`. `Message.poll` and diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index ac43fee2c2..9b2d41518a 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -79,7 +79,7 @@ - `StreamChatApi.general.getAppSettings` is removed; call `StreamChatClient.getAppSettings` instead. - `StreamChatApi.guest` is removed; call `StreamChatClient.connectGuestUser` instead. - `ConnectGuestUserResponse` is removed from the public API. -- `StreamChatClient`'s poll methods and `Channel.sendPoll`, `updatePoll`, `deletePoll`, `closePoll`, `createPollOption`, `castPollVote`, `addPollAnswer`, `removePollVote` and `queryPollVotes` return a `Result` instead of throwing; `deletePoll` and `deletePollOption` return a `Result`. +- The poll calls on `StreamChatClient` and `Channel` return a `Result` instead of throwing. - `CreatePollResponse`, `GetPollResponse` and `UpdatePollResponse` are replaced by `PollResponse`; `CreatePollOptionResponse`, `GetPollOptionResponse` and `UpdatePollOptionResponse` by `PollOptionResponse`; and `CastPollVoteResponse` and `RemovePollVoteResponse` by `PollVoteResponse`. - `queryPolls` and `queryPollVotes` take `limit` (10 when omitted), `next` and `prev` instead of `PaginationParams`, and their responses carry a `prev` cursor. - `Channel.castPollVote` and `removePollVote` return a failure instead of throwing an `ArgumentError` for an option or vote without an id; `updatePoll` and `updatePollOption` do the same for an option without one. diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index 5d95914843..3acda820b4 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -1581,7 +1581,7 @@ class Channel { /// Fetches one page of the votes and answers of the poll [pollId] matching [filter], ordered by [sort]. /// /// Up to [limit] votes are returned. The next page is fetched by passing the `next` cursor of a response as [next], - /// and the previous page by passing its `prev` cursor as [prev]; pass at most one of the two. + /// and the previous page by passing its `prev` cursor as [prev]. At most one of [next] and [prev] may be given. Future> queryPollVotes( String pollId, { PollVoteFilter? filter, diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 20a2405046..bc7c34f0fa 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -1720,7 +1720,7 @@ class StreamChatClient { /// Fetches one page of the polls matching [filter], ordered by [sort]. /// /// Up to [limit] polls are returned. The next page is fetched by passing the `next` cursor of a response as [next], - /// and the previous page by passing its `prev` cursor as [prev]; pass at most one of the two. + /// and the previous page by passing its `prev` cursor as [prev]. At most one of [next] and [prev] may be given. Future> queryPolls({ PollFilter? filter, List? sort, @@ -1732,7 +1732,7 @@ class StreamChatClient { /// Fetches one page of the votes and answers of the poll [pollId] matching [filter], ordered by [sort]. /// /// Up to [limit] votes are returned. The next page is fetched by passing the `next` cursor of a response as [next], - /// and the previous page by passing its `prev` cursor as [prev]; pass at most one of the two. + /// and the previous page by passing its `prev` cursor as [prev]. At most one of [next] and [prev] may be given. Future> queryPollVotes( String pollId, { PollVoteFilter? filter, diff --git a/packages/stream_chat/lib/src/core/models/converters/v1_json_converters.dart b/packages/stream_chat/lib/src/core/models/converters/v1_json_converters.dart index eec5e19d8e..cddcb9e352 100644 --- a/packages/stream_chat/lib/src/core/models/converters/v1_json_converters.dart +++ b/packages/stream_chat/lib/src/core/models/converters/v1_json_converters.dart @@ -130,7 +130,7 @@ class PollV1JsonConverter implements JsonConverter> { 'name': poll.name, 'description': poll.description, 'options': poll.options.map(_pollOptionToV1Json).toList(), - 'voting_visibility': poll.votingVisibility.value, + 'voting_visibility': poll.votingVisibility.rawType, 'enforce_unique_vote': poll.enforceUniqueVote, 'max_votes_allowed': poll.maxVotesAllowed, 'allow_user_suggested_options': poll.allowUserSuggestedOptions, diff --git a/packages/stream_chat/lib/src/core/models/poll.dart b/packages/stream_chat/lib/src/core/models/poll.dart index 91f405df96..d3387b4c4a 100644 --- a/packages/stream_chat/lib/src/core/models/poll.dart +++ b/packages/stream_chat/lib/src/core/models/poll.dart @@ -114,7 +114,7 @@ class Poll with _$Poll { /// The most recent votes across all options. /// /// Answers are not included; see [latestAnswers] for those. - List get latestVotes => [...latestVotesByOption.values.flattened]; + late final List latestVotes = [...latestVotesByOption.values.flattened]; /// The most recent answers left on this poll. @override @@ -131,12 +131,12 @@ class Poll with _$Poll { /// The votes of the current user. /// /// Answers are not included; see [ownAnswers] for those. - List get ownVotes => [...ownVotesAndAnswers.where((it) => !it.isAnswer)]; + late final List ownVotes = [...ownVotesAndAnswers.where((it) => !it.isAnswer)]; /// The answers of the current user. /// /// Votes are not included; see [ownVotes] for those. - List get ownAnswers => [...ownVotesAndAnswers.where((it) => it.isAnswer)]; + late final List ownAnswers = [...ownVotesAndAnswers.where((it) => it.isAnswer)]; /// The unique identifier of the user who created this poll. @override @@ -270,7 +270,7 @@ class PollFilterField extends FilterField { /// **Supported operators:** `$eq` static final votingVisibility = PollFilterField( 'voting_visibility', - (it) => it.votingVisibility.value, + (it) => it.votingVisibility.rawType, ); /// Filters polls by their creation date. diff --git a/packages/stream_chat/lib/src/core/models/response/poll_vote_response.dart b/packages/stream_chat/lib/src/core/models/response/poll_vote_response.dart index 151b4c3d75..3ed047a17a 100644 --- a/packages/stream_chat/lib/src/core/models/response/poll_vote_response.dart +++ b/packages/stream_chat/lib/src/core/models/response/poll_vote_response.dart @@ -18,6 +18,8 @@ class PollVoteResponse with _$PollVoteResponse { final String duration; /// The vote that was cast or removed. + /// + /// Every vote on a message is returned; this is null only for a response that carries no vote. @override final PollVote? vote; } diff --git a/packages/stream_chat/lib/src/core/models/voting_visibility.dart b/packages/stream_chat/lib/src/core/models/voting_visibility.dart index d3853a6b39..bea1b6ec21 100644 --- a/packages/stream_chat/lib/src/core/models/voting_visibility.dart +++ b/packages/stream_chat/lib/src/core/models/voting_visibility.dart @@ -2,7 +2,7 @@ /// /// A visibility without a constant here can still be named by wrapping its /// value: `VotingVisibility('public')`. -extension type const VotingVisibility(String value) implements String { +extension type const VotingVisibility(String rawType) implements String { /// Votes are counted, but who cast them is hidden from other users. static const anonymous = VotingVisibility('anonymous'); diff --git a/packages/stream_chat/lib/src/repository/polls_repository.dart b/packages/stream_chat/lib/src/repository/polls_repository.dart index b61a1225ab..f8c9f9c017 100644 --- a/packages/stream_chat/lib/src/repository/polls_repository.dart +++ b/packages/stream_chat/lib/src/repository/polls_repository.dart @@ -135,7 +135,7 @@ class PollsRepository { /// Fetches one page of the polls matching [filter], ordered by [sort]. /// - /// [next] and [prev] are the cursors a previous page returned; pass at most one of them. + /// [next] and [prev] are the cursors a previous page returned; at most one of them may be given. Future> queryPolls({ PollFilter? filter, List? sort, @@ -158,7 +158,7 @@ class PollsRepository { /// Fetches one page of the votes and answers of the poll [pollId] matching [filter], ordered by [sort]. /// - /// [next] and [prev] are the cursors a previous page returned; pass at most one of them. + /// [next] and [prev] are the cursors a previous page returned; at most one of them may be given. Future> queryPollVotes( String pollId, { PollVoteFilter? filter, diff --git a/packages/stream_chat/test/src/core/models/poll_test.dart b/packages/stream_chat/test/src/core/models/poll_test.dart index 0c88907fce..e733b477cb 100644 --- a/packages/stream_chat/test/src/core/models/poll_test.dart +++ b/packages/stream_chat/test/src/core/models/poll_test.dart @@ -28,6 +28,25 @@ void main() { expect(poll.copyWith(maxVotesAllowed: null).maxVotesAllowed, isNull); }); + test('Poll.copyWith generates a new id when the id is set to null', () { + final poll = createTestPoll(id: 'poll-id', name: 'Lunch?'); + + final copy = poll.copyWith(id: null); + + expect(copy.id, isNotEmpty); + expect(copy.id, isNot('poll-id')); + }); + + test('Poll.copyWith sets a timestamp set to null to the current time', () { + final poll = createTestPoll(name: 'Lunch?', createdAt: DateTime.utc(2023), updatedAt: DateTime.utc(2023)); + final before = DateTime.now(); + + final copy = poll.copyWith(createdAt: null, updatedAt: null); + + expect(copy.createdAt.isBefore(before), isFalse); + expect(copy.updatedAt.isBefore(before), isFalse); + }); + test('Poll.latestVotes holds the latest votes of every option', () { final pizza = _vote(id: 'v1', optionId: 'pizza'); final sushi = _vote(id: 'v2', optionId: 'sushi'); diff --git a/packages/stream_chat/test/src/core/models/poll_vote_test.dart b/packages/stream_chat/test/src/core/models/poll_vote_test.dart index 02c315b22f..1a0286a6ca 100644 --- a/packages/stream_chat/test/src/core/models/poll_vote_test.dart +++ b/packages/stream_chat/test/src/core/models/poll_vote_test.dart @@ -17,6 +17,16 @@ void main() { expect(vote.isAnswer, isFalse); }); + test('PollVote.copyWith sets a timestamp set to null to the current time', () { + final vote = createTestPollVote(optionId: 'pizza', createdAt: DateTime.utc(2023), updatedAt: DateTime.utc(2023)); + final before = DateTime.now(); + + final copy = vote.copyWith(createdAt: null, updatedAt: null); + + expect(copy.createdAt.isBefore(before), isFalse); + expect(copy.updatedAt.isBefore(before), isFalse); + }); + test('PollVoteSortField.id orders alphabetically', () { expectOrders( PollVoteSortField.id, diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index e93a6c141f..57d89bed9d 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -8,12 +8,15 @@ - The sort argument on every list controller is typed `List`, `List` and so on, rather than `SortOrder`. On the controllers that have a default, the `sort` property is non-nullable: leaving it unset selects that default, and `ChannelSort.empty` / `MemberSort.empty` / … query with the ordering the API applies on its own. - `defaultChannelListSort`, `defaultMemberListSort`, `defaultUserListSort`, `defaultDraftListSort`, `defaultMessageReminderListSort` and `defaultPollVoteListSort` are removed. Use `ChannelSort.defaultSort`, `MemberSort.defaultSort` and so on from `stream_chat`. Leaving a controller's sort unset still applies the same ordering. - `StreamChannelListController.muteChannel` and `unmuteChannel` return a `Result` instead of throwing. -- `StreamPollVoteListController` pages with the poll vote query's `limit` and `next` cursor rather than `PaginationParams`. 🐞 Fixed - A list controller sorted each loaded page with an unstable sort, so rows it called equal — members sharing a `created_at`, say — were reordered on every page append. They keep the order they arrived in now. +🔄 Changed + +- `StreamPollVoteListController` pages with the poll vote query's `limit` and `next` cursor rather than `PaginationParams`. + ## 10.4.0 🔄 Changed diff --git a/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart b/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart index 49894df190..bbb392b388 100644 --- a/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart @@ -100,6 +100,34 @@ void main() { expect(controller.value, PagedValue(items: [_vote('v1')], nextPageKey: 'cursor', error: error)); }); + + test( + 'StreamPollVoteListController.loadMore keeps the loaded votes and records an error the channel throws', + () async { + final channel = _channelAnswering([ + Result.success(_page([_vote('v1')], next: 'cursor')), + ]); + final controller = _controller(channel); + await controller.doInitialLoad(); + final thrown = StateError('Channel is not initialized'); + when( + () => channel.queryPollVotes( + any(), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + limit: any(named: 'limit'), + next: any(named: 'next'), + prev: any(named: 'prev'), + ), + ).thenThrow(thrown); + + await controller.loadMore('cursor'); + + final success = controller.value.asSuccess; + expect(success.items, [_vote('v1')]); + expect(success.error, isA().having((it) => it.cause, 'cause', thrown)); + }, + ); } PollVote _vote(String id) => PollVote( diff --git a/packages/stream_chat_persistence/lib/src/converter/voting_visibility_converter.dart b/packages/stream_chat_persistence/lib/src/converter/voting_visibility_converter.dart index 389b55b154..6ac4e93c29 100644 --- a/packages/stream_chat_persistence/lib/src/converter/voting_visibility_converter.dart +++ b/packages/stream_chat_persistence/lib/src/converter/voting_visibility_converter.dart @@ -10,5 +10,5 @@ class VotingVisibilityConverter extends TypeConverter VotingVisibility fromSql(String fromDb) => VotingVisibility(fromDb); @override - String toSql(VotingVisibility value) => value.value; + String toSql(VotingVisibility value) => value.rawType; } From 42e56df244f7400d7913eea3fe6388f8188cc98b Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 1 Oct 2026 16:34:43 +0200 Subject: [PATCH 04/32] refactor(llc): let the API reject poll updates with option ids missing Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 5 +- openapi-migration/09-users.md | 2 + openapi-migration/tool/generate_plan.py | 5 ++ packages/stream_chat/CHANGELOG.md | 2 +- .../lib/src/client/channel/channel.dart | 16 +++--- .../stream_chat/lib/src/client/client.dart | 32 +++++------ .../src/repository/mapper/polls_mapper.dart | 53 ++++++++----------- .../lib/src/repository/polls_repository.dart | 49 +++++++---------- .../src/client/client_poll_options_test.dart | 11 ++-- .../test/src/client/client_polls_test.dart | 15 ++++-- 10 files changed, 94 insertions(+), 96 deletions(-) diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index 1bac2e8e3d..514d2865bf 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -221,7 +221,7 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `CreatePollOptionResponse` / `GetPollOptionResponse` / `UpdatePollOptionResponse` | `PollOptionResponse` | `renamed` | Same fields: `duration` and `pollOption` | | `CastPollVoteResponse` / `RemovePollVoteResponse` | `PollVoteResponse` | `renamed` | `vote` is nullable | | `queryPolls(pagination: PaginationParams(limit: l, next: n))` / `queryPollVotes(…)` | `queryPolls(limit: l, next: n)` / `queryPollVotes(…)` | `retyped` | `limit` is still 10 when omitted. Pass a response's `prev` as `prev` to page backwards. The other `PaginationParams` fields never had an effect on these queries | -| `Channel.castPollVote` / `.removePollVote` throwing `ArgumentError` | a `Failure` carrying a `StreamClientException` | `retyped` | For an option or vote without an id. `updatePoll` and `updatePollOption` fail the same way for an option without one | +| `Channel.castPollVote` / `.removePollVote` throwing `ArgumentError` | a `Failure` carrying a `StreamClientException` | `retyped` | For an option or vote without an id | | `VotingVisibility` (enum) | `VotingVisibility` (extension type over `String`) | `retyped` | `VotingVisibility.public` and `.anonymous` are unchanged; a `switch` needs a default arm, and `.name` / `.values` are gone — read `.rawType` | | `Poll.fromJson` / `.toJson`, `PollOption.fromJson` / `.toJson`, `PollVote.fromJson` / `.toJson` | — | `removed` | The models are plain classes; construct them directly. `Message.poll` and the poll events still decode from the same keys | | — | `PollOption.fromData` / `.toData` | `added` | Read and write only the format the offline database stores; not a codec for API payloads | @@ -798,8 +798,7 @@ await channel.queryPollVotes(pollId, limit: 10, next: cursor); ``` **A missing id is a failure, not an `ArgumentError`.** `Channel.castPollVote` with an option that has no id, and -`removePollVote` with a vote that has none, return a `Failure` carrying a `StreamClientException`. `updatePoll` and -`updatePollOption` do the same for an option without an id, without sending anything. +`removePollVote` with a vote that has none, return a `Failure` carrying a `StreamClientException`. **`VotingVisibility` is an extension type over its wire string.** `VotingVisibility.public` and `VotingVisibility.anonymous` read the same, and a visibility the SDK does not name is kept rather than rejected. diff --git a/openapi-migration/09-users.md b/openapi-migration/09-users.md index 3df0231010..e0da9d6499 100644 --- a/openapi-migration/09-users.md +++ b/openapi-migration/09-users.md @@ -51,6 +51,7 @@ - Every other group depends on the `User` decision. - User data arrives over the WebSocket on nearly every event. - Landing `UserResponse` -> `User` unblocks the two fields [08](08-moderation-and-blocklists.md) had to drop from `MuteUsersResponse`: the `mutes` the call created and the `ownUser` it left behind. Adding them is additive for anyone reading the response, so revisit them here rather than leaving them dropped for good. +- Until then, a user mapped from `UserResponse` and the same user decoded from v1 JSON carry different `extraData`: the v1 path keeps `blocked_user_ids`, `deleted_at`, `deactivated_at` and `revoke_tokens_issued_before`, the mapper drops them. Equality includes `extraData`, so a `Poll` from a REST call (its `createdBy`, its votes' `user`) and the same poll from an event compare unequal. ## Definition of done @@ -72,3 +73,4 @@ the table in `README.md`. - [ ] `user_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. diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 765929b96c..52b857418d 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -573,12 +573,17 @@ def match(path): 'had to drop from `MuteUsersResponse`: the `mutes` the call created and the `ownUser` it left ' 'behind. Adding them is additive for anyone reading the response, so revisit them here rather ' 'than leaving them dropped for good.', + 'Until then, a user mapped from `UserResponse` and the same user decoded from v1 JSON carry different ' + '`extraData`: the v1 path keeps `blocked_user_ids`, `deleted_at`, `deactivated_at` and ' + '`revoke_tokens_issued_before`, the mapper drops them. Equality includes `extraData`, so a `Poll` from a ' + 'REST call (its `createdBy`, its votes\' `user`) and the same poll from an event compare unequal.', ], done=DONE + ( '- [ ] Temporary adapters owned by this group (`DeviceV1JsonConverter`) are deleted and removed from\n' ' the table in `README.md`.\n' '- [ ] `user_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' ), ), dict( diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 1aa592e5c1..256d158aea 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -82,7 +82,7 @@ - The poll calls on `StreamChatClient` and `Channel` return a `Result` instead of throwing. - `CreatePollResponse`, `GetPollResponse` and `UpdatePollResponse` are replaced by `PollResponse`; `CreatePollOptionResponse`, `GetPollOptionResponse` and `UpdatePollOptionResponse` by `PollOptionResponse`; and `CastPollVoteResponse` and `RemovePollVoteResponse` by `PollVoteResponse`. - `queryPolls` and `queryPollVotes` take `limit` (10 when omitted), `next` and `prev` instead of `PaginationParams`, and their responses carry a `prev` cursor. -- `Channel.castPollVote` and `removePollVote` return a failure instead of throwing an `ArgumentError` for an option or vote without an id; `updatePoll` and `updatePollOption` do the same for an option without one. +- `Channel.castPollVote` and `removePollVote` return a failure instead of throwing an `ArgumentError` for an option or vote without an id. - `VotingVisibility` is an extension type over its wire string instead of an enum. - `Poll`, `PollOption`, `PollVote` and the poll responses no longer decode from or encode to JSON; `PollOption` gains `fromData` and `toData`, which read and write only the format `stream_chat_persistence` stores it in. - The poll responses are immutable, built through a const constructor, and their `duration` is a non-nullable `String`; `PollVoteResponse.vote` is nullable. diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index 3acda820b4..56364bd47a 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -1454,11 +1454,7 @@ class Channel { }) async { _checkInitialized(); final created = await _pollLock.synchronized(() => _client.createPoll(poll)); - - return switch (created) { - Success(data: PollResponse(poll: final createdPoll)) => _sendPollMessage(createdPoll, messageText), - Failure(:final error, :final stackTrace) => Result.failure(error, stackTrace), - }; + return created.flatMapAsync((response) => _sendPollMessage(response.poll, messageText)); } Future> _sendPollMessage(Poll poll, String? messageText) async { @@ -1473,10 +1469,10 @@ class Channel { } } - /// Replaces the settings and options of [poll] with the ones it carries. + /// Updates [poll] to match the settings and options it carries. /// - /// An option left out of [poll] is removed from it. Every option must already exist and carry its id; if one does - /// not, the call returns a failure without updating the poll. New options are added with [createPollOption]. + /// [Poll.options] becomes the poll's full list of options, each matched by its id, so an existing option missing + /// from it is deleted. New options are added through [createPollOption]. Future> updatePoll(Poll poll) { _checkInitialized(); return _pollLock.synchronized(() => _client.updatePoll(poll)); @@ -1536,7 +1532,7 @@ class Channel { ); } - /// Leaves [answerText] as an answer on [poll], which was sent in [message]. + /// Adds [answerText] as an answer to [poll], which was sent in [message]. /// /// Each user has one answer per poll; a new one replaces the previous one. Future> addPollAnswer( @@ -1578,7 +1574,7 @@ class Channel { ); } - /// Fetches one page of the votes and answers of the poll [pollId] matching [filter], ordered by [sort]. + /// Fetches one page of the votes and answers matching [filter] on the poll with the id [pollId], ordered by [sort]. /// /// Up to [limit] votes are returned. The next page is fetched by passing the `next` cursor of a response as [next], /// and the previous page by passing its `prev` cursor as [prev]. At most one of [next] and [prev] may be given. diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 0504222348..4a547b3803 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -1649,16 +1649,16 @@ class StreamChatClient { /// shown to the members of a channel once it is sent in a message; [Channel.sendPoll] does both. Future> createPoll(Poll poll) => _pollsRepository.createPoll(poll); - /// Fetches the poll [pollId]. + /// Fetches the poll with the id [pollId]. Future> getPoll(String pollId) => _pollsRepository.getPoll(pollId); - /// Replaces the settings and options of [poll] with the ones it carries. + /// Updates [poll] to match the settings and options it carries. /// - /// An option left out of [poll] is removed from it. Every option must already exist and carry its id; if one does - /// not, the call returns a failure without updating the poll. New options are added with [createPollOption]. + /// [Poll.options] becomes the poll's full list of options, each matched by its id, so an existing option missing + /// from it is deleted. New options are added through [createPollOption]. Future> updatePoll(Poll poll) => _pollsRepository.updatePoll(poll); - /// Changes some of the settings of the poll [pollId], leaving the others as they are. + /// Changes some of the settings of the poll with the id [pollId], leaving the others as they are. /// /// The fields in [set] take their new values and the fields named in [unset] are cleared. A key that is not a /// setting of the poll is stored in its custom data. @@ -1668,13 +1668,13 @@ class StreamChatClient { List? unset, }) => _pollsRepository.partialUpdatePoll(pollId, set: set, unset: unset); - /// Deletes the poll [pollId]. + /// Deletes the poll with the id [pollId]. Future> deletePoll(String pollId) => _pollsRepository.deletePoll(pollId); - /// Closes the poll [pollId], so it accepts no more votes or answers. + /// Closes the poll with the id [pollId], so it accepts no more votes or answers. Future> closePoll(String pollId) => partialUpdatePoll(pollId, set: {'is_closed': true}); - /// Adds [option] to the poll [pollId]. + /// Adds [option] to the poll with the id [pollId]. /// /// The option gets a new id, which the returned option carries. Future> createPollOption( @@ -1682,27 +1682,27 @@ class StreamChatClient { PollOption option, ) => _pollsRepository.createPollOption(pollId, option); - /// Fetches the option [optionId] of the poll [pollId]. + /// Fetches an option of a poll, identified by [optionId] and [pollId]. Future> getPollOption( String pollId, String optionId, ) => _pollsRepository.getPollOption(pollId, optionId); - /// Replaces the text and custom data of [option], an existing option of the poll [pollId]. + /// Replaces the text and custom data of [option], an existing option of the poll with the id [pollId]. /// - /// The [option] is found by its id; if it has none, the call returns a failure without updating anything. + /// The [option] is found by its id. Future> updatePollOption( String pollId, PollOption option, ) => _pollsRepository.updatePollOption(pollId, option); - /// Removes the option [optionId] from the poll [pollId]. + /// Removes an option from a poll, identified by [optionId] and [pollId]. Future> deletePollOption( String pollId, String optionId, ) => _pollsRepository.deletePollOption(pollId, optionId); - /// Votes for the option [optionId] of the poll [pollId], which was sent in the message [messageId]. + /// Votes for an option of a poll sent in a message, identified by [optionId], [pollId] and [messageId]. /// /// If the poll allows only one vote per user, the vote replaces the previous one. Future> castPollVote( @@ -1711,7 +1711,7 @@ class StreamChatClient { required String optionId, }) => _pollsRepository.castPollVote(messageId, pollId, optionId: optionId); - /// Leaves [answerText] as an answer on the poll [pollId], which was sent in the message [messageId]. + /// Adds [answerText] as an answer to a poll sent in a message, identified by [pollId] and [messageId]. /// /// Each user has one answer per poll; a new one replaces the previous one. Future> addPollAnswer( @@ -1720,7 +1720,7 @@ class StreamChatClient { required String answerText, }) => _pollsRepository.addPollAnswer(messageId, pollId, answerText: answerText); - /// Removes the vote or answer [voteId] from the poll [pollId], which was sent in the message [messageId]. + /// Removes a vote or answer from a poll sent in a message, identified by [voteId], [pollId] and [messageId]. Future> removePollVote( String messageId, String pollId, @@ -1739,7 +1739,7 @@ class StreamChatClient { String? prev, }) => _pollsRepository.queryPolls(filter: filter, sort: sort, limit: limit, next: next, prev: prev); - /// Fetches one page of the votes and answers of the poll [pollId] matching [filter], ordered by [sort]. + /// Fetches one page of the votes and answers matching [filter] on the poll with the id [pollId], ordered by [sort]. /// /// Up to [limit] votes are returned. The next page is fetched by passing the `next` cursor of a response as [next], /// and the previous page by passing its `prev` cursor as [prev]. At most one of [next] and [prev] may be given. 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 8feadb680d..5c6bf8ad55 100644 --- a/packages/stream_chat/lib/src/repository/mapper/polls_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/polls_mapper.dart @@ -132,25 +132,20 @@ extension PollRequestMapper on Poll { custom: extraData, ); - /// Converts this poll into an [api.UpdatePollRequest], or null when one of its [options] has no id. - api.UpdatePollRequest? toUpdatePollRequest() { - final options = [for (final option in this.options) option.toPollOptionRequest()]; - if (options.contains(null)) return null; - - return api.UpdatePollRequest( - id: id, - name: name, - description: description, - options: options.nonNulls.toList(), - votingVisibility: api.UpdatePollRequestVotingVisibility.fromJson(votingVisibility), - enforceUniqueVote: enforceUniqueVote, - maxVotesAllowed: maxVotesAllowed, - allowAnswers: allowAnswers, - allowUserSuggestedOptions: allowUserSuggestedOptions, - isClosed: isClosed, - custom: extraData, - ); - } + /// Converts this poll into an [api.UpdatePollRequest]. + api.UpdatePollRequest toUpdatePollRequest() => api.UpdatePollRequest( + id: id, + name: name, + description: description, + options: [for (final option in options) option.toPollOptionRequest()], + votingVisibility: api.UpdatePollRequestVotingVisibility.fromJson(votingVisibility), + enforceUniqueVote: enforceUniqueVote, + maxVotesAllowed: maxVotesAllowed, + allowAnswers: allowAnswers, + allowUserSuggestedOptions: allowUserSuggestedOptions, + isClosed: isClosed, + custom: extraData, + ); } /// Maps a [PollOption] to the generated requests that create and update it. @@ -158,12 +153,10 @@ extension PollOptionRequestMapper on PollOption { /// Converts this option into an [api.PollOptionInput], an option of a poll being created. api.PollOptionInput toPollOptionInput() => api.PollOptionInput(text: text, custom: extraData); - /// Converts this option into an [api.PollOptionRequest], an option of a poll being updated, or null when it has - /// no [id]. - api.PollOptionRequest? toPollOptionRequest() => switch (id) { - final id? => api.PollOptionRequest(id: id, text: text, custom: extraData), - null => null, - }; + /// Converts this option into an [api.PollOptionRequest], an option of a poll being updated. + /// + /// An option without an [id] is sent with an empty one. + api.PollOptionRequest toPollOptionRequest() => api.PollOptionRequest(id: id ?? '', text: text, custom: extraData); /// Converts this option into an [api.CreatePollOptionRequest]. api.CreatePollOptionRequest toCreatePollOptionRequest() => api.CreatePollOptionRequest( @@ -171,11 +164,11 @@ extension PollOptionRequestMapper on PollOption { custom: extraData, ); - /// Converts this option into an [api.UpdatePollOptionRequest], or null when it has no [id]. - api.UpdatePollOptionRequest? toUpdatePollOptionRequest() => switch (id) { - final id? => api.UpdatePollOptionRequest(id: id, text: text, custom: extraData), - null => null, - }; + /// Converts this option into an [api.UpdatePollOptionRequest]. + /// + /// An option without an [id] is sent with an empty one. + api.UpdatePollOptionRequest toUpdatePollOptionRequest() => + api.UpdatePollOptionRequest(id: id ?? '', text: text, custom: extraData); } Map _withoutKeys(Map custom, List keys) => diff --git a/packages/stream_chat/lib/src/repository/polls_repository.dart b/packages/stream_chat/lib/src/repository/polls_repository.dart index f8c9f9c017..b15c0ce654 100644 --- a/packages/stream_chat/lib/src/repository/polls_repository.dart +++ b/packages/stream_chat/lib/src/repository/polls_repository.dart @@ -1,4 +1,4 @@ -import 'package:stream_core/stream_core.dart' show PatternMatching, Result, Sort, StreamClientException; +import 'package:stream_core/stream_core.dart' show PatternMatching, Result, Sort; import '../../open_api/api.dart' as api; import '../core/models/poll.dart'; @@ -26,26 +26,23 @@ class PollsRepository { return result.map((response) => response.toModel()); } - /// Fetches the poll [pollId]. + /// Fetches the poll with the id [pollId]. Future> getPoll(String pollId) async { final result = await _api.getPoll(pollId: pollId); return result.map((response) => response.toModel()); } - /// Replaces the settings and options of [poll]. + /// Updates [poll] to match the settings and options it carries. /// - /// Every option must have an id; without one the call fails without a request. + /// [Poll.options] becomes the poll's full list of options, each matched by its id. Future> updatePoll(Poll poll) async { - final request = poll.toUpdatePollRequest(); - if (request == null) return const Result.failure(StreamClientException(message: _optionsWithoutIdMessage)); - - final result = await _api.updatePoll(updatePollRequest: request); + final result = await _api.updatePoll(updatePollRequest: poll.toUpdatePollRequest()); return result.map((response) => response.toModel()); } - /// Sets the fields in [set] and removes the fields named in [unset] on the poll [pollId]. + /// Sets the fields in [set] and removes the fields named in [unset] on the poll with the id [pollId]. Future> partialUpdatePoll( String pollId, { Map? set, @@ -59,14 +56,14 @@ class PollsRepository { return result.map((response) => response.toModel()); } - /// Deletes the poll [pollId]. + /// Deletes the poll with the id [pollId]. Future> deletePoll(String pollId) async { final result = await _api.deletePoll(pollId: pollId); return result.ignoreValue(); } - /// Adds [option] to the poll [pollId]. + /// Adds [option] to the poll with the id [pollId]. Future> createPollOption(String pollId, PollOption option) async { final result = await _api.createPollOption( pollId: pollId, @@ -76,40 +73,40 @@ class PollsRepository { return result.map((response) => response.toModel()); } - /// Fetches the option [optionId] of the poll [pollId]. + /// Fetches an option of a poll, identified by [optionId] and [pollId]. Future> getPollOption(String pollId, String optionId) async { final result = await _api.getPollOption(pollId: pollId, optionId: optionId); return result.map((response) => response.toModel()); } - /// Replaces the text and custom data of [option] in the poll [pollId]. + /// Replaces the text and custom data of [option] in the poll with the id [pollId]. /// - /// The [option] must have an id; without one the call fails without a request. + /// The [option] is matched by its id. Future> updatePollOption(String pollId, PollOption option) async { - final request = option.toUpdatePollOptionRequest(); - if (request == null) return const Result.failure(StreamClientException(message: _optionWithoutIdMessage)); - - final result = await _api.updatePollOption(pollId: pollId, updatePollOptionRequest: request); + final result = await _api.updatePollOption( + pollId: pollId, + updatePollOptionRequest: option.toUpdatePollOptionRequest(), + ); return result.map((response) => response.toModel()); } - /// Removes the option [optionId] from the poll [pollId]. + /// Removes an option from a poll, identified by [optionId] and [pollId]. Future> deletePollOption(String pollId, String optionId) async { final result = await _api.deletePollOption(pollId: pollId, optionId: optionId); return result.ignoreValue(); } - /// Casts a vote for the option [optionId] of the poll [pollId], sent in the message [messageId]. + /// Casts a vote for an option of a poll sent in a message, identified by [optionId], [pollId] and [messageId]. Future> castPollVote( String messageId, String pollId, { required String optionId, }) => _castPollVote(messageId, pollId, api.VoteData(optionId: optionId)); - /// Leaves [answerText] as an answer on the poll [pollId], sent in the message [messageId]. + /// Adds [answerText] as an answer to a poll sent in a message, identified by [pollId] and [messageId]. Future> addPollAnswer( String messageId, String pollId, { @@ -126,7 +123,7 @@ class PollsRepository { return result.map((response) => response.toModel()); } - /// Removes the vote or answer [voteId] from the poll [pollId], sent in the message [messageId]. + /// Removes a vote or answer from a poll sent in a message, identified by [voteId], [pollId] and [messageId]. Future> removePollVote(String messageId, String pollId, String voteId) async { final result = await _api.deletePollVote(messageId: messageId, pollId: pollId, voteId: voteId); @@ -156,7 +153,7 @@ class PollsRepository { return result.map((response) => response.toModel()); } - /// Fetches one page of the votes and answers of the poll [pollId] matching [filter], ordered by [sort]. + /// Fetches one page of the votes and answers matching [filter] on the poll with the id [pollId], ordered by [sort]. /// /// [next] and [prev] are the cursors a previous page returned; at most one of them may be given. Future> queryPollVotes( @@ -186,9 +183,3 @@ api.SortParamRequest _sortParam(Sort sort) { final json = sort.toJson(); return api.SortParamRequest(field: json['field'] as String?, direction: json['direction'] as int?); } - -const _optionWithoutIdMessage = 'The poll option has no id. Only an option that was already created can be updated.'; - -const _optionsWithoutIdMessage = - 'An option of the poll has no id. Every option of an updated poll must already exist; add new options with ' - '`createPollOption`.'; diff --git a/packages/stream_chat/test/src/client/client_poll_options_test.dart b/packages/stream_chat/test/src/client/client_poll_options_test.dart index 50b26a0962..6c47d8129e 100644 --- a/packages/stream_chat/test/src/client/client_poll_options_test.dart +++ b/packages/stream_chat/test/src/client/client_poll_options_test.dart @@ -104,14 +104,19 @@ void main() { expect(result, const Result.success(_pizzaResponse)); }); - test('StreamChatClient.updatePollOption returns a failure without a request when the option has no id', () async { + test('StreamChatClient.updatePollOption sends an option without an id with an empty id', () async { final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.updatePollOption( + pollId: 'poll-id', + updatePollOptionRequest: const api.UpdatePollOptionRequest(id: '', text: 'Pizza', custom: {}), + ), + ).thenAnswer((_) async => const Result.success(_generatedPizzaResponse)); final client = pollsClient(defaultApi); final result = await client.updatePollOption('poll-id', const PollOption(text: 'Pizza')); - expect(result.exceptionOrNull(), isA()); - verifyZeroInteractions(defaultApi); + expect(result, const Result.success(_pizzaResponse)); }); test('StreamChatClient.updatePollOption returns the failure without throwing', () async { diff --git a/packages/stream_chat/test/src/client/client_polls_test.dart b/packages/stream_chat/test/src/client/client_polls_test.dart index e937d36c60..6259999002 100644 --- a/packages/stream_chat/test/src/client/client_polls_test.dart +++ b/packages/stream_chat/test/src/client/client_polls_test.dart @@ -156,14 +156,21 @@ void main() { expect(result, Result.success(pollResponse)); }); - test('StreamChatClient.updatePoll returns a failure without a request when an option has no id', () async { + test('StreamChatClient.updatePoll sends an option without an id with an empty id', () async { final defaultApi = pollsDefaultApi(); + when( + () => defaultApi.updatePoll(updatePollRequest: any(named: 'updatePollRequest')), + ).thenAnswer((_) async => Result.success(generatedPollResponse)); final client = pollsClient(defaultApi); - final result = await client.updatePoll(_newPoll()); + await client.updatePoll(_newPoll().copyWith(options: const [PollOption(text: 'Pizza')])); - expect(result.exceptionOrNull(), isA()); - verifyZeroInteractions(defaultApi); + final request = + verify( + () => defaultApi.updatePoll(updatePollRequest: captureAny(named: 'updatePollRequest')), + ).captured.single + as api.UpdatePollRequest; + expect(request.options, const [api.PollOptionRequest(id: '', text: 'Pizza', custom: {})]); }); test('StreamChatClient.updatePoll returns the failure without throwing', () async { From ad777cde4731dbbcec676ecedfdff2151848088f Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 1 Oct 2026 18:08:36 +0200 Subject: [PATCH 05/32] refactor(llc): keep the v10 copyWith on Poll, PollOption and PollVote Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 15 +- packages/stream_chat/CHANGELOG.md | 1 - .../stream_chat/lib/src/core/models/poll.dart | 58 +++++- .../lib/src/core/models/poll.freezed.dart | 171 +----------------- .../lib/src/core/models/poll_option.dart | 19 +- .../src/core/models/poll_option.freezed.dart | 44 ----- .../lib/src/core/models/poll_vote.dart | 23 ++- .../src/core/models/poll_vote.freezed.dart | 82 --------- .../src/core/models/poll_option_test.dart | 12 ++ .../test/src/core/models/poll_test.dart | 25 +-- .../test/src/core/models/poll_vote_test.dart | 10 +- 11 files changed, 138 insertions(+), 322 deletions(-) diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index 514d2865bf..9a8cdee4cb 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -227,7 +227,7 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | — | `PollOption.fromData` / `.toData` | `added` | Read and write only the format the offline database stores; not a codec for API payloads | | `CreatePollResponse()..poll = …` and the other response setters | `PollResponse(duration: …, poll: …)` | `retyped` | The responses are plain classes with a const constructor and final fields | | poll responses' `duration` (`String?`) | `String` | `retyped` | Always present; drop any `!` or `?? ''` | -| `Poll` / `PollOption` / `PollVote extends Equatable`, `props` | value `==`, plus `copyWith` | `removed` | `Poll` and `PollOption` now compare `extraData` too. `copyWith` sets a field passed as `null`: a `null` `Poll.id` generates a new id, and a `null` `createdAt` / `updatedAt` becomes the current time | +| `Poll` / `PollOption` / `PollVote extends Equatable`, `props` | value `==` | `removed` | `Poll` and `PollOption` now compare `extraData` too | | `Poll.ownVotes` / `.ownAnswers` / `.latestVotes` (late fields), `PollVote.isAnswer` (a field set in the constructor) | the same names, as getters | `retyped` | Computed on each read | | `StreamChatApi.polls` (`PollsApi`) | `StreamChatClient`'s poll methods | `removed` | The endpoints moved to the generated client | | _(more added per feature as PRs land)_ | | | | @@ -809,15 +809,10 @@ A `switch` over it needs a default arm, and `.name` / `.values` are gone — rea the poll events still decode from the same keys. `PollOption.fromData` and `toData` read and write the format the offline database stores; they are not a way to decode API responses. -**`Poll`, `PollOption` and `PollVote` no longer extend `Equatable`.** They still compare by value, `props` is gone, -and two changes follow: - -- `Poll` and `PollOption` now include `extraData` in `==`. Two polls that differ only in custom data are no longer - equal, so neither are the messages that carry them; a widget comparing them rebuilds when only custom data - changes. -- `copyWith` sets a field passed as `null` instead of keeping it — `poll.copyWith(description: null)` clears the - description. Fields with a default take it again: a `null` `Poll.id` generates a new id, and a `null` `createdAt` or - `updatedAt` becomes the current time. +**`Poll`, `PollOption` and `PollVote` no longer extend `Equatable`.** They still compare by value and `props` is gone. +`Poll` and `PollOption` now include `extraData` in `==`: two polls that differ only in custom data are no longer +equal, so neither are the messages that carry them, and a widget comparing them rebuilds when only custom data +changes. **`StreamChatApi.polls` is removed.** Call the poll methods on `StreamChatClient` or `Channel` instead. diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 256d158aea..49739fc5cd 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -87,7 +87,6 @@ - `Poll`, `PollOption`, `PollVote` and the poll responses no longer decode from or encode to JSON; `PollOption` gains `fromData` and `toData`, which read and write only the format `stream_chat_persistence` stores it in. - The poll responses are immutable, built through a const constructor, and their `duration` is a non-nullable `String`; `PollVoteResponse.vote` is nullable. - `Poll`, `PollOption` and `PollVote` no longer extend `Equatable`, so `props` is removed; they still compare by value, now including `extraData`. -- `copyWith` on `Poll`, `PollOption` and `PollVote` sets a field passed as `null` instead of keeping it; on `Poll` a `null` `id` generates a new one, and a `null` `createdAt` or `updatedAt` on `Poll` and `PollVote` becomes the current time. - `StreamChatApi.polls` is removed; its endpoints are reached through `StreamChatClient`. 🐞 Fixed diff --git a/packages/stream_chat/lib/src/core/models/poll.dart b/packages/stream_chat/lib/src/core/models/poll.dart index d3387b4c4a..1f1678c75e 100644 --- a/packages/stream_chat/lib/src/core/models/poll.dart +++ b/packages/stream_chat/lib/src/core/models/poll.dart @@ -11,12 +11,18 @@ import 'voting_visibility.dart'; part 'poll.freezed.dart'; +class _NullConst { + const _NullConst(); +} + +const _nullConst = _NullConst(); + /// A question with a set of options that the members of a channel vote on. /// /// A poll is sent in a message. Besides its settings it carries a summary of /// the votes so far: the counts per option, the latest votes and answers, and /// the votes of the current user. -@freezed +@Freezed(copyWith: false) class Poll with _$Poll { /// Creates a new [Poll]. /// @@ -114,6 +120,7 @@ class Poll with _$Poll { /// The most recent votes across all options. /// /// Answers are not included; see [latestAnswers] for those. + @override late final List latestVotes = [...latestVotesByOption.values.flattened]; /// The most recent answers left on this poll. @@ -131,11 +138,13 @@ class Poll with _$Poll { /// The votes of the current user. /// /// Answers are not included; see [ownAnswers] for those. + @override late final List ownVotes = [...ownVotesAndAnswers.where((it) => !it.isAnswer)]; /// The answers of the current user. /// /// Votes are not included; see [ownVotes] for those. + @override late final List ownAnswers = [...ownVotesAndAnswers.where((it) => it.isAnswer)]; /// The unique identifier of the user who created this poll. @@ -158,6 +167,53 @@ class Poll with _$Poll { @override final Map extraData; + /// Creates a copy of [Poll] with specified attributes overridden. + Poll copyWith({ + String? id, + String? name, + String? description, + List? options, + VotingVisibility? votingVisibility, + bool? enforceUniqueVote, + Object? maxVotesAllowed = _nullConst, + bool? allowUserSuggestedOptions, + bool? allowAnswers, + bool? isClosed, + Map? voteCountsByOption, + List? ownVotesAndAnswers, + int? voteCount, + int? answersCount, + Map>? latestVotesByOption, + List? latestAnswers, + String? createdById, + User? createdBy, + DateTime? createdAt, + DateTime? updatedAt, + Map? extraData, + }) => Poll( + id: id ?? this.id, + name: name ?? this.name, + description: description ?? this.description, + options: options ?? this.options, + votingVisibility: votingVisibility ?? this.votingVisibility, + enforceUniqueVote: enforceUniqueVote ?? this.enforceUniqueVote, + maxVotesAllowed: maxVotesAllowed == _nullConst ? this.maxVotesAllowed : maxVotesAllowed as int?, + allowUserSuggestedOptions: allowUserSuggestedOptions ?? this.allowUserSuggestedOptions, + allowAnswers: allowAnswers ?? this.allowAnswers, + isClosed: isClosed ?? this.isClosed, + voteCountsByOption: voteCountsByOption ?? this.voteCountsByOption, + ownVotesAndAnswers: ownVotesAndAnswers ?? this.ownVotesAndAnswers, + voteCount: voteCount ?? this.voteCount, + answersCount: answersCount ?? this.answersCount, + latestVotesByOption: latestVotesByOption ?? this.latestVotesByOption, + latestAnswers: latestAnswers ?? this.latestAnswers, + createdById: createdById ?? this.createdById, + createdBy: createdBy ?? this.createdBy, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + extraData: extraData ?? this.extraData, + ); + /// The keys a poll carries besides its custom data. static const topLevelFields = [ 'id', diff --git a/packages/stream_chat/lib/src/core/models/poll.freezed.dart b/packages/stream_chat/lib/src/core/models/poll.freezed.dart index c573136c33..60829461c4 100644 --- a/packages/stream_chat/lib/src/core/models/poll.freezed.dart +++ b/packages/stream_chat/lib/src/core/models/poll.freezed.dart @@ -26,21 +26,18 @@ mixin _$Poll { int get answersCount; Map get voteCountsByOption; Map> get latestVotesByOption; + List get latestVotes; List get latestAnswers; List get ownVotesAndAnswers; int get voteCount; + List get ownVotes; + List get ownAnswers; String? get createdById; User? get createdBy; DateTime get createdAt; DateTime get updatedAt; Map get extraData; - /// Create a copy of Poll - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @pragma('vm:prefer-inline') - $PollCopyWith get copyWith => _$PollCopyWithImpl(this as Poll, _$identity); - @override bool operator ==(Object other) { return identical(this, other) || @@ -60,9 +57,12 @@ mixin _$Poll { (identical(other.answersCount, answersCount) || other.answersCount == answersCount) && const DeepCollectionEquality().equals(other.voteCountsByOption, voteCountsByOption) && const DeepCollectionEquality().equals(other.latestVotesByOption, latestVotesByOption) && + const DeepCollectionEquality().equals(other.latestVotes, latestVotes) && const DeepCollectionEquality().equals(other.latestAnswers, latestAnswers) && const DeepCollectionEquality().equals(other.ownVotesAndAnswers, ownVotesAndAnswers) && (identical(other.voteCount, voteCount) || other.voteCount == voteCount) && + const DeepCollectionEquality().equals(other.ownVotes, ownVotes) && + const DeepCollectionEquality().equals(other.ownAnswers, ownAnswers) && (identical(other.createdById, createdById) || other.createdById == createdById) && (identical(other.createdBy, createdBy) || other.createdBy == createdBy) && (identical(other.createdAt, createdAt) || other.createdAt == createdAt) && @@ -86,9 +86,12 @@ mixin _$Poll { answersCount, const DeepCollectionEquality().hash(voteCountsByOption), const DeepCollectionEquality().hash(latestVotesByOption), + const DeepCollectionEquality().hash(latestVotes), const DeepCollectionEquality().hash(latestAnswers), const DeepCollectionEquality().hash(ownVotesAndAnswers), voteCount, + const DeepCollectionEquality().hash(ownVotes), + const DeepCollectionEquality().hash(ownAnswers), createdById, createdBy, createdAt, @@ -98,160 +101,6 @@ mixin _$Poll { @override String toString() { - return 'Poll(id: $id, name: $name, description: $description, options: $options, votingVisibility: $votingVisibility, enforceUniqueVote: $enforceUniqueVote, maxVotesAllowed: $maxVotesAllowed, allowUserSuggestedOptions: $allowUserSuggestedOptions, allowAnswers: $allowAnswers, isClosed: $isClosed, answersCount: $answersCount, voteCountsByOption: $voteCountsByOption, latestVotesByOption: $latestVotesByOption, latestAnswers: $latestAnswers, ownVotesAndAnswers: $ownVotesAndAnswers, voteCount: $voteCount, createdById: $createdById, createdBy: $createdBy, createdAt: $createdAt, updatedAt: $updatedAt, extraData: $extraData)'; - } -} - -/// @nodoc -abstract mixin class $PollCopyWith<$Res> { - factory $PollCopyWith(Poll value, $Res Function(Poll) _then) = _$PollCopyWithImpl; - @useResult - $Res call({ - String? id, - String name, - String? description, - List options, - VotingVisibility votingVisibility, - bool enforceUniqueVote, - int? maxVotesAllowed, - bool allowAnswers, - List latestAnswers, - int answersCount, - bool allowUserSuggestedOptions, - bool isClosed, - DateTime? createdAt, - DateTime? updatedAt, - Map voteCountsByOption, - int voteCount, - Map> latestVotesByOption, - String? createdById, - User? createdBy, - List ownVotesAndAnswers, - Map extraData, - }); -} - -/// @nodoc -class _$PollCopyWithImpl<$Res> implements $PollCopyWith<$Res> { - _$PollCopyWithImpl(this._self, this._then); - - final Poll _self; - final $Res Function(Poll) _then; - - /// Create a copy of Poll - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = freezed, - Object? name = null, - Object? description = freezed, - Object? options = null, - Object? votingVisibility = null, - Object? enforceUniqueVote = null, - Object? maxVotesAllowed = freezed, - Object? allowAnswers = null, - Object? latestAnswers = null, - Object? answersCount = null, - Object? allowUserSuggestedOptions = null, - Object? isClosed = null, - Object? createdAt = freezed, - Object? updatedAt = freezed, - Object? voteCountsByOption = null, - Object? voteCount = null, - Object? latestVotesByOption = null, - Object? createdById = freezed, - Object? createdBy = freezed, - Object? ownVotesAndAnswers = null, - Object? extraData = null, - }) { - return _then( - Poll( - id: freezed == id - ? _self.id! - : id // ignore: cast_nullable_to_non_nullable - as String?, - name: null == name - ? _self.name - : name // ignore: cast_nullable_to_non_nullable - as String, - description: freezed == description - ? _self.description - : description // ignore: cast_nullable_to_non_nullable - as String?, - options: null == options - ? _self.options - : options // ignore: cast_nullable_to_non_nullable - as List, - votingVisibility: null == votingVisibility - ? _self.votingVisibility - : votingVisibility // ignore: cast_nullable_to_non_nullable - as VotingVisibility, - enforceUniqueVote: null == enforceUniqueVote - ? _self.enforceUniqueVote - : enforceUniqueVote // ignore: cast_nullable_to_non_nullable - as bool, - maxVotesAllowed: freezed == maxVotesAllowed - ? _self.maxVotesAllowed - : maxVotesAllowed // ignore: cast_nullable_to_non_nullable - as int?, - allowAnswers: null == allowAnswers - ? _self.allowAnswers - : allowAnswers // ignore: cast_nullable_to_non_nullable - as bool, - latestAnswers: null == latestAnswers - ? _self.latestAnswers - : latestAnswers // ignore: cast_nullable_to_non_nullable - as List, - answersCount: null == answersCount - ? _self.answersCount - : answersCount // ignore: cast_nullable_to_non_nullable - as int, - allowUserSuggestedOptions: null == allowUserSuggestedOptions - ? _self.allowUserSuggestedOptions - : allowUserSuggestedOptions // ignore: cast_nullable_to_non_nullable - as bool, - isClosed: null == isClosed - ? _self.isClosed - : isClosed // ignore: cast_nullable_to_non_nullable - as bool, - createdAt: freezed == createdAt - ? _self.createdAt! - : createdAt // ignore: cast_nullable_to_non_nullable - as DateTime?, - updatedAt: freezed == updatedAt - ? _self.updatedAt! - : updatedAt // ignore: cast_nullable_to_non_nullable - as DateTime?, - voteCountsByOption: null == voteCountsByOption - ? _self.voteCountsByOption - : voteCountsByOption // ignore: cast_nullable_to_non_nullable - as Map, - voteCount: null == voteCount - ? _self.voteCount - : voteCount // ignore: cast_nullable_to_non_nullable - as int, - latestVotesByOption: null == latestVotesByOption - ? _self.latestVotesByOption - : latestVotesByOption // ignore: cast_nullable_to_non_nullable - as Map>, - createdById: freezed == createdById - ? _self.createdById - : createdById // ignore: cast_nullable_to_non_nullable - as String?, - createdBy: freezed == createdBy - ? _self.createdBy - : createdBy // ignore: cast_nullable_to_non_nullable - as User?, - ownVotesAndAnswers: null == ownVotesAndAnswers - ? _self.ownVotesAndAnswers - : ownVotesAndAnswers // ignore: cast_nullable_to_non_nullable - as List, - extraData: null == extraData - ? _self.extraData - : extraData // ignore: cast_nullable_to_non_nullable - as Map, - ), - ); + return 'Poll(id: $id, name: $name, description: $description, options: $options, votingVisibility: $votingVisibility, enforceUniqueVote: $enforceUniqueVote, maxVotesAllowed: $maxVotesAllowed, allowUserSuggestedOptions: $allowUserSuggestedOptions, allowAnswers: $allowAnswers, isClosed: $isClosed, answersCount: $answersCount, voteCountsByOption: $voteCountsByOption, latestVotesByOption: $latestVotesByOption, latestVotes: $latestVotes, latestAnswers: $latestAnswers, ownVotesAndAnswers: $ownVotesAndAnswers, voteCount: $voteCount, ownVotes: $ownVotes, ownAnswers: $ownAnswers, createdById: $createdById, createdBy: $createdBy, createdAt: $createdAt, updatedAt: $updatedAt, extraData: $extraData)'; } } diff --git a/packages/stream_chat/lib/src/core/models/poll_option.dart b/packages/stream_chat/lib/src/core/models/poll_option.dart index c89099c560..03318cdeef 100644 --- a/packages/stream_chat/lib/src/core/models/poll_option.dart +++ b/packages/stream_chat/lib/src/core/models/poll_option.dart @@ -5,8 +5,14 @@ import '../../db/data_serializable.dart'; part 'poll_option.freezed.dart'; part 'poll_option.g.dart'; +class _NullConst { + const _NullConst(); +} + +const _nullConst = _NullConst(); + /// One of the choices a poll offers to vote on. -@freezed +@Freezed(copyWith: false) // TODO(openapi-migration): remove in group 10 @DataSerializable(includeIfNull: false) class PollOption with _$PollOption { @@ -37,6 +43,17 @@ class PollOption with _$PollOption { @override final Map extraData; + /// Creates a copy of [PollOption] with specified attributes overridden. + PollOption copyWith({ + Object? id = _nullConst, + String? text, + Map? extraData, + }) => PollOption( + id: id == _nullConst ? this.id : id as String?, + text: text ?? this.text, + extraData: extraData ?? this.extraData, + ); + /// The keys an option carries besides its custom data. static const topLevelFields = [ 'id', diff --git a/packages/stream_chat/lib/src/core/models/poll_option.freezed.dart b/packages/stream_chat/lib/src/core/models/poll_option.freezed.dart index 239f118a07..651352a105 100644 --- a/packages/stream_chat/lib/src/core/models/poll_option.freezed.dart +++ b/packages/stream_chat/lib/src/core/models/poll_option.freezed.dart @@ -17,12 +17,6 @@ mixin _$PollOption { String get text; Map get extraData; - /// Create a copy of PollOption - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @pragma('vm:prefer-inline') - $PollOptionCopyWith get copyWith => _$PollOptionCopyWithImpl(this as PollOption, _$identity); - @override bool operator ==(Object other) { return identical(this, other) || @@ -41,41 +35,3 @@ mixin _$PollOption { return 'PollOption(id: $id, text: $text, extraData: $extraData)'; } } - -/// @nodoc -abstract mixin class $PollOptionCopyWith<$Res> { - factory $PollOptionCopyWith(PollOption value, $Res Function(PollOption) _then) = _$PollOptionCopyWithImpl; - @useResult - $Res call({String? id, String text, Map extraData}); -} - -/// @nodoc -class _$PollOptionCopyWithImpl<$Res> implements $PollOptionCopyWith<$Res> { - _$PollOptionCopyWithImpl(this._self, this._then); - - final PollOption _self; - final $Res Function(PollOption) _then; - - /// Create a copy of PollOption - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({Object? id = freezed, Object? text = null, Object? extraData = null}) { - return _then( - PollOption( - id: freezed == id - ? _self.id - : id // ignore: cast_nullable_to_non_nullable - as String?, - text: null == text - ? _self.text - : text // ignore: cast_nullable_to_non_nullable - as String, - extraData: null == extraData - ? _self.extraData - : extraData // ignore: cast_nullable_to_non_nullable - as Map, - ), - ); - } -} diff --git a/packages/stream_chat/lib/src/core/models/poll_vote.dart b/packages/stream_chat/lib/src/core/models/poll_vote.dart index 8ed1137bab..3acaf2538f 100644 --- a/packages/stream_chat/lib/src/core/models/poll_vote.dart +++ b/packages/stream_chat/lib/src/core/models/poll_vote.dart @@ -9,7 +9,7 @@ part 'poll_vote.freezed.dart'; /// /// A vote carries an [optionId] and an answer carries [answerText]; [isAnswer] /// tells the two apart. -@freezed +@Freezed(copyWith: false) class PollVote with _$PollVote { /// Creates a new [PollVote]. /// @@ -69,6 +69,27 @@ class PollVote with _$PollVote { @override final User? user; + /// Creates a copy of [PollVote] with specified attributes overridden. + PollVote copyWith({ + String? id, + String? pollId, + String? optionId, + String? answerText, + DateTime? createdAt, + DateTime? updatedAt, + String? userId, + User? user, + }) => PollVote( + id: id ?? this.id, + pollId: pollId ?? this.pollId, + optionId: optionId ?? this.optionId, + answerText: answerText ?? this.answerText, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + userId: userId ?? this.userId, + user: user ?? this.user, + ); + /// Whether this is a free-form answer rather than a vote for an option. bool get isAnswer => answerText != null; } diff --git a/packages/stream_chat/lib/src/core/models/poll_vote.freezed.dart b/packages/stream_chat/lib/src/core/models/poll_vote.freezed.dart index 0b759fbc82..10327a250c 100644 --- a/packages/stream_chat/lib/src/core/models/poll_vote.freezed.dart +++ b/packages/stream_chat/lib/src/core/models/poll_vote.freezed.dart @@ -22,12 +22,6 @@ mixin _$PollVote { String? get userId; User? get user; - /// Create a copy of PollVote - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @pragma('vm:prefer-inline') - $PollVoteCopyWith get copyWith => _$PollVoteCopyWithImpl(this as PollVote, _$identity); - @override bool operator ==(Object other) { return identical(this, other) || @@ -51,79 +45,3 @@ mixin _$PollVote { return 'PollVote(id: $id, pollId: $pollId, optionId: $optionId, answerText: $answerText, createdAt: $createdAt, updatedAt: $updatedAt, userId: $userId, user: $user)'; } } - -/// @nodoc -abstract mixin class $PollVoteCopyWith<$Res> { - factory $PollVoteCopyWith(PollVote value, $Res Function(PollVote) _then) = _$PollVoteCopyWithImpl; - @useResult - $Res call({ - String? id, - String? pollId, - String? optionId, - String? answerText, - DateTime? createdAt, - DateTime? updatedAt, - String? userId, - User? user, - }); -} - -/// @nodoc -class _$PollVoteCopyWithImpl<$Res> implements $PollVoteCopyWith<$Res> { - _$PollVoteCopyWithImpl(this._self, this._then); - - final PollVote _self; - final $Res Function(PollVote) _then; - - /// Create a copy of PollVote - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = freezed, - Object? pollId = freezed, - Object? optionId = freezed, - Object? answerText = freezed, - Object? createdAt = freezed, - Object? updatedAt = freezed, - Object? userId = freezed, - Object? user = freezed, - }) { - return _then( - PollVote( - id: freezed == id - ? _self.id - : id // ignore: cast_nullable_to_non_nullable - as String?, - pollId: freezed == pollId - ? _self.pollId - : pollId // ignore: cast_nullable_to_non_nullable - as String?, - optionId: freezed == optionId - ? _self.optionId - : optionId // ignore: cast_nullable_to_non_nullable - as String?, - answerText: freezed == answerText - ? _self.answerText - : answerText // ignore: cast_nullable_to_non_nullable - as String?, - createdAt: freezed == createdAt - ? _self.createdAt! - : createdAt // ignore: cast_nullable_to_non_nullable - as DateTime?, - updatedAt: freezed == updatedAt - ? _self.updatedAt! - : updatedAt // ignore: cast_nullable_to_non_nullable - as DateTime?, - userId: freezed == userId - ? _self.userId - : userId // ignore: cast_nullable_to_non_nullable - as String?, - user: freezed == user - ? _self.user - : user // ignore: cast_nullable_to_non_nullable - as User?, - ), - ); - } -} diff --git a/packages/stream_chat/test/src/core/models/poll_option_test.dart b/packages/stream_chat/test/src/core/models/poll_option_test.dart index 8d0ea26d07..81c13bb074 100644 --- a/packages/stream_chat/test/src/core/models/poll_option_test.dart +++ b/packages/stream_chat/test/src/core/models/poll_option_test.dart @@ -29,4 +29,16 @@ void main() { expect(option, isNot(option.copyWith(extraData: const {'color': 'green'}))); }); + + test('PollOption.copyWith clears an id passed as null', () { + const option = PollOption(id: 'pizza', text: 'Pizza'); + + expect(option.copyWith(id: null).id, isNull); + }); + + test('PollOption.copyWith keeps the text and custom data passed as null', () { + const option = PollOption(id: 'pizza', text: 'Pizza', extraData: {'color': 'red'}); + + expect(option.copyWith(text: null, extraData: null), option); + }); } diff --git a/packages/stream_chat/test/src/core/models/poll_test.dart b/packages/stream_chat/test/src/core/models/poll_test.dart index e733b477cb..6b87abaacc 100644 --- a/packages/stream_chat/test/src/core/models/poll_test.dart +++ b/packages/stream_chat/test/src/core/models/poll_test.dart @@ -28,23 +28,18 @@ void main() { expect(poll.copyWith(maxVotesAllowed: null).maxVotesAllowed, isNull); }); - test('Poll.copyWith generates a new id when the id is set to null', () { - final poll = createTestPoll(id: 'poll-id', name: 'Lunch?'); - - final copy = poll.copyWith(id: null); - - expect(copy.id, isNotEmpty); - expect(copy.id, isNot('poll-id')); - }); - - test('Poll.copyWith sets a timestamp set to null to the current time', () { - final poll = createTestPoll(name: 'Lunch?', createdAt: DateTime.utc(2023), updatedAt: DateTime.utc(2023)); - final before = DateTime.now(); + test('Poll.copyWith keeps a field passed as null', () { + final poll = createTestPoll( + id: 'poll-id', + name: 'Lunch?', + description: 'Pick one', + createdAt: DateTime.utc(2023), + updatedAt: DateTime.utc(2023), + ); - final copy = poll.copyWith(createdAt: null, updatedAt: null); + final copy = poll.copyWith(id: null, description: null, createdAt: null, updatedAt: null); - expect(copy.createdAt.isBefore(before), isFalse); - expect(copy.updatedAt.isBefore(before), isFalse); + expect(copy, poll); }); test('Poll.latestVotes holds the latest votes of every option', () { diff --git a/packages/stream_chat/test/src/core/models/poll_vote_test.dart b/packages/stream_chat/test/src/core/models/poll_vote_test.dart index 1a0286a6ca..ce0ada7931 100644 --- a/packages/stream_chat/test/src/core/models/poll_vote_test.dart +++ b/packages/stream_chat/test/src/core/models/poll_vote_test.dart @@ -17,14 +17,12 @@ void main() { expect(vote.isAnswer, isFalse); }); - test('PollVote.copyWith sets a timestamp set to null to the current time', () { - final vote = createTestPollVote(optionId: 'pizza', createdAt: DateTime.utc(2023), updatedAt: DateTime.utc(2023)); - final before = DateTime.now(); + test('PollVote.copyWith keeps a field passed as null', () { + final vote = createTestPollVote(id: 'vote-id', optionId: 'pizza', userId: 'user-id'); - final copy = vote.copyWith(createdAt: null, updatedAt: null); + final copy = vote.copyWith(id: null, optionId: null, userId: null, createdAt: null); - expect(copy.createdAt.isBefore(before), isFalse); - expect(copy.updatedAt.isBefore(before), isFalse); + expect(copy, vote); }); test('PollVoteSortField.id orders alphabetically', () { From 8bd2b402639e233d8cdc134729c712815f042e23 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 1 Oct 2026 20:33:15 +0200 Subject: [PATCH 06/32] docs(repo): correct the poll getters row in the v11 migration guide Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index 9a8cdee4cb..41a062118c 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -228,7 +228,7 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `CreatePollResponse()..poll = …` and the other response setters | `PollResponse(duration: …, poll: …)` | `retyped` | The responses are plain classes with a const constructor and final fields | | poll responses' `duration` (`String?`) | `String` | `retyped` | Always present; drop any `!` or `?? ''` | | `Poll` / `PollOption` / `PollVote extends Equatable`, `props` | value `==` | `removed` | `Poll` and `PollOption` now compare `extraData` too | -| `Poll.ownVotes` / `.ownAnswers` / `.latestVotes` (late fields), `PollVote.isAnswer` (a field set in the constructor) | the same names, as getters | `retyped` | Computed on each read | +| `PollVote.isAnswer` (a field set in the constructor) | the same name, as a getter | `retyped` | Computed from `answerText` on each read | | `StreamChatApi.polls` (`PollsApi`) | `StreamChatClient`'s poll methods | `removed` | The endpoints moved to the generated client | | _(more added per feature as PRs land)_ | | | | From 02ca61279022056a2e52e44e074ecb818358899e Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 2 Oct 2026 10:32:24 +0200 Subject: [PATCH 07/32] docs(repo): keep v10 copyWith semantics in the openapi-migration rules Co-Authored-By: Claude Opus 5.5 --- .claude/skills/openapi-migration/SKILL.md | 38 +++++++++++++++++++ openapi-migration/02-devices.md | 2 + openapi-migration/03-user-groups.md | 2 + openapi-migration/04-roles-guest-and-app.md | 2 + openapi-migration/05-polls.md | 2 + openapi-migration/06-reminders.md | 2 + openapi-migration/07-threads-and-drafts.md | 2 + .../08-moderation-and-blocklists.md | 2 + openapi-migration/09-users.md | 2 + openapi-migration/10-messages.md | 2 + openapi-migration/11-channels-and-members.md | 2 + openapi-migration/12-uploads-cdn.md | 2 + openapi-migration/13-push-preferences.md | 2 + openapi-migration/14-banned-users.md | 2 + openapi-migration/README.md | 4 ++ openapi-migration/tool/generate_plan.py | 2 + 16 files changed, 70 insertions(+) diff --git a/.claude/skills/openapi-migration/SKILL.md b/.claude/skills/openapi-migration/SKILL.md index 5e47c55c2a..380a42f3a5 100644 --- a/.claude/skills/openapi-migration/SKILL.md +++ b/.claude/skills/openapi-migration/SKILL.md @@ -204,6 +204,9 @@ a public signature. [Persisted models](#persisted-models-the-dataserializable-codec)). A model stored only as a table row, mapped column by column like `Poll` in `polls`, needs no codec. - **Which request enums need a hand-written public type** (rule 6). +- **Whether v10 has a `copyWith`, and how it treats `null`.** If it has one, the migrated model keeps that exact + method, `_nullConst` sentinels included. freezed's `copyWith` behaves differently, so read + [Keeping a v10 `copyWith`](#keeping-a-v10-copywith) before relying on it. **Per method, decide what it returns:** @@ -456,6 +459,39 @@ List>? _membersToData(List? members) => `.g.dart` and `.freezed.dart` files of the models you changed; `--build-filter` deletes every generated output outside the filter, so never use it. +### Keeping a v10 `copyWith` + +**freezed's `copyWith` is not v10's, and swapping one for the other changes behaviour without a compile error.** + +- **v10:** `null` means "keep the current value" (`name: name ?? this.name`). A field opts into clearing through + the `_nullConst` sentinel: `Object? maxVotesAllowed = _nullConst`, read back as + `maxVotesAllowed == _nullConst ? this.maxVotesAllowed : maxVotesAllowed as int?`. Every non-nullable field also + takes a nullable parameter. +- **freezed:** `null` passed for a nullable field sets it to `null`, and a non-nullable field's parameter is + non-nullable. A field whose constructor turns `null` into a default (`id ?? Uuid().v4()`, + `createdAt ?? DateTime.now()`) gets a new value instead of keeping the old one. + +The SDK's state handling relies on exactly which fields keep and which clear. `ChannelClientState` clears expired +pins with `message.copyWith(pinExpires: null)` and a deleted draft with `channelState.copyWith(draft: null)`, and +`StreamMessageComposerController.clearQuotedMessage` clears the quote the same way. Each of those works only +because the field has a sentinel. Elsewhere, a value that happens to be `null` passed to a `??` field keeps what +was there. So: + +- **If the v10 class has a `copyWith`, keep it verbatim.** Annotate the class `@Freezed(copyWith: false)` and copy + v10's method from `git show :`. Copy the whole signature, every `??`, every `_nullConst` parameter, + and the file's `_NullConst` class. freezed still generates `==`, `hashCode` and `toString`. Copy + `lib/src/core/models/poll.dart` (sentinel on `maxVotesAllowed`) and `poll_option.dart` (sentinel on `id`). +- **Only a class with no v10 `copyWith` uses freezed's.** A `copyWith` it gains that way is new API, so its + semantics are not a break. +- **Diff the two methods parameter by parameter** against the base before moving on: the defaults, the `??` + fallbacks and the sentinels. +- **Mind what freezed counts as data.** For a class with a hand-written constructor, it puts every instance field + in `==`, `hashCode` and `toString`, private fields and `late final` caches included. + - A cache derived only from fields already compared (`Poll.ownVotes`, from `ownVotesAndAnswers`) changes no + result, so it can stay `late final`. + - A field v10 set in the constructor's initializer list from another field (`PollVote.isAnswer`) becomes a + getter. + ### Documenting the public surface Write these as you write the code. `public_member_api_docs` only checks a doc *exists*, so a placeholder survives @@ -487,6 +523,8 @@ Budget for this: on a typical feature it is most of the diff, and none of it is - **Stub each generated response with every field our model carries,** each with a distinct value, and compare the whole returned envelope, so a field the mapper drops or swaps fails an assertion. Give every method a failure test too. +- **Pin each kept `copyWith`'s `null` handling:** one test that `null` keeps the `??` fields, and one per + `_nullConst` field that `null` clears it (`poll_test.dart`, `poll_option_test.dart`). - **Test each temporary converter through its parent's `fromJson`/`toJson`.** - **Test each `@DataSerializable` model's stored format:** pin `toData()` to a literal map, so a rename that changes the stored keys fails; round-trip `fromData(toData())`; cover null versus empty for nullable lists. Extend the diff --git a/openapi-migration/02-devices.md b/openapi-migration/02-devices.md index 71e2e9abeb..174f1cfe52 100644 --- a/openapi-migration/02-devices.md +++ b/openapi-migration/02-devices.md @@ -89,6 +89,8 @@ Revisited after landing: the group first adopted the generated `DeviceResponse`, 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] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels + included ([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. diff --git a/openapi-migration/03-user-groups.md b/openapi-migration/03-user-groups.md index 36c1e01060..a8a8405930 100644 --- a/openapi-migration/03-user-groups.md +++ b/openapi-migration/03-user-groups.md @@ -73,6 +73,8 @@ Follows the [domain-model rules](README.md#domain-models). 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] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels + included ([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. diff --git a/openapi-migration/04-roles-guest-and-app.md b/openapi-migration/04-roles-guest-and-app.md index f6da6636af..89a8b9e350 100644 --- a/openapi-migration/04-roles-guest-and-app.md +++ b/openapi-migration/04-roles-guest-and-app.md @@ -111,6 +111,8 @@ 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] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels + included ([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. diff --git a/openapi-migration/05-polls.md b/openapi-migration/05-polls.md index 8b591ba349..f06156384c 100644 --- a/openapi-migration/05-polls.md +++ b/openapi-migration/05-polls.md @@ -99,6 +99,8 @@ 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] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels + included ([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. diff --git a/openapi-migration/06-reminders.md b/openapi-migration/06-reminders.md index f2ccb1b74e..969d08f45e 100644 --- a/openapi-migration/06-reminders.md +++ b/openapi-migration/06-reminders.md @@ -41,6 +41,8 @@ hand-written, with the reason. - [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [ ] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels + included ([README rule 2](README.md#domain-models)). - [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. diff --git a/openapi-migration/07-threads-and-drafts.md b/openapi-migration/07-threads-and-drafts.md index b325ae5e0d..2c9e6ba997 100644 --- a/openapi-migration/07-threads-and-drafts.md +++ b/openapi-migration/07-threads-and-drafts.md @@ -48,6 +48,8 @@ hand-written, with the reason. - [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [ ] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels + included ([README rule 2](README.md#domain-models)). - [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. diff --git a/openapi-migration/08-moderation-and-blocklists.md b/openapi-migration/08-moderation-and-blocklists.md index 35e3686252..1b2e34a44b 100644 --- a/openapi-migration/08-moderation-and-blocklists.md +++ b/openapi-migration/08-moderation-and-blocklists.md @@ -187,6 +187,8 @@ 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] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels + included ([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. diff --git a/openapi-migration/09-users.md b/openapi-migration/09-users.md index e0da9d6499..db346a7ec4 100644 --- a/openapi-migration/09-users.md +++ b/openapi-migration/09-users.md @@ -59,6 +59,8 @@ hand-written, with the reason. - [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [ ] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels + included ([README rule 2](README.md#domain-models)). - [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. diff --git a/openapi-migration/10-messages.md b/openapi-migration/10-messages.md index 7a5d1ea5cc..09c47be804 100644 --- a/openapi-migration/10-messages.md +++ b/openapi-migration/10-messages.md @@ -65,6 +65,8 @@ hand-written, with the reason. - [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [ ] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels + included ([README rule 2](README.md#domain-models)). - [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. diff --git a/openapi-migration/11-channels-and-members.md b/openapi-migration/11-channels-and-members.md index ba18c48dd2..b7344cd8bb 100644 --- a/openapi-migration/11-channels-and-members.md +++ b/openapi-migration/11-channels-and-members.md @@ -89,6 +89,8 @@ hand-written, with the reason. - [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [ ] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels + included ([README rule 2](README.md#domain-models)). - [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. diff --git a/openapi-migration/12-uploads-cdn.md b/openapi-migration/12-uploads-cdn.md index fc9ee4cadb..1c81f63c61 100644 --- a/openapi-migration/12-uploads-cdn.md +++ b/openapi-migration/12-uploads-cdn.md @@ -54,6 +54,8 @@ hand-written, with the reason. - [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [ ] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels + included ([README rule 2](README.md#domain-models)). - [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. diff --git a/openapi-migration/13-push-preferences.md b/openapi-migration/13-push-preferences.md index 55b14189c8..2dafa94a72 100644 --- a/openapi-migration/13-push-preferences.md +++ b/openapi-migration/13-push-preferences.md @@ -38,6 +38,8 @@ hand-written, with the reason. - [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [ ] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels + included ([README rule 2](README.md#domain-models)). - [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. diff --git a/openapi-migration/14-banned-users.md b/openapi-migration/14-banned-users.md index e6f0ea918f..2ec0b23dad 100644 --- a/openapi-migration/14-banned-users.md +++ b/openapi-migration/14-banned-users.md @@ -45,6 +45,8 @@ hand-written, with the reason. - [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [ ] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels + included ([README rule 2](README.md#domain-models)). - [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. diff --git a/openapi-migration/README.md b/openapi-migration/README.md index 03ba6bc099..52594e6a43 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -100,6 +100,10 @@ includes a named response that carries only `duration` today, such as `HideChann (see the breaks above). A field the server adds is exposed later, as an additive change. The one exception to "no JSON" is the temporary `@DataSerializable` storage codec in rule 8. + **A model that had a `copyWith` in v10 keeps that exact method,** `_nullConst` sentinels included, under + `@Freezed(copyWith: false)`. freezed's own `copyWith` sets a field passed as `null`, where v10's keeps it, and + the SDK's state handling depends on the difference. Only a model with no v10 `copyWith` uses freezed's. + **Names default to v10's,** even where the generated name differs: `OGAttachmentResponse` rather than `GetOGResponse`, `AppSettings` rather than `AppResponseFields`, `UploadConfig` rather than `FileUploadConfig`. That is a default, not a requirement. The user may ask for another name, or a migration may propose one that diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 52b857418d..6ce3206faa 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -76,6 +76,8 @@ def match(path): hand-written, with the reason. - [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. + - [ ] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels + included ([README rule 2](README.md#domain-models)). - [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. From 8a5f068017feed9b6d0759aeb8e122d70f0a97f3 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 2 Oct 2026 10:59:59 +0200 Subject: [PATCH 08/32] refactor(llc): share the sort mapper and keep poll client tests in client_test Co-Authored-By: Claude Opus 5.5 --- .../src/repository/mapper/sort_mapper.dart | 9 + .../lib/src/repository/polls_repository.dart | 12 +- .../src/client/client_poll_options_test.dart | 164 ----- .../src/client/client_poll_votes_test.dart | 183 ------ .../test/src/client/client_polls_test.dart | 344 ---------- .../test/src/client/client_test.dart | 598 ++++++++++++++++++ .../test/src/client/poll_fixtures.dart | 20 - 7 files changed, 611 insertions(+), 719 deletions(-) create mode 100644 packages/stream_chat/lib/src/repository/mapper/sort_mapper.dart delete mode 100644 packages/stream_chat/test/src/client/client_poll_options_test.dart delete mode 100644 packages/stream_chat/test/src/client/client_poll_votes_test.dart delete mode 100644 packages/stream_chat/test/src/client/client_polls_test.dart diff --git a/packages/stream_chat/lib/src/repository/mapper/sort_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/sort_mapper.dart new file mode 100644 index 0000000000..ff4ac63d50 --- /dev/null +++ b/packages/stream_chat/lib/src/repository/mapper/sort_mapper.dart @@ -0,0 +1,9 @@ +import 'package:stream_core/stream_core.dart' show Sort; + +import '../../../open_api/api.dart' as api; + +/// Maps a [Sort] to the generated [api.SortParamRequest]. +extension SortMapper on Sort { + /// Converts this sort into an [api.SortParamRequest]. + api.SortParamRequest toRequest() => api.SortParamRequest(field: field.remote, direction: direction.value); +} diff --git a/packages/stream_chat/lib/src/repository/polls_repository.dart b/packages/stream_chat/lib/src/repository/polls_repository.dart index b15c0ce654..077ebbf499 100644 --- a/packages/stream_chat/lib/src/repository/polls_repository.dart +++ b/packages/stream_chat/lib/src/repository/polls_repository.dart @@ -1,4 +1,4 @@ -import 'package:stream_core/stream_core.dart' show PatternMatching, Result, Sort; +import 'package:stream_core/stream_core.dart' show PatternMatching, Result; import '../../open_api/api.dart' as api; import '../core/models/poll.dart'; @@ -11,6 +11,7 @@ import '../core/models/response/query_poll_votes_response.dart'; import '../core/models/response/query_polls_response.dart'; import 'mapper/polls_mapper.dart'; import 'mapper/result_mapper.dart'; +import 'mapper/sort_mapper.dart'; /// Repository dedicated to poll operations. class PollsRepository { @@ -143,7 +144,7 @@ class PollsRepository { final result = await _api.queryPolls( queryPollsRequest: api.QueryPollsRequest( filter: filter?.toJson(), - sort: sort?.map(_sortParam).toList(), + sort: sort?.map((it) => it.toRequest()).toList(), limit: limit, next: next, prev: prev, @@ -168,7 +169,7 @@ class PollsRepository { pollId: pollId, queryPollVotesRequest: api.QueryPollVotesRequest( filter: filter?.toJson(), - sort: sort?.map(_sortParam).toList(), + sort: sort?.map((it) => it.toRequest()).toList(), limit: limit, next: next, prev: prev, @@ -178,8 +179,3 @@ class PollsRepository { return result.map((response) => response.toModel()); } } - -api.SortParamRequest _sortParam(Sort sort) { - final json = sort.toJson(); - return api.SortParamRequest(field: json['field'] as String?, direction: json['direction'] as int?); -} diff --git a/packages/stream_chat/test/src/client/client_poll_options_test.dart b/packages/stream_chat/test/src/client/client_poll_options_test.dart deleted file mode 100644 index 6c47d8129e..0000000000 --- a/packages/stream_chat/test/src/client/client_poll_options_test.dart +++ /dev/null @@ -1,164 +0,0 @@ -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 'poll_fixtures.dart'; - -void main() { - test( - 'StreamChatClient.createPollOption sends the option text and custom data and returns the created option', - () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.createPollOption( - pollId: 'poll-id', - createPollOptionRequest: const api.CreatePollOptionRequest(text: 'Pizza', custom: {'color': 'red'}), - ), - ).thenAnswer((_) async => const Result.success(_generatedPizzaResponse)); - final client = pollsClient(defaultApi); - - final result = await client.createPollOption( - 'poll-id', - const PollOption(text: 'Pizza', extraData: {'color': 'red'}), - ); - - expect(result, const Result.success(_pizzaResponse)); - }, - ); - - test('StreamChatClient.createPollOption returns the failure without throwing', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.createPollOption( - pollId: 'poll-id', - createPollOptionRequest: any(named: 'createPollOptionRequest'), - ), - ).thenAnswer((_) async => const Result.failure(pollsApiError)); - final client = pollsClient(defaultApi); - - final result = await client.createPollOption('poll-id', const PollOption(text: 'Pizza')); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test('StreamChatClient.getPollOption sends the poll and option ids and returns the option', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.getPollOption(pollId: 'poll-id', optionId: 'pizza'), - ).thenAnswer((_) async => const Result.success(_generatedPizzaResponse)); - final client = pollsClient(defaultApi); - - final result = await client.getPollOption('poll-id', 'pizza'); - - expect(result, const Result.success(_pizzaResponse)); - }); - - test('StreamChatClient.getPollOption returns the failure without throwing', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.getPollOption(pollId: 'poll-id', optionId: 'pizza'), - ).thenAnswer((_) async => const Result.failure(pollsApiError)); - final client = pollsClient(defaultApi); - - final result = await client.getPollOption('poll-id', 'pizza'); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test( - "StreamChatClient.getPollOption keeps custom fields named like the option's own fields out of its custom data", - () async { - final defaultApi = pollsDefaultApi(); - final response = api.PollOptionResponse( - duration: '4.21ms', - pollOption: generatedPizza.copyWith(custom: const {'color': 'red', 'text': 'custom-text'}), - ); - when( - () => defaultApi.getPollOption(pollId: 'poll-id', optionId: 'pizza'), - ).thenAnswer((_) async => Result.success(response)); - final client = pollsClient(defaultApi); - - final result = await client.getPollOption('poll-id', 'pizza'); - - expect(result.getOrNull()?.pollOption.extraData, const {'color': 'red'}); - }, - ); - - test('StreamChatClient.updatePollOption sends the option and returns the updated option', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.updatePollOption( - pollId: 'poll-id', - updatePollOptionRequest: const api.UpdatePollOptionRequest( - id: 'pizza', - text: 'Pizza', - custom: {'color': 'red'}, - ), - ), - ).thenAnswer((_) async => const Result.success(_generatedPizzaResponse)); - final client = pollsClient(defaultApi); - - final result = await client.updatePollOption('poll-id', pizza); - - expect(result, const Result.success(_pizzaResponse)); - }); - - test('StreamChatClient.updatePollOption sends an option without an id with an empty id', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.updatePollOption( - pollId: 'poll-id', - updatePollOptionRequest: const api.UpdatePollOptionRequest(id: '', text: 'Pizza', custom: {}), - ), - ).thenAnswer((_) async => const Result.success(_generatedPizzaResponse)); - final client = pollsClient(defaultApi); - - final result = await client.updatePollOption('poll-id', const PollOption(text: 'Pizza')); - - expect(result, const Result.success(_pizzaResponse)); - }); - - test('StreamChatClient.updatePollOption returns the failure without throwing', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.updatePollOption( - pollId: 'poll-id', - updatePollOptionRequest: any(named: 'updatePollOptionRequest'), - ), - ).thenAnswer((_) async => const Result.failure(pollsApiError)); - final client = pollsClient(defaultApi); - - final result = await client.updatePollOption('poll-id', pizza); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test('StreamChatClient.deletePollOption sends the poll and option ids and returns a success', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.deletePollOption(pollId: 'poll-id', optionId: 'pizza'), - ).thenAnswer((_) async => const Result.success(api.DurationResponse(duration: '4.21ms'))); - final client = pollsClient(defaultApi); - - final result = await client.deletePollOption('poll-id', 'pizza'); - - expect(result, const Result.success(null)); - }); - - test('StreamChatClient.deletePollOption returns the failure without throwing', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.deletePollOption(pollId: 'poll-id', optionId: 'pizza'), - ).thenAnswer((_) async => const Result.failure(pollsApiError)); - final client = pollsClient(defaultApi); - - final result = await client.deletePollOption('poll-id', 'pizza'); - - expect(result.exceptionOrNull(), pollsApiError); - }); -} - -const _generatedPizzaResponse = api.PollOptionResponse(duration: '4.21ms', pollOption: generatedPizza); - -const _pizzaResponse = PollOptionResponse(duration: '4.21ms', pollOption: pizza); diff --git a/packages/stream_chat/test/src/client/client_poll_votes_test.dart b/packages/stream_chat/test/src/client/client_poll_votes_test.dart deleted file mode 100644 index f504689fbe..0000000000 --- a/packages/stream_chat/test/src/client/client_poll_votes_test.dart +++ /dev/null @@ -1,183 +0,0 @@ -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 'poll_fixtures.dart'; - -void main() { - test('StreamChatClient.castPollVote sends the selected option and returns the cast vote', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.castPollVote( - messageId: 'message-id', - pollId: 'poll-id', - castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(optionId: 'pizza')), - ), - ).thenAnswer( - (_) async => Result.success(api.PollVoteResponse(duration: '4.21ms', poll: generatedPoll, vote: generatedVote)), - ); - final client = pollsClient(defaultApi); - - final result = await client.castPollVote('message-id', 'poll-id', optionId: 'pizza'); - - expect(result, Result.success(PollVoteResponse(duration: '4.21ms', vote: vote))); - }); - - test('StreamChatClient.castPollVote returns the failure without throwing', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.castPollVote( - messageId: 'message-id', - pollId: 'poll-id', - castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(optionId: 'pizza')), - ), - ).thenAnswer((_) async => const Result.failure(pollsApiError)); - final client = pollsClient(defaultApi); - - final result = await client.castPollVote('message-id', 'poll-id', optionId: 'pizza'); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test('StreamChatClient.castPollVote returns no vote when the response carries none', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.castPollVote( - messageId: 'message-id', - pollId: 'poll-id', - castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(optionId: 'pizza')), - ), - ).thenAnswer((_) async => const Result.success(api.PollVoteResponse(duration: '4.21ms'))); - final client = pollsClient(defaultApi); - - final result = await client.castPollVote('message-id', 'poll-id', optionId: 'pizza'); - - expect(result, const Result.success(PollVoteResponse(duration: '4.21ms'))); - }); - - test('StreamChatClient.addPollAnswer sends the answer text and returns the answer', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.castPollVote( - messageId: 'message-id', - pollId: 'poll-id', - castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(answerText: 'Anything')), - ), - ).thenAnswer( - (_) async => Result.success(api.PollVoteResponse(duration: '4.21ms', poll: generatedPoll, vote: generatedAnswer)), - ); - final client = pollsClient(defaultApi); - - final result = await client.addPollAnswer('message-id', 'poll-id', answerText: 'Anything'); - - expect(result, Result.success(PollVoteResponse(duration: '4.21ms', vote: answer))); - }); - - test('StreamChatClient.addPollAnswer returns the failure without throwing', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.castPollVote( - messageId: 'message-id', - pollId: 'poll-id', - castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(answerText: 'Anything')), - ), - ).thenAnswer((_) async => const Result.failure(pollsApiError)); - final client = pollsClient(defaultApi); - - final result = await client.addPollAnswer('message-id', 'poll-id', answerText: 'Anything'); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test('StreamChatClient.removePollVote sends the vote id and returns the removed vote', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.deletePollVote(messageId: 'message-id', pollId: 'poll-id', voteId: 'vote-id'), - ).thenAnswer( - (_) async => Result.success(api.PollVoteResponse(duration: '4.21ms', poll: generatedPoll, vote: generatedVote)), - ); - final client = pollsClient(defaultApi); - - final result = await client.removePollVote('message-id', 'poll-id', 'vote-id'); - - expect(result, Result.success(PollVoteResponse(duration: '4.21ms', vote: vote))); - }); - - test('StreamChatClient.removePollVote returns the failure without throwing', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.deletePollVote(messageId: 'message-id', pollId: 'poll-id', voteId: 'vote-id'), - ).thenAnswer((_) async => const Result.failure(pollsApiError)); - final client = pollsClient(defaultApi); - - final result = await client.removePollVote('message-id', 'poll-id', 'vote-id'); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test('StreamChatClient.queryPollVotes sends the filter, sort and cursor and returns the matching votes', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.queryPollVotes( - pollId: 'poll-id', - queryPollVotesRequest: const api.QueryPollVotesRequest( - filter: { - 'is_answer': {r'$eq': true}, - }, - sort: [api.SortParamRequest(field: 'created_at', direction: 1)], - limit: 25, - prev: 'prev-cursor', - ), - ), - ).thenAnswer( - (_) async => Result.success( - api.PollVotesResponse(duration: '4.21ms', votes: [generatedVote, generatedAnswer], next: 'after', prev: 'x'), - ), - ); - final client = pollsClient(defaultApi); - - final result = await client.queryPollVotes( - 'poll-id', - filter: Filter.equal(PollVoteFilterField.isAnswer, true), - sort: [PollVoteSort.asc(PollVoteSortField.createdAt)], - limit: 25, - prev: 'prev-cursor', - ); - - expect( - result, - Result.success(QueryPollVotesResponse(duration: '4.21ms', votes: [vote, answer], next: 'after', prev: 'x')), - ); - }); - - test('StreamChatClient.queryPollVotes returns the failure without throwing', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.queryPollVotes( - pollId: 'poll-id', - queryPollVotesRequest: const api.QueryPollVotesRequest(limit: 10), - ), - ).thenAnswer((_) async => const Result.failure(pollsApiError)); - final client = pollsClient(defaultApi); - - final result = await client.queryPollVotes('poll-id'); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test('StreamChatClient.queryPollVotes sends ten as the default limit', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.queryPollVotes( - pollId: 'poll-id', - queryPollVotesRequest: const api.QueryPollVotesRequest(limit: 10), - ), - ).thenAnswer((_) async => const Result.success(api.PollVotesResponse(duration: '4.21ms', votes: []))); - final client = pollsClient(defaultApi); - - final result = await client.queryPollVotes('poll-id'); - - expect(result, const Result.success(QueryPollVotesResponse(duration: '4.21ms', votes: []))); - }); -} diff --git a/packages/stream_chat/test/src/client/client_polls_test.dart b/packages/stream_chat/test/src/client/client_polls_test.dart deleted file mode 100644 index 6259999002..0000000000 --- a/packages/stream_chat/test/src/client/client_polls_test.dart +++ /dev/null @@ -1,344 +0,0 @@ -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 'poll_fixtures.dart'; - -void main() { - test('StreamChatClient.createPoll sends the poll settings and returns the created poll', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.createPoll(createPollRequest: any(named: 'createPollRequest')), - ).thenAnswer((_) async => Result.success(generatedPollResponse)); - final client = pollsClient(defaultApi); - - final result = await client.createPoll(_newPoll()); - - final request = verify( - () => defaultApi.createPoll(createPollRequest: captureAny(named: 'createPollRequest')), - ).captured.single; - expect( - request, - const api.CreatePollRequest( - id: 'poll-id', - name: 'Lunch?', - description: 'Pick one', - options: [ - api.PollOptionInput(text: 'Pizza', custom: {'color': 'red'}), - api.PollOptionInput(text: 'Sushi', custom: {}), - ], - votingVisibility: api.CreatePollRequestVotingVisibility.anonymous, - enforceUniqueVote: false, - maxVotesAllowed: 2, - allowAnswers: true, - allowUserSuggestedOptions: true, - isClosed: false, - custom: {'topic': 'food'}, - ), - ); - expect(result, Result.success(pollResponse)); - }); - - test('StreamChatClient.createPoll returns the failure without throwing', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.createPoll(createPollRequest: any(named: 'createPollRequest')), - ).thenAnswer((_) async => const Result.failure(pollsApiError)); - final client = pollsClient(defaultApi); - - final result = await client.createPoll(_newPoll()); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test('StreamChatClient.getPoll sends the poll id and returns the poll', () async { - final defaultApi = pollsDefaultApi(); - when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(generatedPollResponse)); - final client = pollsClient(defaultApi); - - final result = await client.getPoll('poll-id'); - - expect(result, Result.success(pollResponse)); - }); - - test('StreamChatClient.getPoll returns the failure without throwing', () async { - final defaultApi = pollsDefaultApi(); - when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => const Result.failure(pollsApiError)); - final client = pollsClient(defaultApi); - - final result = await client.getPoll('poll-id'); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test( - "StreamChatClient.getPoll keeps custom fields named like the poll's own fields out of its custom data", - () async { - final defaultApi = pollsDefaultApi(); - final response = api.PollResponse( - duration: '4.21ms', - poll: generatedPoll.copyWith(custom: const {'topic': 'food', 'name': 'custom-name', 'own_votes': 'custom'}), - ); - when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(response)); - final client = pollsClient(defaultApi); - - final result = await client.getPoll('poll-id'); - - expect(result.getOrNull()?.poll.extraData, const {'topic': 'food'}); - }, - ); - - test('StreamChatClient.getPoll keeps a voting visibility the SDK does not name', () async { - final defaultApi = pollsDefaultApi(); - final response = api.PollResponse( - duration: '4.21ms', - poll: generatedPoll.copyWith(votingVisibility: api.PollResponseDataVotingVisibility.fromJson('members_only')), - ); - when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(response)); - final client = pollsClient(defaultApi); - - final result = await client.getPoll('poll-id'); - - expect(result.getOrNull()?.poll.votingVisibility, const VotingVisibility('members_only')); - }); - - test('StreamChatClient.getPoll reads a poll that does not say whether it is closed as open', () async { - final defaultApi = pollsDefaultApi(); - final response = api.PollResponse(duration: '4.21ms', poll: generatedPoll.copyWith(isClosed: null)); - when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(response)); - final client = pollsClient(defaultApi); - - final result = await client.getPoll('poll-id'); - - expect(result.getOrNull()?.poll.isClosed, isFalse); - }); - - test('StreamChatClient.updatePoll sends the poll settings and options and returns the updated poll', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.updatePoll(updatePollRequest: any(named: 'updatePollRequest')), - ).thenAnswer((_) async => Result.success(generatedPollResponse)); - final client = pollsClient(defaultApi); - - final result = await client.updatePoll( - _newPoll().copyWith( - options: const [ - PollOption(id: 'pizza', text: 'Pizza', extraData: {'color': 'red'}), - PollOption(id: 'sushi', text: 'Sushi'), - ], - isClosed: true, - ), - ); - - final request = verify( - () => defaultApi.updatePoll(updatePollRequest: captureAny(named: 'updatePollRequest')), - ).captured.single; - expect( - request, - const api.UpdatePollRequest( - id: 'poll-id', - name: 'Lunch?', - description: 'Pick one', - options: [ - api.PollOptionRequest(id: 'pizza', text: 'Pizza', custom: {'color': 'red'}), - api.PollOptionRequest(id: 'sushi', text: 'Sushi', custom: {}), - ], - votingVisibility: api.UpdatePollRequestVotingVisibility.anonymous, - enforceUniqueVote: false, - maxVotesAllowed: 2, - allowAnswers: true, - allowUserSuggestedOptions: true, - isClosed: true, - custom: {'topic': 'food'}, - ), - ); - expect(result, Result.success(pollResponse)); - }); - - test('StreamChatClient.updatePoll sends an option without an id with an empty id', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.updatePoll(updatePollRequest: any(named: 'updatePollRequest')), - ).thenAnswer((_) async => Result.success(generatedPollResponse)); - final client = pollsClient(defaultApi); - - await client.updatePoll(_newPoll().copyWith(options: const [PollOption(text: 'Pizza')])); - - final request = - verify( - () => defaultApi.updatePoll(updatePollRequest: captureAny(named: 'updatePollRequest')), - ).captured.single - as api.UpdatePollRequest; - expect(request.options, const [api.PollOptionRequest(id: '', text: 'Pizza', custom: {})]); - }); - - test('StreamChatClient.updatePoll returns the failure without throwing', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.updatePoll(updatePollRequest: any(named: 'updatePollRequest')), - ).thenAnswer((_) async => const Result.failure(pollsApiError)); - final client = pollsClient(defaultApi); - - final result = await client.updatePoll(poll); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test('StreamChatClient.partialUpdatePoll sends the fields to set and unset and returns the updated poll', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.updatePollPartial( - pollId: 'poll-id', - updatePollPartialRequest: const api.UpdatePollPartialRequest(set: {'name': 'Dinner?'}, unset: ['description']), - ), - ).thenAnswer((_) async => Result.success(generatedPollResponse)); - final client = pollsClient(defaultApi); - - final result = await client.partialUpdatePoll('poll-id', set: {'name': 'Dinner?'}, unset: ['description']); - - expect(result, Result.success(pollResponse)); - }); - - test('StreamChatClient.partialUpdatePoll returns the failure without throwing', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.updatePollPartial( - pollId: 'poll-id', - updatePollPartialRequest: const api.UpdatePollPartialRequest(set: {'name': 'Dinner?'}), - ), - ).thenAnswer((_) async => const Result.failure(pollsApiError)); - final client = pollsClient(defaultApi); - - final result = await client.partialUpdatePoll('poll-id', set: {'name': 'Dinner?'}); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test('StreamChatClient.closePoll marks the poll closed and returns the closed poll', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.updatePollPartial( - pollId: 'poll-id', - updatePollPartialRequest: const api.UpdatePollPartialRequest(set: {'is_closed': true}), - ), - ).thenAnswer((_) async => Result.success(generatedPollResponse)); - final client = pollsClient(defaultApi); - - final result = await client.closePoll('poll-id'); - - expect(result, Result.success(pollResponse)); - }); - - test('StreamChatClient.closePoll returns the failure without throwing', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.updatePollPartial( - pollId: 'poll-id', - updatePollPartialRequest: const api.UpdatePollPartialRequest(set: {'is_closed': true}), - ), - ).thenAnswer((_) async => const Result.failure(pollsApiError)); - final client = pollsClient(defaultApi); - - final result = await client.closePoll('poll-id'); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test('StreamChatClient.deletePoll sends the poll id and returns a success', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.deletePoll(pollId: 'poll-id'), - ).thenAnswer((_) async => const Result.success(api.DurationResponse(duration: '4.21ms'))); - final client = pollsClient(defaultApi); - - final result = await client.deletePoll('poll-id'); - - expect(result, const Result.success(null)); - }); - - test('StreamChatClient.deletePoll returns the failure without throwing', () async { - final defaultApi = pollsDefaultApi(); - when(() => defaultApi.deletePoll(pollId: 'poll-id')).thenAnswer((_) async => const Result.failure(pollsApiError)); - final client = pollsClient(defaultApi); - - final result = await client.deletePoll('poll-id'); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test('StreamChatClient.queryPolls sends the filter, sort and cursor and returns the matching polls', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.queryPolls( - queryPollsRequest: const api.QueryPollsRequest( - filter: { - 'is_closed': {r'$eq': true}, - }, - sort: [api.SortParamRequest(field: 'created_at', direction: -1)], - limit: 10, - next: 'next-cursor', - ), - ), - ).thenAnswer( - (_) async => Result.success( - api.QueryPollsResponse(duration: '4.21ms', polls: [generatedPoll], next: 'after', prev: 'before'), - ), - ); - final client = pollsClient(defaultApi); - - final result = await client.queryPolls( - filter: Filter.equal(PollFilterField.isClosed, true), - sort: [PollSort.desc(PollSortField.createdAt)], - limit: 10, - next: 'next-cursor', - ); - - expect( - result, - Result.success(QueryPollsResponse(duration: '4.21ms', polls: [poll], next: 'after', prev: 'before')), - ); - }); - - test('StreamChatClient.queryPolls sends the previous-page cursor and ten as the default limit', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.queryPolls(queryPollsRequest: const api.QueryPollsRequest(limit: 10, prev: 'prev-cursor')), - ).thenAnswer((_) async => const Result.success(api.QueryPollsResponse(duration: '4.21ms', polls: []))); - final client = pollsClient(defaultApi); - - final result = await client.queryPolls(prev: 'prev-cursor'); - - expect(result, const Result.success(QueryPollsResponse(duration: '4.21ms', polls: []))); - }); - - test('StreamChatClient.queryPolls returns the failure without throwing', () async { - final defaultApi = pollsDefaultApi(); - when( - () => defaultApi.queryPolls(queryPollsRequest: const api.QueryPollsRequest(limit: 10)), - ).thenAnswer((_) async => const Result.failure(pollsApiError)); - final client = pollsClient(defaultApi); - - final result = await client.queryPolls(); - - expect(result.exceptionOrNull(), pollsApiError); - }); -} - -// A poll as a caller builds it: options without ids and the vote summary at its defaults. -Poll _newPoll() => Poll( - id: 'poll-id', - name: 'Lunch?', - description: 'Pick one', - options: const [ - PollOption(text: 'Pizza', extraData: {'color': 'red'}), - PollOption(text: 'Sushi'), - ], - votingVisibility: VotingVisibility.anonymous, - enforceUniqueVote: false, - maxVotesAllowed: 2, - allowAnswers: true, - allowUserSuggestedOptions: true, - voteCount: 7, - extraData: const {'topic': 'food'}, -); diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 9401f55467..22ccdc69b6 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -13,6 +13,7 @@ import '../matchers.dart'; import '../mocks.dart'; import '../utils.dart'; import '../ws/fake_chat_server.dart'; +import 'poll_fixtures.dart'; void main() { group('Fake web-socket connection functions', () { @@ -1075,6 +1076,10 @@ void main() { registerFallbackValue(FakeDraftMessage()); registerFallbackValue(FakePollVote()); registerFallbackValue(const PaginationParams()); + registerFallbackValue(const api.CreatePollRequest(name: 'fallback')); + registerFallbackValue(const api.UpdatePollRequest(id: 'fallback', name: 'fallback')); + registerFallbackValue(const api.CreatePollOptionRequest(text: 'fallback')); + registerFallbackValue(const api.UpdatePollOptionRequest(id: 'fallback', text: 'fallback')); }); setUp(() async { @@ -2574,6 +2579,577 @@ void main() { expect(res.getOrNull()?.app.fileUploadConfig.sizeLimit, UploadConfig.defaultSizeLimit); }); + test('StreamChatClient.createPoll sends the poll settings and returns the created poll', () async { + when( + () => defaultApi.createPoll(createPollRequest: any(named: 'createPollRequest')), + ).thenAnswer((_) async => Result.success(generatedPollResponse)); + + final result = await client.createPoll(_newPoll()); + + final request = verify( + () => defaultApi.createPoll(createPollRequest: captureAny(named: 'createPollRequest')), + ).captured.single; + expect( + request, + const api.CreatePollRequest( + id: 'poll-id', + name: 'Lunch?', + description: 'Pick one', + options: [ + api.PollOptionInput(text: 'Pizza', custom: {'color': 'red'}), + api.PollOptionInput(text: 'Sushi', custom: {}), + ], + votingVisibility: api.CreatePollRequestVotingVisibility.anonymous, + enforceUniqueVote: false, + maxVotesAllowed: 2, + allowAnswers: true, + allowUserSuggestedOptions: true, + isClosed: false, + custom: {'topic': 'food'}, + ), + ); + expect(result, Result.success(pollResponse)); + }); + + test('StreamChatClient.createPoll returns the failure without throwing', () async { + when( + () => defaultApi.createPoll(createPollRequest: any(named: 'createPollRequest')), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.createPoll(_newPoll()); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.getPoll sends the poll id and returns the poll', () async { + when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(generatedPollResponse)); + + final result = await client.getPoll('poll-id'); + + expect(result, Result.success(pollResponse)); + }); + + test('StreamChatClient.getPoll returns the failure without throwing', () async { + when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.getPoll('poll-id'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test( + "StreamChatClient.getPoll keeps custom fields named like the poll's own fields out of its custom data", + () async { + final response = api.PollResponse( + duration: '4.21ms', + poll: generatedPoll.copyWith(custom: const {'topic': 'food', 'name': 'custom-name', 'own_votes': 'custom'}), + ); + when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(response)); + + final result = await client.getPoll('poll-id'); + + expect(result.getOrNull()?.poll.extraData, const {'topic': 'food'}); + }, + ); + + test('StreamChatClient.getPoll keeps a voting visibility the SDK does not name', () async { + final response = api.PollResponse( + duration: '4.21ms', + poll: generatedPoll.copyWith(votingVisibility: api.PollResponseDataVotingVisibility.fromJson('members_only')), + ); + when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(response)); + + final result = await client.getPoll('poll-id'); + + expect(result.getOrNull()?.poll.votingVisibility, const VotingVisibility('members_only')); + }); + + test('StreamChatClient.getPoll reads a poll that does not say whether it is closed as open', () async { + final response = api.PollResponse(duration: '4.21ms', poll: generatedPoll.copyWith(isClosed: null)); + when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(response)); + + final result = await client.getPoll('poll-id'); + + expect(result.getOrNull()?.poll.isClosed, isFalse); + }); + + test('StreamChatClient.updatePoll sends the poll settings and options and returns the updated poll', () async { + when( + () => defaultApi.updatePoll(updatePollRequest: any(named: 'updatePollRequest')), + ).thenAnswer((_) async => Result.success(generatedPollResponse)); + + final result = await client.updatePoll( + _newPoll().copyWith( + options: const [ + PollOption(id: 'pizza', text: 'Pizza', extraData: {'color': 'red'}), + PollOption(id: 'sushi', text: 'Sushi'), + ], + isClosed: true, + ), + ); + + final request = verify( + () => defaultApi.updatePoll(updatePollRequest: captureAny(named: 'updatePollRequest')), + ).captured.single; + expect( + request, + const api.UpdatePollRequest( + id: 'poll-id', + name: 'Lunch?', + description: 'Pick one', + options: [ + api.PollOptionRequest(id: 'pizza', text: 'Pizza', custom: {'color': 'red'}), + api.PollOptionRequest(id: 'sushi', text: 'Sushi', custom: {}), + ], + votingVisibility: api.UpdatePollRequestVotingVisibility.anonymous, + enforceUniqueVote: false, + maxVotesAllowed: 2, + allowAnswers: true, + allowUserSuggestedOptions: true, + isClosed: true, + custom: {'topic': 'food'}, + ), + ); + expect(result, Result.success(pollResponse)); + }); + + test('StreamChatClient.updatePoll sends an option without an id with an empty id', () async { + when( + () => defaultApi.updatePoll(updatePollRequest: any(named: 'updatePollRequest')), + ).thenAnswer((_) async => Result.success(generatedPollResponse)); + + await client.updatePoll(_newPoll().copyWith(options: const [PollOption(text: 'Pizza')])); + + final request = + verify( + () => defaultApi.updatePoll(updatePollRequest: captureAny(named: 'updatePollRequest')), + ).captured.single + as api.UpdatePollRequest; + expect(request.options, const [api.PollOptionRequest(id: '', text: 'Pizza', custom: {})]); + }); + + test('StreamChatClient.updatePoll returns the failure without throwing', () async { + when( + () => defaultApi.updatePoll(updatePollRequest: any(named: 'updatePollRequest')), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.updatePoll(poll); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.partialUpdatePoll sends the fields to set and unset and returns the updated poll', () async { + when( + () => defaultApi.updatePollPartial( + pollId: 'poll-id', + updatePollPartialRequest: const api.UpdatePollPartialRequest( + set: {'name': 'Dinner?'}, + unset: ['description'], + ), + ), + ).thenAnswer((_) async => Result.success(generatedPollResponse)); + + final result = await client.partialUpdatePoll('poll-id', set: {'name': 'Dinner?'}, unset: ['description']); + + expect(result, Result.success(pollResponse)); + }); + + test('StreamChatClient.partialUpdatePoll returns the failure without throwing', () async { + when( + () => defaultApi.updatePollPartial( + pollId: 'poll-id', + updatePollPartialRequest: const api.UpdatePollPartialRequest(set: {'name': 'Dinner?'}), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.partialUpdatePoll('poll-id', set: {'name': 'Dinner?'}); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.closePoll marks the poll closed and returns the closed poll', () async { + when( + () => defaultApi.updatePollPartial( + pollId: 'poll-id', + updatePollPartialRequest: const api.UpdatePollPartialRequest(set: {'is_closed': true}), + ), + ).thenAnswer((_) async => Result.success(generatedPollResponse)); + + final result = await client.closePoll('poll-id'); + + expect(result, Result.success(pollResponse)); + }); + + test('StreamChatClient.closePoll returns the failure without throwing', () async { + when( + () => defaultApi.updatePollPartial( + pollId: 'poll-id', + updatePollPartialRequest: const api.UpdatePollPartialRequest(set: {'is_closed': true}), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.closePoll('poll-id'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.deletePoll sends the poll id and returns a success', () async { + when( + () => defaultApi.deletePoll(pollId: 'poll-id'), + ).thenAnswer((_) async => const Result.success(api.DurationResponse(duration: '4.21ms'))); + + final result = await client.deletePoll('poll-id'); + + expect(result, const Result.success(null)); + }); + + test('StreamChatClient.deletePoll returns the failure without throwing', () async { + when(() => defaultApi.deletePoll(pollId: 'poll-id')).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.deletePoll('poll-id'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.queryPolls sends the filter, sort and cursor and returns the matching polls', () async { + when( + () => defaultApi.queryPolls( + queryPollsRequest: const api.QueryPollsRequest( + filter: { + 'is_closed': {r'$eq': true}, + }, + sort: [api.SortParamRequest(field: 'created_at', direction: -1)], + limit: 10, + next: 'next-cursor', + ), + ), + ).thenAnswer( + (_) async => Result.success( + api.QueryPollsResponse(duration: '4.21ms', polls: [generatedPoll], next: 'after', prev: 'before'), + ), + ); + + final result = await client.queryPolls( + filter: Filter.equal(PollFilterField.isClosed, true), + sort: [PollSort.desc(PollSortField.createdAt)], + limit: 10, + next: 'next-cursor', + ); + + expect( + result, + Result.success(QueryPollsResponse(duration: '4.21ms', polls: [poll], next: 'after', prev: 'before')), + ); + }); + + test('StreamChatClient.queryPolls sends the previous-page cursor and ten as the default limit', () async { + when( + () => defaultApi.queryPolls(queryPollsRequest: const api.QueryPollsRequest(limit: 10, prev: 'prev-cursor')), + ).thenAnswer((_) async => const Result.success(api.QueryPollsResponse(duration: '4.21ms', polls: []))); + + final result = await client.queryPolls(prev: 'prev-cursor'); + + expect(result, const Result.success(QueryPollsResponse(duration: '4.21ms', polls: []))); + }); + + test('StreamChatClient.queryPolls returns the failure without throwing', () async { + when( + () => defaultApi.queryPolls(queryPollsRequest: const api.QueryPollsRequest(limit: 10)), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.queryPolls(); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test( + 'StreamChatClient.createPollOption sends the option text and custom data and returns the created option', + () async { + when( + () => defaultApi.createPollOption( + pollId: 'poll-id', + createPollOptionRequest: const api.CreatePollOptionRequest(text: 'Pizza', custom: {'color': 'red'}), + ), + ).thenAnswer((_) async => const Result.success(_generatedPizzaResponse)); + + final result = await client.createPollOption( + 'poll-id', + const PollOption(text: 'Pizza', extraData: {'color': 'red'}), + ); + + expect(result, const Result.success(_pizzaResponse)); + }, + ); + + test('StreamChatClient.createPollOption returns the failure without throwing', () async { + when( + () => defaultApi.createPollOption( + pollId: 'poll-id', + createPollOptionRequest: any(named: 'createPollOptionRequest'), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.createPollOption('poll-id', const PollOption(text: 'Pizza')); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.getPollOption sends the poll and option ids and returns the option', () async { + when( + () => defaultApi.getPollOption(pollId: 'poll-id', optionId: 'pizza'), + ).thenAnswer((_) async => const Result.success(_generatedPizzaResponse)); + + final result = await client.getPollOption('poll-id', 'pizza'); + + expect(result, const Result.success(_pizzaResponse)); + }); + + test('StreamChatClient.getPollOption returns the failure without throwing', () async { + when( + () => defaultApi.getPollOption(pollId: 'poll-id', optionId: 'pizza'), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.getPollOption('poll-id', 'pizza'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test( + "StreamChatClient.getPollOption keeps custom fields named like the option's own fields out of its custom data", + () async { + final response = api.PollOptionResponse( + duration: '4.21ms', + pollOption: generatedPizza.copyWith(custom: const {'color': 'red', 'text': 'custom-text'}), + ); + when( + () => defaultApi.getPollOption(pollId: 'poll-id', optionId: 'pizza'), + ).thenAnswer((_) async => Result.success(response)); + + final result = await client.getPollOption('poll-id', 'pizza'); + + expect(result.getOrNull()?.pollOption.extraData, const {'color': 'red'}); + }, + ); + + test('StreamChatClient.updatePollOption sends the option and returns the updated option', () async { + when( + () => defaultApi.updatePollOption( + pollId: 'poll-id', + updatePollOptionRequest: const api.UpdatePollOptionRequest( + id: 'pizza', + text: 'Pizza', + custom: {'color': 'red'}, + ), + ), + ).thenAnswer((_) async => const Result.success(_generatedPizzaResponse)); + + final result = await client.updatePollOption('poll-id', pizza); + + expect(result, const Result.success(_pizzaResponse)); + }); + + test('StreamChatClient.updatePollOption sends an option without an id with an empty id', () async { + when( + () => defaultApi.updatePollOption( + pollId: 'poll-id', + updatePollOptionRequest: const api.UpdatePollOptionRequest(id: '', text: 'Pizza', custom: {}), + ), + ).thenAnswer((_) async => const Result.success(_generatedPizzaResponse)); + + final result = await client.updatePollOption('poll-id', const PollOption(text: 'Pizza')); + + expect(result, const Result.success(_pizzaResponse)); + }); + + test('StreamChatClient.updatePollOption returns the failure without throwing', () async { + when( + () => defaultApi.updatePollOption( + pollId: 'poll-id', + updatePollOptionRequest: any(named: 'updatePollOptionRequest'), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.updatePollOption('poll-id', pizza); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.deletePollOption sends the poll and option ids and returns a success', () async { + when( + () => defaultApi.deletePollOption(pollId: 'poll-id', optionId: 'pizza'), + ).thenAnswer((_) async => const Result.success(api.DurationResponse(duration: '4.21ms'))); + + final result = await client.deletePollOption('poll-id', 'pizza'); + + expect(result, const Result.success(null)); + }); + + test('StreamChatClient.deletePollOption returns the failure without throwing', () async { + when( + () => defaultApi.deletePollOption(pollId: 'poll-id', optionId: 'pizza'), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.deletePollOption('poll-id', 'pizza'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.castPollVote sends the selected option and returns the cast vote', () async { + when( + () => defaultApi.castPollVote( + messageId: 'message-id', + pollId: 'poll-id', + castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(optionId: 'pizza')), + ), + ).thenAnswer( + (_) async => Result.success(api.PollVoteResponse(duration: '4.21ms', poll: generatedPoll, vote: generatedVote)), + ); + + final result = await client.castPollVote('message-id', 'poll-id', optionId: 'pizza'); + + expect(result, Result.success(PollVoteResponse(duration: '4.21ms', vote: vote))); + }); + + test('StreamChatClient.castPollVote returns the failure without throwing', () async { + when( + () => defaultApi.castPollVote( + messageId: 'message-id', + pollId: 'poll-id', + castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(optionId: 'pizza')), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.castPollVote('message-id', 'poll-id', optionId: 'pizza'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.castPollVote returns no vote when the response carries none', () async { + when( + () => defaultApi.castPollVote( + messageId: 'message-id', + pollId: 'poll-id', + castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(optionId: 'pizza')), + ), + ).thenAnswer((_) async => const Result.success(api.PollVoteResponse(duration: '4.21ms'))); + + final result = await client.castPollVote('message-id', 'poll-id', optionId: 'pizza'); + + expect(result, const Result.success(PollVoteResponse(duration: '4.21ms'))); + }); + + test('StreamChatClient.addPollAnswer sends the answer text and returns the answer', () async { + when( + () => defaultApi.castPollVote( + messageId: 'message-id', + pollId: 'poll-id', + castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(answerText: 'Anything')), + ), + ).thenAnswer( + (_) async => + Result.success(api.PollVoteResponse(duration: '4.21ms', poll: generatedPoll, vote: generatedAnswer)), + ); + + final result = await client.addPollAnswer('message-id', 'poll-id', answerText: 'Anything'); + + expect(result, Result.success(PollVoteResponse(duration: '4.21ms', vote: answer))); + }); + + test('StreamChatClient.addPollAnswer returns the failure without throwing', () async { + when( + () => defaultApi.castPollVote( + messageId: 'message-id', + pollId: 'poll-id', + castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(answerText: 'Anything')), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.addPollAnswer('message-id', 'poll-id', answerText: 'Anything'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.removePollVote sends the vote id and returns the removed vote', () async { + when( + () => defaultApi.deletePollVote(messageId: 'message-id', pollId: 'poll-id', voteId: 'vote-id'), + ).thenAnswer( + (_) async => Result.success(api.PollVoteResponse(duration: '4.21ms', poll: generatedPoll, vote: generatedVote)), + ); + + final result = await client.removePollVote('message-id', 'poll-id', 'vote-id'); + + expect(result, Result.success(PollVoteResponse(duration: '4.21ms', vote: vote))); + }); + + test('StreamChatClient.removePollVote returns the failure without throwing', () async { + when( + () => defaultApi.deletePollVote(messageId: 'message-id', pollId: 'poll-id', voteId: 'vote-id'), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.removePollVote('message-id', 'poll-id', 'vote-id'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.queryPollVotes sends the filter, sort and cursor and returns the matching votes', () async { + when( + () => defaultApi.queryPollVotes( + pollId: 'poll-id', + queryPollVotesRequest: const api.QueryPollVotesRequest( + filter: { + 'is_answer': {r'$eq': true}, + }, + sort: [api.SortParamRequest(field: 'created_at', direction: 1)], + limit: 25, + prev: 'prev-cursor', + ), + ), + ).thenAnswer( + (_) async => Result.success( + api.PollVotesResponse(duration: '4.21ms', votes: [generatedVote, generatedAnswer], next: 'after', prev: 'x'), + ), + ); + + final result = await client.queryPollVotes( + 'poll-id', + filter: Filter.equal(PollVoteFilterField.isAnswer, true), + sort: [PollVoteSort.asc(PollVoteSortField.createdAt)], + limit: 25, + prev: 'prev-cursor', + ); + + expect( + result, + Result.success(QueryPollVotesResponse(duration: '4.21ms', votes: [vote, answer], next: 'after', prev: 'x')), + ); + }); + + test('StreamChatClient.queryPollVotes returns the failure without throwing', () async { + when( + () => defaultApi.queryPollVotes( + pollId: 'poll-id', + queryPollVotesRequest: const api.QueryPollVotesRequest(limit: 10), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.queryPollVotes('poll-id'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.queryPollVotes sends ten as the default limit', () async { + when( + () => defaultApi.queryPollVotes( + pollId: 'poll-id', + queryPollVotesRequest: const api.QueryPollVotesRequest(limit: 10), + ), + ).thenAnswer((_) async => const Result.success(api.PollVotesResponse(duration: '4.21ms', votes: []))); + + final result = await client.queryPollVotes('poll-id'); + + expect(result, const Result.success(QueryPollVotesResponse(duration: '4.21ms', votes: []))); + }); + group('`.channel`', () { test('should return back a new channel instance', () { const channelType = 'test-channel-type'; @@ -6650,3 +7226,25 @@ UserGroup _userGroup(String id) { api.DeviceResponse _generatedDevice({required String id, required String pushProvider}) { return api.DeviceResponse(id: id, pushProvider: pushProvider, createdAt: DateTime.utc(2024), userId: 'test-user-id'); } + +// A poll as a caller builds it: options without ids and the vote summary at its defaults. +Poll _newPoll() => Poll( + id: 'poll-id', + name: 'Lunch?', + description: 'Pick one', + options: const [ + PollOption(text: 'Pizza', extraData: {'color': 'red'}), + PollOption(text: 'Sushi'), + ], + votingVisibility: VotingVisibility.anonymous, + enforceUniqueVote: false, + maxVotesAllowed: 2, + allowAnswers: true, + allowUserSuggestedOptions: true, + voteCount: 7, + extraData: const {'topic': 'food'}, +); + +const _generatedPizzaResponse = api.PollOptionResponse(duration: '4.21ms', pollOption: generatedPizza); + +const _pizzaResponse = PollOptionResponse(duration: '4.21ms', pollOption: pizza); diff --git a/packages/stream_chat/test/src/client/poll_fixtures.dart b/packages/stream_chat/test/src/client/poll_fixtures.dart index 69ae70db62..84c4b87bb8 100644 --- a/packages/stream_chat/test/src/client/poll_fixtures.dart +++ b/packages/stream_chat/test/src/client/poll_fixtures.dart @@ -1,29 +1,9 @@ -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'; // Fixtures shared by the poll client tests. Every generated object carries a value in every field, including the // ones the SDK models leave out, so a field the mapping drops or swaps changes the compared object. -/// A [StreamChatClient] whose generated API is [defaultApi], disposed when the test ends. -StreamChatClient pollsClient(api.DefaultApi defaultApi) { - final client = StreamChatClient('test-api-key', defaultApi: defaultApi); - addTearDown(client.dispose); - return client; -} - -/// A [MockDefaultApi] with the fallback values the poll requests need. -MockDefaultApi pollsDefaultApi() { - registerFallbackValue(const api.CreatePollRequest(name: 'fallback')); - registerFallbackValue(const api.UpdatePollRequest(id: 'fallback', name: 'fallback')); - registerFallbackValue(const api.CreatePollOptionRequest(text: 'fallback')); - registerFallbackValue(const api.UpdatePollOptionRequest(id: 'fallback', text: 'fallback')); - return MockDefaultApi(); -} - /// A failure the generated API answers with. const pollsApiError = StreamClientException(message: 'boom'); From ab5bdefbd1eb2db1db89334d13ae0963b5c2558e Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 2 Oct 2026 19:14:55 +0200 Subject: [PATCH 09/32] refactor(llc): apply review remarks on connectGuestUser and the user mappers Co-Authored-By: Claude Opus 5.5 --- openapi-migration/04-roles-guest-and-app.md | 5 +- openapi-migration/tool/generate_plan.py | 5 +- packages/stream_chat/CHANGELOG.md | 2 +- .../stream_chat/lib/src/client/client.dart | 13 ++-- .../src/repository/mapper/user_mapper.dart | 28 ++++---- .../client_connect_guest_user_test.dart | 67 ++++++++++++++++++- 6 files changed, 95 insertions(+), 25 deletions(-) diff --git a/openapi-migration/04-roles-guest-and-app.md b/openapi-migration/04-roles-guest-and-app.md index f6da6636af..5017d28e87 100644 --- a/openapi-migration/04-roles-guest-and-app.md +++ b/openapi-migration/04-roles-guest-and-app.md @@ -90,8 +90,9 @@ in the raw `me`. None was ever applied. Verified live against the demo app and in the backend source; recorded as a 🐞 Fixed entry. - **`UserResponse.toModel()` leaves custom keys named like `OwnUser` fields (`OwnUser.topLevelFields`), - plus `deleted_at`, `deactivated_at` and `revoke_tokens_issued_before`, out of `extraData`.** v1's flat user JSON shadowed them; v2 nests custom data, and without the guard a - custom `online` string crashed `OwnUser.fromUser`. + plus `deleted_at`, `deactivated_at` and `revoke_tokens_issued_before`, out of `extraData`.** v1's flat + user JSON shadowed them; v2 nests custom data, and without the guard a custom `online` string crashed + `OwnUser.fromUser`. `User.toRequest()` leaves the same keys out of the custom data it sends. - **The three extra names are guarded because the socket connect refuses them.** A custom `deleted_at`, `deactivated_at` or `revoke_tokens_issued_before` left in `extraData` is re-sent in the connect's user details, and the backend answers 400 ("reserved field", or "expected date" for a diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 2ece18d044..85e55d966f 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -286,8 +286,9 @@ def match(path): in the raw `me`. None was ever applied. Verified live against the demo app and in the backend source; recorded as a 🐞 Fixed entry. - **`UserResponse.toModel()` leaves custom keys named like `OwnUser` fields (`OwnUser.topLevelFields`), - plus `deleted_at`, `deactivated_at` and `revoke_tokens_issued_before`, out of `extraData`.** v1's flat user JSON shadowed them; v2 nests custom data, and without the guard a - custom `online` string crashed `OwnUser.fromUser`. + plus `deleted_at`, `deactivated_at` and `revoke_tokens_issued_before`, out of `extraData`.** v1's flat + user JSON shadowed them; v2 nests custom data, and without the guard a custom `online` string crashed + `OwnUser.fromUser`. `User.toRequest()` leaves the same keys out of the custom data it sends. - **The three extra names are guarded because the socket connect refuses them.** A custom `deleted_at`, `deactivated_at` or `revoke_tokens_issued_before` left in `extraData` is re-sent in the connect's user details, and the backend answers 400 ("reserved field", or "expected date" for a diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 3359cbe5c2..6d0c00ea2d 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -82,7 +82,7 @@ 🐞 Fixed -- `connectGuestUser` no longer saves the user's client state (online, banned, unread counts, devices, push preferences and similar) as custom data on the created guest. +- `connectGuestUser` no longer copies the user's online status, ban, unread counts, devices and push preferences into the created guest's `extraData`. - `queryChannels` no longer gives up watching the channels it loads when it is called before the connection is open. Whether they can be watched is read when the request is sent, which is after the query has waited for the connection. diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 57a2aa52c1..1680c87926 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -446,11 +446,14 @@ class StreamChatClient { ); } - /// Connects a guest created from [user], which triggers a connection to the API. + /// Signs in a new guest created from [user] and returns it. /// - /// Returns a [Future] that resolves when the connection is set up. Throws a [StateError] if a user is already - /// signed in, so [disconnectUser] comes first, and a [StreamException] if the guest cannot be created or the - /// connection fails. + /// The guest gets an id of its own and the guest role, so the returned user is not [user]. If [connectWebSocket] + /// is false, the guest is signed in without opening a connection. + /// + /// Throws a [StateError] if a user is already signed in; call [disconnectUser] first. + /// + /// Throws a [StreamException] if the guest cannot be created or the connection fails. Future connectGuestUser( User user, { bool connectWebSocket = true, @@ -474,7 +477,7 @@ class StreamChatClient { final result = await _generalRepository.createGuest(user); - // Unlike the endpoint methods, connecting reports a failure by throwing. + // A failed sign-in throws, like the other connect methods. final guestUser = result.getOrElse((error, stackTrace) { final exception = StreamException.tryFrom(error) ?? diff --git a/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart index 18d2c1ca95..a2f3e37612 100644 --- a/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart @@ -5,16 +5,21 @@ import '../../core/models/response/connect_guest_user_response.dart'; import '../../core/models/user.dart'; import '../../core/util/extension.dart'; -// The user and privacy settings mappers in this file build today's `User` class, which has not been restructured -// for the generated client yet and still reads and writes JSON. -// TODO(openapi-migration): Update these mappers when `User` is restructured. +// 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', + 'revoke_tokens_issued_before', + }; + /// Converts this response into a [User]. /// - /// [custom] becomes [User.extraData], without the keys named after a field of the user's own, so a custom field - /// never stands in for one of them. + /// Custom data named like one of the user's own fields is left out of [User.extraData]. User toModel() => User( id: id, role: role, @@ -33,18 +38,14 @@ extension UserResponseMapper on api.UserResponse { ); } -// Custom keys a user's own fields hide: every field of an [OwnUser], and three the socket connect refuses to take -// back from its user details. -const _shadowedCustomKeys = {...OwnUser.topLevelFields, 'deleted_at', 'deactivated_at', 'revoke_tokens_issued_before'}; - /// Maps a [User] to the generated [api.UserRequest]. extension UserRequestMapper on User { /// Converts this user into an [api.UserRequest]. /// - /// The `name` and `image` keys of [extraData] become their own fields and the rest becomes - /// [api.UserRequest.custom]. The name is read from [extraData] rather than [name], which falls back to [id]. + /// 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. /// - /// The privacy settings of an [OwnUser] are carried over as well. + /// Custom data named like one of the user's own fields is left out. api.UserRequest toRequest() => api.UserRequest( id: id, name: extraData['name'].safeCast(), @@ -57,7 +58,8 @@ extension UserRequestMapper on User { }, custom: {...extraData} ..remove('name') - ..remove('image'), + ..remove('image') + ..removeWhere((key, _) => UserResponseMapper._shadowedCustomKeys.contains(key)), ); } 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 992bb5af44..c5d5ef6ccd 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 @@ -11,11 +11,11 @@ import '../ws/fake_chat_server.dart'; const _guestId = 'guest-1b2c3d4e-requested-id'; void main() { - test('StreamChatClient.connectGuestUser sends the user and returns the created guest', () async { + test('StreamChatClient.connectGuestUser sends only the fields a guest takes from the user', () async { final defaultApi = _defaultApiAnswering(Result.success(_createGuestResponse())); final client = _client(defaultApi); - final guest = await client.connectGuestUser(_fullyPopulatedUser(), connectWebSocket: false); + await client.connectGuestUser(_fullyPopulatedUser(), connectWebSocket: false); final request = verify( () => defaultApi.createGuest(createGuestRequest: captureAny(named: 'createGuestRequest')), @@ -38,12 +38,42 @@ void main() { ), ), ); + }); + + test('StreamChatClient.connectGuestUser returns the created guest', () async { + final client = _client(_defaultApiAnswering(Result.success(_createGuestResponse()))); + + final guest = await client.connectGuestUser(_fullyPopulatedUser(), connectWebSocket: false); + expect(guest, OwnUser.fromUser(_createdGuest())); // `User` equality leaves these two out. expect(guest.createdAt, DateTime.utc(2026, 9, 1)); 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); @@ -109,6 +139,39 @@ void main() { throwsA(isA().having((it) => it.statusCode, 'statusCode', 403)), ); }); + + test( + 'StreamChatClient.connectGuestUser throws a StreamClientException when the guest cannot be created for a reason ' + 'other than a StreamException', + () async { + final client = _client(_defaultApiAnswering(const Result.failure(FormatException('Unexpected response')))); + + await expectLater( + client.connectGuestUser(_fullyPopulatedUser()), + throwsA(isA().having((it) => it.cause, 'cause', isA())), + ); + }, + ); + + test('StreamChatClient.connectGuestUser throws when the connection fails', () async { + final client = _client( + _defaultApiAnswering(Result.success(_createGuestResponse())), + server: FakeChatServer()..handshakeFails = true, + ); + + await expectLater( + client.connectGuestUser(_fullyPopulatedUser()), + throwsA(isA()), + ); + }); + + test('StreamChatClient.connectGuestUser leaves the connection closed when connectWebSocket is false', () async { + final client = _client(_defaultApiAnswering(Result.success(_createGuestResponse()))); + + await client.connectGuestUser(_fullyPopulatedUser(), connectWebSocket: false); + + expect(client.connectionStatus, ConnectionStatus.disconnected); + }); } StreamChatClient _client(api.DefaultApi defaultApi, {FakeChatServer? server}) { From 4439cf3db3f03d3fa688f2228e5aaccc62d48f75 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 2 Oct 2026 20:55:54 +0200 Subject: [PATCH 10/32] refactor(llc): rename the internal guest envelope to CreateGuestUserResponse Co-Authored-By: Claude Opus 5.5 --- openapi-migration/04-roles-guest-and-app.md | 5 ++- openapi-migration/tool/generate_plan.py | 5 ++- packages/stream_chat/CHANGELOG.md | 3 +- ...e.dart => create_guest_user_response.dart} | 8 ++-- ...> create_guest_user_response.freezed.dart} | 45 ++++++++----------- .../src/repository/general_repository.dart | 4 +- .../src/repository/mapper/user_mapper.dart | 8 ++-- 7 files changed, 36 insertions(+), 42 deletions(-) rename packages/stream_chat/lib/src/core/models/response/{connect_guest_user_response.dart => create_guest_user_response.dart} (73%) rename packages/stream_chat/lib/src/core/models/response/{connect_guest_user_response.freezed.dart => create_guest_user_response.freezed.dart} (63%) diff --git a/openapi-migration/04-roles-guest-and-app.md b/openapi-migration/04-roles-guest-and-app.md index 5017d28e87..e8df714c1d 100644 --- a/openapi-migration/04-roles-guest-and-app.md +++ b/openapi-migration/04-roles-guest-and-app.md @@ -78,8 +78,9 @@ 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. -- **`ConnectGuestUserResponse` is internal:** a freezed envelope that only `createGuest` returns. Its - only public reach in v10 was `StreamChatApi.guest`, removed together with `GuestApi`. +- **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`. - **The request carries every field the backend honours for a client-side guest:** id, name, image, custom data, language, `invisible` and, for an `OwnUser`, privacy settings. `role`, `teams` and `teams_role` are ignored for client-side calls and are not sent. diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 1629cce145..151e2843f3 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -274,8 +274,9 @@ def match(path): 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. - - **`ConnectGuestUserResponse` is internal:** a freezed envelope that only `createGuest` returns. Its - only public reach in v10 was `StreamChatApi.guest`, removed together with `GuestApi`. + - **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`. - **The request carries every field the backend honours for a client-side guest:** id, name, image, custom data, language, `invisible` and, for an `OwnUser`, privacy settings. `role`, `teams` and `teams_role` are ignored for client-side calls and are not sent. diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 08e0fd749b..09cca1ec02 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -77,8 +77,7 @@ - `AppSettingsResponse` is immutable, built through a const constructor, compares by value, gains `copyWith`, and its `duration` is a non-nullable `String`. - `AppSettings` and `UploadConfig` no longer extend `Equatable`, so `props` is removed; they still compare by value and gain `copyWith`. - `StreamChatApi.general.getAppSettings` is removed; call `StreamChatClient.getAppSettings` instead. -- `StreamChatApi.guest` is removed; call `StreamChatClient.connectGuestUser` instead. -- `ConnectGuestUserResponse` is removed from the public API. +- `StreamChatApi.guest` and `ConnectGuestUserResponse` are removed; call `StreamChatClient.connectGuestUser` instead. - `sendImage`, `sendFile`, `uploadImage` and `uploadFile`, on `StreamChatClient`, `Channel` and `AttachmentFileUploader`, return a `Result` instead of throwing; read the URL from `fileUrl` and a video's thumbnail from `thumbUrl`. - `deleteImage`, `deleteFile`, `removeImage` and `removeFile` return a `Result` instead of throwing, and carry no value on success. - `SendAttachmentResponse`, `SendFileResponse`, `SendImageResponse`, `UploadImageResponse` and `UploadFileResponse` are removed in favor of `UploadedFile`. diff --git a/packages/stream_chat/lib/src/core/models/response/connect_guest_user_response.dart b/packages/stream_chat/lib/src/core/models/response/create_guest_user_response.dart similarity index 73% rename from packages/stream_chat/lib/src/core/models/response/connect_guest_user_response.dart rename to packages/stream_chat/lib/src/core/models/response/create_guest_user_response.dart index 22ddf57da8..4b17e3f2aa 100644 --- a/packages/stream_chat/lib/src/core/models/response/connect_guest_user_response.dart +++ b/packages/stream_chat/lib/src/core/models/response/create_guest_user_response.dart @@ -2,13 +2,13 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import '../user.dart'; -part 'connect_guest_user_response.freezed.dart'; +part 'create_guest_user_response.freezed.dart'; /// A newly created guest and the token that authenticates it. @freezed -class ConnectGuestUserResponse with _$ConnectGuestUserResponse { - /// Creates a new [ConnectGuestUserResponse]. - const ConnectGuestUserResponse({ +class CreateGuestUserResponse with _$CreateGuestUserResponse { + /// Creates a new [CreateGuestUserResponse]. + const CreateGuestUserResponse({ required this.duration, required this.accessToken, required this.user, diff --git a/packages/stream_chat/lib/src/core/models/response/connect_guest_user_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/create_guest_user_response.freezed.dart similarity index 63% rename from packages/stream_chat/lib/src/core/models/response/connect_guest_user_response.freezed.dart rename to packages/stream_chat/lib/src/core/models/response/create_guest_user_response.freezed.dart index 956b65604f..ac5c6cbb5f 100644 --- a/packages/stream_chat/lib/src/core/models/response/connect_guest_user_response.freezed.dart +++ b/packages/stream_chat/lib/src/core/models/response/create_guest_user_response.freezed.dart @@ -3,7 +3,7 @@ // 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 'connect_guest_user_response.dart'; +part of 'create_guest_user_response.dart'; // ************************************************************************** // FreezedGenerator @@ -12,26 +12,23 @@ part of 'connect_guest_user_response.dart'; T _$identity(T value) => value; /// @nodoc -mixin _$ConnectGuestUserResponse { +mixin _$CreateGuestUserResponse { String get duration; String get accessToken; User get user; - /// Create a copy of ConnectGuestUserResponse + /// Create a copy of CreateGuestUserResponse /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') - $ConnectGuestUserResponseCopyWith get copyWith => - _$ConnectGuestUserResponseCopyWithImpl( - this as ConnectGuestUserResponse, - _$identity, - ); + $CreateGuestUserResponseCopyWith get copyWith => + _$CreateGuestUserResponseCopyWithImpl(this as CreateGuestUserResponse, _$identity); @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is ConnectGuestUserResponse && + other is CreateGuestUserResponse && (identical(other.duration, duration) || other.duration == duration) && (identical(other.accessToken, accessToken) || other.accessToken == accessToken) && (identical(other.user, user) || other.user == user)); @@ -42,38 +39,34 @@ mixin _$ConnectGuestUserResponse { @override String toString() { - return 'ConnectGuestUserResponse(duration: $duration, accessToken: $accessToken, user: $user)'; + return 'CreateGuestUserResponse(duration: $duration, accessToken: $accessToken, user: $user)'; } } /// @nodoc -abstract mixin class $ConnectGuestUserResponseCopyWith<$Res> { - factory $ConnectGuestUserResponseCopyWith( - ConnectGuestUserResponse value, - $Res Function(ConnectGuestUserResponse) _then, - ) = _$ConnectGuestUserResponseCopyWithImpl; +abstract mixin class $CreateGuestUserResponseCopyWith<$Res> { + factory $CreateGuestUserResponseCopyWith( + CreateGuestUserResponse value, + $Res Function(CreateGuestUserResponse) _then, + ) = _$CreateGuestUserResponseCopyWithImpl; @useResult $Res call({String duration, String accessToken, User user}); } /// @nodoc -class _$ConnectGuestUserResponseCopyWithImpl<$Res> implements $ConnectGuestUserResponseCopyWith<$Res> { - _$ConnectGuestUserResponseCopyWithImpl(this._self, this._then); +class _$CreateGuestUserResponseCopyWithImpl<$Res> implements $CreateGuestUserResponseCopyWith<$Res> { + _$CreateGuestUserResponseCopyWithImpl(this._self, this._then); - final ConnectGuestUserResponse _self; - final $Res Function(ConnectGuestUserResponse) _then; + final CreateGuestUserResponse _self; + final $Res Function(CreateGuestUserResponse) _then; - /// Create a copy of ConnectGuestUserResponse + /// Create a copy of CreateGuestUserResponse /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override - $Res call({ - Object? duration = null, - Object? accessToken = null, - Object? user = null, - }) { + $Res call({Object? duration = null, Object? accessToken = null, Object? user = null}) { return _then( - ConnectGuestUserResponse( + CreateGuestUserResponse( duration: null == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable diff --git a/packages/stream_chat/lib/src/repository/general_repository.dart b/packages/stream_chat/lib/src/repository/general_repository.dart index 16dca35133..82ab18eceb 100644 --- a/packages/stream_chat/lib/src/repository/general_repository.dart +++ b/packages/stream_chat/lib/src/repository/general_repository.dart @@ -1,7 +1,7 @@ import 'package:stream_core/stream_core.dart' show PatternMatching, Result; import '../../open_api/api.dart' as api; -import '../core/models/response/connect_guest_user_response.dart'; +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'; @@ -27,7 +27,7 @@ class GeneralRepository { /// Creates a guest from [user] and returns it with the token that authenticates it. /// /// The returned guest has an id of its own and the `guest` role, rather than those of [user]. - Future> createGuest(User user) async { + Future> createGuest(User user) async { final result = await _api.createGuest( createGuestRequest: api.CreateGuestRequest(user: user.toRequest()), ); diff --git a/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart index a2f3e37612..5a480ebb34 100644 --- a/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/user_mapper.dart @@ -1,7 +1,7 @@ import '../../../open_api/api.dart' as api; import '../../core/models/own_user.dart'; import '../../core/models/privacy_settings.dart'; -import '../../core/models/response/connect_guest_user_response.dart'; +import '../../core/models/response/create_guest_user_response.dart'; import '../../core/models/user.dart'; import '../../core/util/extension.dart'; @@ -82,10 +82,10 @@ extension PrivacySettingsRequestMapper on PrivacySettings { ); } -/// Maps a generated [api.CreateGuestResponse] to a [ConnectGuestUserResponse]. +/// Maps a generated [api.CreateGuestResponse] to a [CreateGuestUserResponse]. extension CreateGuestResponseMapper on api.CreateGuestResponse { - /// Converts this response into a [ConnectGuestUserResponse]. - ConnectGuestUserResponse toModel() => ConnectGuestUserResponse( + /// Converts this response into a [CreateGuestUserResponse]. + CreateGuestUserResponse toModel() => CreateGuestUserResponse( duration: duration, accessToken: accessToken, user: user.toModel(), From 7b61488c633221148a4d0bf8413a1857194652f8 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 5 Oct 2026 14:00:28 +0200 Subject: [PATCH 11/32] feat(llc, persistence): map the generated channel and member responses Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 2 +- openapi-migration/11-channels-and-members.md | 38 +- openapi-migration/README.md | 1 + openapi-migration/tool/generate_plan.py | 80 ++++- packages/stream_chat/CHANGELOG.md | 1 + .../lib/src/core/models/channel_model.dart | 29 +- .../lib/src/core/models/member.dart | 63 +++- .../src/repository/mapper/channel_mapper.dart | 166 +++++++++ .../stream_chat/test/fixtures/channel.json | 13 +- .../stream_chat/test/fixtures/member.json | 7 +- .../test/src/core/models/channel_test.dart | 64 ++++ .../test/src/core/models/member_test.dart | 41 +++ .../mapper/channel_mapper_test.dart | 329 ++++++++++++++++++ packages/stream_chat_persistence/CHANGELOG.md | 1 + .../lib/src/db/drift_chat_database.dart | 2 +- .../lib/src/db/drift_chat_database.g.dart | 220 ++++++++---- .../lib/src/entity/members.dart | 5 + .../lib/src/mapper/member_mapper.dart | 2 + .../test/src/dao/member_dao_test.dart | 2 + .../test/src/mapper/member_mapper_test.dart | 4 + 20 files changed, 977 insertions(+), 93 deletions(-) create mode 100644 packages/stream_chat/lib/src/repository/mapper/channel_mapper.dart create mode 100644 packages/stream_chat/test/src/repository/mapper/channel_mapper_test.dart diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index 022dbf1998..357a82a115 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -411,7 +411,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 `1035` to `1102`, and the upgrade strategy drops and recreates every +on v11. The Drift schema version moves from `1036` to `1104`, 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 diff --git a/openapi-migration/11-channels-and-members.md b/openapi-migration/11-channels-and-members.md index ba18c48dd2..75e422037e 100644 --- a/openapi-migration/11-channels-and-members.md +++ b/openapi-migration/11-channels-and-members.md @@ -73,15 +73,49 @@ ## Decisions to make - `ChannelState`, `ChannelModel` and `Member` are public, persisted, and rebuilt from WebSocket events. Keep ours and map. -- **Whether `ChannelModel` promotes its `extraData`-backed flags to real fields.** `disabled`, `hidden`, `muted`, `blocked` and `truncatedAt` all arrive as root fields on `ChannelResponse` but are pushed into `extraData` and read back through getters, which the constructor comment calls "for backwards compatibility". Promoting them is a break worth making in v11 if it is made at all, and it belongs with this group's model shape rather than with whichever phase happens to add the next flag. Raised on [#2958](https://github.com/GetStream/stream-chat-flutter/pull/2958). +- **Whether `ChannelModel` and `Member` promote their `extraData`-backed getters to real fields.** `ChannelModel.disabled`, `hidden`, `muted`, `blocked`, `truncatedAt`, `truncatedBy`, `autoTranslationEnabled` and `autoTranslationLanguage`, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, `futureChannelBanExpires` and `deletedAt`, all arrive as root fields but live in `extraData` and are read back through getters; the constructors write them there. Promoting them is a break: their keys leave `extraData`, a key set in `extraData` no longer sets the field, and a `custom` filter or sort field naming one reads null locally. When promoting: (1) decide each field's `merge` rule. Today `ChannelModel.merge` keeps the flags and `truncatedAt` when a payload leaves them out and drops the other three, as on v11; `Member` has no `merge`. The server always sends `disabled`, omits `hidden` and `blocked` from every event, clears `truncated_by` only through a server-side update (and may leave it out where it does not load the user), never sends `muted` or `mute_expires_at` on the channel, and omits `auto_translation_*` when false or empty. (2) Make `toJson` leave out `extraData` keys named like a field, or a same-named `extraData` key overwrites the field in the output. (3) Keep the constructor copying `extraData`. (4) Add persistence columns; `truncatedBy` is a user, so it needs the users table or a stored map. Raised on [#2958](https://github.com/GetStream/stream-chat-flutter/pull/2958). +- **Whether `ChannelConfig` exposes the generated-only config fields.** `ChannelConfigWithInfo` carries `allowedFlagReasons`, `automodBehavior`, `automodThresholds`, `blocklist`, `blocklistBehavior`, `blocklists`, `countMessages`, `customEvents`, `grants`, `name`, `partitionSize`, `partitionTtl`, `quotes` and `reminders`, which `ChannelConfig` does not model and v1 drops as well. `blocklists` and `automodThresholds` would need hand-written models. The mapper marks the drop with a TODO. +- **Expose `hide_messages_before` on `ChannelState`.** `clear_history` writes it on the caller's member row, and the server returns it next to `channel` in each channel-state entry (`ChannelStateResponse.hideMessagesBefore`), never on the channel itself. v1 drops it today. - `sync` returns `SyncResponse` — one of the two models that needed the WSEvent generator patch. Verify it decodes before relying on it. +## Decisions taken + +- **The generated channel and member types map onto today's classes.** + `lib/src/repository/mapper/channel_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 + `User`. 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.** The + ones `ChannelModel` and `Member` did not read get a typed getter over `extraData`, the pattern `muted` + and `blocked` already used: `ChannelModel.truncatedBy`, `autoTranslationEnabled` and + `autoTranslationLanguage`, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, + `futureChannelBanExpires` and `deletedAt`. Their keys stay in `extraData`, so nothing breaks and the + offline cache keeps them without new columns. Promoting them to stored fields is the decision above. + Three get no getter: the channel's `hide_messages_before` and `mute_expires_at`, which the server + never fills on the channel (a mute's expiry is `ChannelMute.expires`), and the member's deprecated + `role`. The nested config types drop `ChatPreferences.distinctChannelMessages` and `Command`'s + `createdAt` / `updatedAt`, which ours do not model. +- **The mapper writes the server fields where v1 JSON puts them,** into `extraData` under their wire + keys. Custom data named like one of those fields or a top-level field is dropped, as + `user_mapper.dart` does. A mapped channel or member still differs from a v1 decode in three ways, + all for group 09 or this group to settle: dates in `extraData` are formatted by the client rather + than kept as the server sent them (see the risk below); `truncatedBy`, `createdBy` and each member's + user go through `user_mapper.dart`, which drops the user fields `User` does not model; and + `mute_expires_at` and `hide_messages_before` are left out, where v1 would keep them in `extraData` if + the server sent them on the channel. +- **The mapper has its own test file,** `test/src/repository/mapper/channel_mapper_test.dart`, an + exception to the precedent of testing mappers through the client: no client method returns a + channel through the generated client yet. Move its cases to client tests when `queryChannels` does. + ## Risks - `sync` and `queryMembers` live in `general_api.dart`, not `channel_api.dart` — this group reaches into that file. - `queryChannels` drives the channel list controllers and the offline cache; a shape change here is felt everywhere. - Channel `custom`/`extraData` promotion, same class of problem as messages. -- Landing `ChannelResponse` -> `ChannelModel` is the last thing blocking [08](08-moderation-and-blocklists.md)'s `muteChannel`, which drops `channelMute`, `channelMutes` and `ownUser` because the generated `ChannelMute` carries a `ChannelResponse?` and a `UserResponse?` where ours needs a non-nullable `ChannelModel` and `User`. It needs 09 as well. Decide the null case there too — ours are non-nullable, the generated ones are not, the same question [14](14-banned-users.md) records for `BanResponse.user`. +- **A zero timestamp decodes differently on v2.** v1 sends Go's zero time as `0001-01-01T00:00:00Z`; v2 encodes timestamps as epoch nanoseconds and sends `0`, which decodes as 1970-01-01. A truncated channel shows it: its `last_message_at` is zero time, so the same channel answers year 1 from a v1 query or WebSocket event and 1970 from `/api/v2/chat/channels`. Nothing in the SDK behaves differently today, since both precede `createdAt` (`lastUpdatedAt` falls back to it, and the read fallback and the `max` against new messages agree), but the two sources disagree on the value. Normalize it deliberately when `queryChannels` moves, and check the other timestamps the server can send as zero. +- **Dates the mapper writes into `extraData` are formatted differently from v1.** v1 keeps the server's string (`2020-01-29T03:22:47.63613Z`); the mapper writes `toIso8601String()` (`2020-01-29T03:22:47.636130Z`). The getters return the same `DateTime`, but `Member` compares `extraData`, so the same member from a v1 payload and from the mapper compares unequal. Promoting the fields removes it. +- [08](08-moderation-and-blocklists.md)'s `muteChannel` drops `channelMute`, `channelMutes` and `ownUser` because the generated `ChannelMute` carries a `ChannelResponse?` and a `UserResponse?` where ours needs a non-nullable `ChannelModel` and `User`. `channel_mapper.dart` covers the channel side now; it needs 09 as well. Decide the null case there too — ours are non-nullable, the generated ones are not, the same question [14](14-banned-users.md) records for `BanResponse.user`. ## Definition of done diff --git a/openapi-migration/README.md b/openapi-migration/README.md index 25bf7b8325..52f4e1d1f3 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -144,6 +144,7 @@ includes a named response that carries only `duration` today, such as `HideChann | `userGroupsFromV1Json` | `Message.mentionedGroups` | [10](10-messages.md) | | `DataSerializable` | `UserGroup`, `UserGroupMember` (`fromData`, `toData`) | [10](10-messages.md) | | `user_mapper.dart` (kept, re-pointed) | today's `User`, which still reads and writes JSON | [09](09-users.md) | +| `channel_mapper.dart` (kept, re-pointed) | today's `ChannelModel`, `ChannelConfig` and `Member`, which still read and write JSON | [11](11-channels-and-members.md) | How v1 JSON decodes `User` once it becomes a plain model is decided in [01-foundation](01-foundation.md): until group 09 restructures it, v1 payloads keep decoding through `User.fromJson`. diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 151e2843f3..810169ed97 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -575,13 +575,31 @@ def match(path): decisions=[ '`ChannelState`, `ChannelModel` and `Member` are public, persisted, and rebuilt from WebSocket ' 'events. Keep ours and map.', - '**Whether `ChannelModel` promotes its `extraData`-backed flags to real fields.** `disabled`, ' - '`hidden`, `muted`, `blocked` and `truncatedAt` all arrive as root fields on `ChannelResponse` but ' - 'are pushed into `extraData` and read back through getters, which the constructor comment calls ' - '"for backwards compatibility". Promoting them is a break worth making in v11 if it is made at ' - 'all, and it belongs with this group\'s model shape rather than with whichever phase happens to ' - 'add the next flag. Raised on ' + '**Whether `ChannelModel` and `Member` promote their `extraData`-backed getters to real fields.** ' + '`ChannelModel.disabled`, `hidden`, `muted`, `blocked`, `truncatedAt`, `truncatedBy`, ' + '`autoTranslationEnabled` and `autoTranslationLanguage`, and `Member.notificationsMuted`, ' + '`status`, `banFromFutureChannels`, `futureChannelBanExpires` and `deletedAt`, all arrive as root fields ' + 'but live in `extraData` and are read back through getters; the constructors write them there. ' + 'Promoting them is a break: their keys leave `extraData`, a key set in `extraData` no longer sets the ' + 'field, and a `custom` filter or sort field naming one reads null locally. When promoting: ' + '(1) decide each field\'s `merge` rule. Today `ChannelModel.merge` keeps the flags and `truncatedAt` ' + 'when a payload leaves them out and drops the other three, as on v11; `Member` has no `merge`. The ' + 'server always sends `disabled`, omits `hidden` and `blocked` from every event, clears `truncated_by` ' + 'only through a server-side update (and may leave it out where it does not load the user), never sends ' + '`muted` or `mute_expires_at` on the channel, and omits `auto_translation_*` when false or empty. ' + '(2) Make `toJson` leave out `extraData` keys named like a field, or a same-named `extraData` key ' + 'overwrites the field in the output. (3) Keep the constructor copying `extraData`. (4) Add persistence columns; ' + '`truncatedBy` is a user, so it needs the users table or a stored map. Raised on ' '[#2958](https://github.com/GetStream/stream-chat-flutter/pull/2958).', + '**Whether `ChannelConfig` exposes the generated-only config fields.** `ChannelConfigWithInfo` ' + 'carries `allowedFlagReasons`, `automodBehavior`, `automodThresholds`, `blocklist`, ' + '`blocklistBehavior`, `blocklists`, `countMessages`, `customEvents`, `grants`, `name`, ' + '`partitionSize`, `partitionTtl`, `quotes` and `reminders`, which `ChannelConfig` does not model and ' + 'v1 drops as well. `blocklists` and `automodThresholds` would need hand-written models. The mapper ' + 'marks the drop with a TODO.', + '**Expose `hide_messages_before` on `ChannelState`.** `clear_history` writes it on the caller\'s ' + 'member row, and the server returns it next to `channel` in each channel-state entry ' + '(`ChannelStateResponse.hideMessagesBefore`), never on the channel itself. v1 drops it today.', '`sync` returns `SyncResponse` — one of the two models that needed the WSEvent generator patch. ' 'Verify it decodes before relying on it.', ], @@ -591,13 +609,55 @@ def match(path): '`queryChannels` drives the channel list controllers and the offline cache; a shape change here is ' 'felt everywhere.', 'Channel `custom`/`extraData` promotion, same class of problem as messages.', - 'Landing `ChannelResponse` -> `ChannelModel` is the last thing blocking ' - '[08](08-moderation-and-blocklists.md)\'s `muteChannel`, which drops `channelMute`, `channelMutes` ' - 'and `ownUser` because the generated `ChannelMute` carries a `ChannelResponse?` and a ' - '`UserResponse?` where ours needs a non-nullable `ChannelModel` and `User`. It needs 09 as well. ' + '**A zero timestamp decodes differently on v2.** v1 sends Go\'s zero time as ' + '`0001-01-01T00:00:00Z`; v2 encodes timestamps as epoch nanoseconds and sends `0`, which decodes as ' + '1970-01-01. A truncated channel shows it: its `last_message_at` is zero time, so the same channel ' + 'answers year 1 from a v1 query or WebSocket event and 1970 from `/api/v2/chat/channels`. Nothing ' + 'in the SDK behaves differently today, since both precede `createdAt` (`lastUpdatedAt` falls back ' + 'to it, and the read fallback and the `max` against new messages agree), but the two sources disagree on ' + 'the value. Normalize it deliberately when `queryChannels` moves, and check the other ' + 'timestamps the server can send as zero.', + '**Dates the mapper writes into `extraData` are formatted differently from v1.** v1 keeps the ' + 'server\'s string (`2020-01-29T03:22:47.63613Z`); the mapper writes `toIso8601String()` ' + '(`2020-01-29T03:22:47.636130Z`). The getters return the same `DateTime`, but `Member` compares ' + '`extraData`, so the same member from a v1 payload and from the mapper compares unequal. Promoting ' + 'the fields removes it.', + '[08](08-moderation-and-blocklists.md)\'s `muteChannel` drops `channelMute`, `channelMutes` and ' + '`ownUser` because the generated `ChannelMute` carries a `ChannelResponse?` and a `UserResponse?` ' + 'where ours needs a non-nullable `ChannelModel` and `User`. `channel_mapper.dart` covers the ' + 'channel side now; it needs 09 as well. ' 'Decide the null case there too — ours are non-nullable, the generated ones are not, the same ' 'question [14](14-banned-users.md) records for `BanResponse.user`.', ], + taken=textwrap.dedent("""\ + - **The generated channel and member types map onto today's classes.** + `lib/src/repository/mapper/channel_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 + `User`. 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.** The + ones `ChannelModel` and `Member` did not read get a typed getter over `extraData`, the pattern `muted` + and `blocked` already used: `ChannelModel.truncatedBy`, `autoTranslationEnabled` and + `autoTranslationLanguage`, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, + `futureChannelBanExpires` and `deletedAt`. Their keys stay in `extraData`, so nothing breaks and the + offline cache keeps them without new columns. Promoting them to stored fields is the decision above. + Three get no getter: the channel's `hide_messages_before` and `mute_expires_at`, which the server + never fills on the channel (a mute's expiry is `ChannelMute.expires`), and the member's deprecated + `role`. The nested config types drop `ChatPreferences.distinctChannelMessages` and `Command`'s + `createdAt` / `updatedAt`, which ours do not model. + - **The mapper writes the server fields where v1 JSON puts them,** into `extraData` under their wire + keys. Custom data named like one of those fields or a top-level field is dropped, as + `user_mapper.dart` does. A mapped channel or member still differs from a v1 decode in three ways, + all for group 09 or this group to settle: dates in `extraData` are formatted by the client rather + than kept as the server sent them (see the risk below); `truncatedBy`, `createdBy` and each member's + user go through `user_mapper.dart`, which drops the user fields `User` does not model; and + `mute_expires_at` and `hide_messages_before` are left out, where v1 would keep them in `extraData` if + the server sent them on the channel. + - **The mapper has its own test file,** `test/src/repository/mapper/channel_mapper_test.dart`, an + exception to the precedent of testing mappers through the client: no client method returns a + channel through the generated client yet. Move its cases to client tests when `queryChannels` does. + """), ), dict( num='12', slug='uploads-cdn', title='Uploads (CDN)', diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 13601cd1cc..babdd4e4b8 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -112,6 +112,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 `ChannelModel.truncatedBy`, `autoTranslationEnabled` and `autoTranslationLanguage`, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, `futureChannelBanExpires`, `deletedAt` and `topLevelFields`; the constructors accept the new fields. 🔒 Security diff --git a/packages/stream_chat/lib/src/core/models/channel_model.dart b/packages/stream_chat/lib/src/core/models/channel_model.dart index df9bfc760e..db8f265b1d 100644 --- a/packages/stream_chat/lib/src/core/models/channel_model.dart +++ b/packages/stream_chat/lib/src/core/models/channel_model.dart @@ -11,7 +11,10 @@ part 'channel_model.g.dart'; /// The class that contains the information about a channel @JsonSerializable() class ChannelModel { - /// Constructor used for json serialization + /// Creates a channel model. + /// + /// The [disabled], [hidden], [muted], [blocked], [truncatedAt], [truncatedBy], [autoTranslationEnabled] and + /// [autoTranslationLanguage] arguments, when given, are stored in [extraData], replacing the entry each one reads. ChannelModel({ String? id, String? type, @@ -36,6 +39,9 @@ class ChannelModel { DateTime? truncatedAt, this.messageCount, this.filterTags, + User? truncatedBy, + bool? autoTranslationEnabled, + String? autoTranslationLanguage, }) : assert( (cid != null && cid.contains(':')) || (id != null && type != null), 'provide either a cid or an id and type', @@ -48,8 +54,7 @@ class ChannelModel { updatedAt = updatedAt ?? DateTime.now(), ownCapabilities = ownCapabilities?.map(ChannelCapability.new).toList(), - // For backwards compatibility, set 'disabled', 'hidden', 'muted', - // 'blocked' and 'truncated_at' in [extraData]. + // These fields live in [extraData], where [ChannelModel.fromJson] leaves them, rather than in fields of their own. extraData = { ...extraData, if (disabled != null) 'disabled': disabled, @@ -57,6 +62,9 @@ class ChannelModel { if (muted != null) 'muted': muted, if (blocked != null) 'blocked': blocked, if (truncatedAt != null) 'truncated_at': truncatedAt.toIso8601String(), + if (truncatedBy != null) 'truncated_by': truncatedBy.toJson(), + if (autoTranslationEnabled != null) 'auto_translation_enabled': autoTranslationEnabled, + if (autoTranslationLanguage != null) 'auto_translation_language': autoTranslationLanguage, }; /// Create a new instance from a json @@ -159,6 +167,21 @@ class ChannelModel { return null; } + /// The user that last truncated this channel. + @JsonKey(includeToJson: false, includeFromJson: false) + User? get truncatedBy => switch (extraData['truncated_by']) { + final Map json when json['id'] is String => User.fromJson(json), + _ => null, + }; + + /// Whether messages in this channel are translated automatically. + @JsonKey(includeToJson: false, includeFromJson: false) + bool? get autoTranslationEnabled => extraData['auto_translation_enabled'].safeCast(); + + /// The language messages in this channel are translated into automatically. + @JsonKey(includeToJson: false, includeFromJson: false) + String? get autoTranslationLanguage => extraData['auto_translation_language'].safeCast(); + /// Map of custom channel extraData final Map extraData; diff --git a/packages/stream_chat/lib/src/core/models/member.dart b/packages/stream_chat/lib/src/core/models/member.dart index 5b46017ee4..2eeaff6f32 100644 --- a/packages/stream_chat/lib/src/core/models/member.dart +++ b/packages/stream_chat/lib/src/core/models/member.dart @@ -13,7 +13,10 @@ part 'member.g.dart'; /// in a channel @JsonSerializable() class Member extends Equatable { - /// Constructor used for json serialization + /// Creates a member. + /// + /// The [notificationsMuted], [status], [banFromFutureChannels], [futureChannelBanExpires] and [deletedAt] arguments, + /// when given, are stored in [extraData], replacing the entry each one reads. Member({ this.user, this.inviteAcceptedAt, @@ -30,20 +33,33 @@ class Member extends Equatable { this.pinnedAt, this.archivedAt, this.deletedMessages = const [], - this.extraData = const {}, + Map extraData = const {}, + bool? notificationsMuted, + String? status, + bool? banFromFutureChannels, + DateTime? futureChannelBanExpires, + DateTime? deletedAt, }) : userId = userId ?? user?.id, createdAt = createdAt ?? DateTime.now(), - updatedAt = updatedAt ?? DateTime.now(); + updatedAt = updatedAt ?? DateTime.now(), + // These fields live in [extraData], where [Member.fromJson] leaves them, rather than in fields of their own. + extraData = { + ...extraData, + if (notificationsMuted != null) 'notifications_muted': notificationsMuted, + if (status != null) 'status': status, + if (banFromFutureChannels != null) 'ban_from_future_channels': banFromFutureChannels, + if (futureChannelBanExpires != null) + 'future_channel_ban_expires': futureChannelBanExpires.toUtc().toIso8601String(), + if (deletedAt != null) 'deleted_at': deletedAt.toUtc().toIso8601String(), + }; /// Create a new instance from a json factory Member.fromJson(Map json) => _$MemberFromJson( - Serializer.moveToExtraDataFromRoot(json, _topLevelFields), + Serializer.moveToExtraDataFromRoot(json, topLevelFields), ); - /// Known top level fields. - /// - /// Useful for [Serializer] methods. - static const _topLevelFields = [ + /// The JSON keys [Member.fromJson] reads into named fields; every other key is kept in [extraData]. + static const topLevelFields = [ 'user', 'invite_accepted_at', 'invite_rejected_at', @@ -112,6 +128,35 @@ class Member extends Equatable { /// Map of custom member extraData. final Map extraData; + /// Whether this member muted notifications for the channel. + @JsonKey(includeToJson: false, includeFromJson: false) + bool? get notificationsMuted => extraData['notifications_muted'].safeCast(); + + /// The status of this membership, such as `member`, or `pending` while an invite is outstanding. + @JsonKey(includeToJson: false, includeFromJson: false) + String? get status => extraData['status'].safeCast(); + + /// Whether this member is banned from future channels. + @JsonKey(includeToJson: false, includeFromJson: false) + bool? get banFromFutureChannels => extraData['ban_from_future_channels'].safeCast(); + + /// The date at which this member's ban from future channels expires. + /// + /// Null when that ban has no expiry. + @JsonKey(includeToJson: false, includeFromJson: false) + DateTime? get futureChannelBanExpires => _date('future_channel_ban_expires'); + + /// The date at which the membership was deleted. + /// + /// Null while the member belongs to the channel. + @JsonKey(includeToJson: false, includeFromJson: false) + DateTime? get deletedAt => _date('deleted_at'); + + DateTime? _date(String key) => switch (extraData[key]) { + final String value => DateTime.tryParse(value), + _ => null, + }; + /// Creates a copy of [Member] with specified attributes overridden. Member copyWith({ User? user, @@ -296,7 +341,7 @@ class MemberFilterField extends FilterField { /// **Supported operators:** `$eq` static final notificationsMuted = MemberFilterField( 'notifications_muted', - (it) => it.extraData['notifications_muted'].safeCast(), + (it) => it.notificationsMuted, ); /// Filters members by when they joined the channel. diff --git a/packages/stream_chat/lib/src/repository/mapper/channel_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/channel_mapper.dart new file mode 100644 index 0000000000..ee9c582b14 --- /dev/null +++ b/packages/stream_chat/lib/src/repository/mapper/channel_mapper.dart @@ -0,0 +1,166 @@ +import 'package:stream_core/stream_core.dart' show Standard; + +import '../../../open_api/api.dart' as api; +import '../../core/models/channel_config.dart'; +import '../../core/models/channel_model.dart'; +import '../../core/models/chat_preferences.dart'; +import '../../core/models/command.dart'; +import '../../core/models/member.dart'; +import '../../core/models/push_level.dart'; +import 'user_mapper.dart'; + +// TODO(openapi-migration): re-point these mappers in group 11. + +/// Maps a generated [api.ChannelResponse] to a [ChannelModel]. +extension ChannelResponseMapper on api.ChannelResponse { + // Custom keys named like one of the channel's own fields, including the ones it keeps in its extra data. + static const _shadowedCustomKeys = { + ...ChannelModel.topLevelFields, + 'disabled', + 'hidden', + 'muted', + 'blocked', + 'truncated_at', + 'truncated_by', + 'mute_expires_at', + 'auto_translation_enabled', + 'auto_translation_language', + 'hide_messages_before', + }; + + /// Converts this response into a [ChannelModel]. + /// + /// Custom data named like one of the channel's own fields is left out of [ChannelModel.extraData]. + ChannelModel toModel() => ChannelModel( + id: id, + type: type, + cid: cid, + ownCapabilities: ownCapabilities, + config: config?.toModel(), + createdBy: createdBy?.toModel(), + frozen: frozen, + lastMessageAt: lastMessageAt, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt, + memberCount: memberCount ?? 0, + members: members?.map((member) => member.toModel()).toList(), + extraData: {...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), + team: team, + cooldown: cooldown ?? 0, + disabled: disabled, + hidden: hidden, + muted: muted, + blocked: blocked, + truncatedAt: truncatedAt, + messageCount: messageCount, + filterTags: filterTags, + truncatedBy: truncatedBy?.toModel(), + autoTranslationEnabled: autoTranslationEnabled, + autoTranslationLanguage: autoTranslationLanguage, + ); +} + +/// Maps a generated [api.ChannelConfigWithInfo] to a [ChannelConfig]. +extension ChannelConfigWithInfoMapper on api.ChannelConfigWithInfo { + // TODO(openapi-migration): revisit exposing the config fields ChannelConfig does not model yet: + // allowedFlagReasons, automodBehavior, automodThresholds, blocklist, blocklistBehavior, blocklists, + // countMessages, customEvents, grants, name, partitionSize, partitionTtl, quotes and reminders. + + /// Converts this configuration into a [ChannelConfig]. + ChannelConfig toModel() => ChannelConfig( + automod: automod, + commands: [for (final command in commands) command.toModel()], + connectEvents: connectEvents, + createdAt: createdAt, + updatedAt: updatedAt, + maxMessageLength: maxMessageLength, + messageRetention: messageRetention, + mutes: mutes, + reactions: reactions, + readEvents: readEvents, + replies: replies, + search: search, + polls: polls, + pushLevel: pushLevel?.let(PushLevel.new), + pushNotifications: pushNotifications, + chatPreferences: chatPreferences?.toModel(), + typingEvents: typingEvents, + uploads: uploads, + urlEnrichment: urlEnrichment, + skipLastMsgUpdateForSystemMsgs: skipLastMsgUpdateForSystemMsgs, + userMessageReminders: userMessageReminders, + markMessagesPending: markMessagesPending, + deliveryEvents: deliveryEvents, + sharedLocations: sharedLocations, + ); +} + +/// Maps a generated [api.Command] to a [Command]. +extension CommandMapper on api.Command { + /// Converts this command into a [Command]. + Command toModel() => Command( + name: name, + description: description, + args: args, + set: CommandSet(set), + ); +} + +/// Maps a generated [api.ChatPreferences] to a [ChatPreferences]. +extension ChatPreferencesMapper on api.ChatPreferences { + /// Converts these preferences into [ChatPreferences]. + ChatPreferences toModel() => ChatPreferences( + channelMentions: channelMentions?.let(ChatPreferenceLevel.new), + defaultPreference: defaultPreference?.let(ChatPreferenceLevel.new), + directMentions: directMentions?.let(ChatPreferenceLevel.new), + groupMentions: groupMentions?.let(ChatPreferenceLevel.new), + hereMentions: hereMentions?.let(ChatPreferenceLevel.new), + roleMentions: roleMentions?.let(ChatPreferenceLevel.new), + threadReplies: threadReplies?.let(ChatPreferenceLevel.new), + ); +} + +/// Maps a generated [api.ChannelMemberResponse] to a [Member]. +extension ChannelMemberResponseMapper on api.ChannelMemberResponse { + // Custom keys named like one of the member's own fields, including the ones it keeps in its extra data. + static const _shadowedCustomKeys = { + ...Member.topLevelFields, + 'notifications_muted', + 'status', + 'role', + 'ban_from_future_channels', + 'future_channel_ban_expires', + 'deleted_at', + }; + + /// Converts this response into a [Member]. + /// + /// Custom data named like one of the member's own fields is left out of [Member.extraData]. + Member toModel() => Member( + user: user?.toModel(), + inviteAcceptedAt: inviteAcceptedAt, + inviteRejectedAt: inviteRejectedAt, + invited: invited ?? false, + channelRole: channelRole, + userId: userId, + isModerator: isModerator ?? false, + createdAt: createdAt, + updatedAt: updatedAt, + banned: banned, + banExpires: banExpires, + shadowBanned: shadowBanned, + pinnedAt: pinnedAt, + archivedAt: archivedAt, + deletedMessages: deletedMessages ?? const [], + extraData: { + ...{...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), + if (role case final role?) 'role': role, + }, + notificationsMuted: notificationsMuted, + status: status, + banFromFutureChannels: banFromFutureChannels, + futureChannelBanExpires: futureChannelBanExpires, + deletedAt: deletedAt, + ); +} diff --git a/packages/stream_chat/test/fixtures/channel.json b/packages/stream_chat/test/fixtures/channel.json index 2184cd6e92..123335e4ef 100644 --- a/packages/stream_chat/test/fixtures/channel.json +++ b/packages/stream_chat/test/fixtures/channel.json @@ -2,6 +2,15 @@ "id": "test", "type": "livestream", "cid": "livestream:test", + "truncated_by": { + "id": "truncator", + "name": "Truncator" + }, + "auto_translation_enabled": true, + "auto_translation_language": "fr", "cats": true, - "fruit": ["bananas", "apples"] -} \ No newline at end of file + "fruit": [ + "bananas", + "apples" + ] +} diff --git a/packages/stream_chat/test/fixtures/member.json b/packages/stream_chat/test/fixtures/member.json index 63b7ddd175..3f9834bfab 100644 --- a/packages/stream_chat/test/fixtures/member.json +++ b/packages/stream_chat/test/fixtures/member.json @@ -17,5 +17,10 @@ "msg-2", "msg-3" ], + "notifications_muted": true, + "status": "member", + "ban_from_future_channels": true, + "future_channel_ban_expires": "2020-02-28T22:17:30.95443Z", + "deleted_at": "2020-01-29T22:17:30.95443Z", "some_custom_field": "with_custom_data" -} \ No newline at end of file +} diff --git a/packages/stream_chat/test/src/core/models/channel_test.dart b/packages/stream_chat/test/src/core/models/channel_test.dart index 63bcd7f6ca..6a6b457ada 100644 --- a/packages/stream_chat/test/src/core/models/channel_test.dart +++ b/packages/stream_chat/test/src/core/models/channel_test.dart @@ -1,6 +1,7 @@ // ignore_for_file: avoid_redundant_argument_values import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/user.dart'; import 'package:test/test.dart'; import '../../utils.dart'; @@ -15,6 +16,10 @@ void main() { expect(channel.extraData['cats'], equals(true)); expect(channel.extraData['fruit'], equals(['bananas', 'apples'])); expect(channel.cooldown, equals(0)); + expect(channel.truncatedBy?.id, 'truncator'); + expect(channel.truncatedBy?.name, 'Truncator'); + expect(channel.autoTranslationEnabled, isTrue); + expect(channel.autoTranslationLanguage, 'fr'); }); test('should serialize to json correctly', () { @@ -204,6 +209,65 @@ void main() { expect(newChannel.truncatedAt, dateThree); }); + test('ChannelModel keeps truncatedBy and the auto-translation settings in extraData', () { + final channel = ChannelModel( + cid: 'test:cid', + truncatedBy: User(id: 'truncator'), + autoTranslationEnabled: true, + autoTranslationLanguage: 'fr', + ); + + expect(channel.extraData, { + 'truncated_by': User(id: 'truncator').toJson(), + 'auto_translation_enabled': true, + 'auto_translation_language': 'fr', + }); + }); + + test( + 'ChannelModel.copyWith drops truncatedBy and the auto-translation settings the replacement extraData leaves out', + () { + final channel = ChannelModel( + cid: 'test:cid', + truncatedBy: User(id: 'truncator'), + autoTranslationEnabled: true, + autoTranslationLanguage: 'fr', + ); + + final newChannel = channel.copyWith(extraData: const {'color': 'red'}); + + expect(newChannel.truncatedBy, isNull); + expect(newChannel.autoTranslationEnabled, isNull); + expect(newChannel.autoTranslationLanguage, isNull); + }, + ); + + test('ChannelModel.merge drops truncatedBy and the auto-translation settings the other channel leaves out', () { + final channel = ChannelModel( + cid: 'test:cid', + truncatedBy: User(id: 'truncator'), + autoTranslationEnabled: true, + autoTranslationLanguage: 'fr', + ); + + final merged = channel.merge(ChannelModel(cid: 'test:cid')); + + expect(merged.truncatedBy, isNull); + expect(merged.autoTranslationEnabled, isNull); + expect(merged.autoTranslationLanguage, isNull); + }); + + test('.truncatedBy should return null if the extraData user has no id', () { + final channel = ChannelModel( + cid: 'test:channel', + extraData: const { + 'truncated_by': {'name': 'No id'}, + }, + ); + + expect(channel.truncatedBy, isNull); + }); + test('.name should fetch from extraData if available', () { final channel = ChannelModel( cid: 'test:channel', diff --git a/packages/stream_chat/test/src/core/models/member_test.dart b/packages/stream_chat/test/src/core/models/member_test.dart index e8095d3a27..7f40031533 100644 --- a/packages/stream_chat/test/src/core/models/member_test.dart +++ b/packages/stream_chat/test/src/core/models/member_test.dart @@ -14,6 +14,47 @@ void main() { expect(member.updatedAt, DateTime.parse('2020-01-28T22:17:30.95443Z')); expect(member.deletedMessages, ['msg-1', 'msg-2', 'msg-3']); expect(member.extraData['some_custom_field'], 'with_custom_data'); + expect(member.notificationsMuted, isTrue); + expect(member.status, 'member'); + expect(member.banFromFutureChannels, isTrue); + expect(member.futureChannelBanExpires, DateTime.parse('2020-02-28T22:17:30.95443Z')); + expect(member.deletedAt, DateTime.parse('2020-01-29T22:17:30.95443Z')); + }); + + test( + 'Member keeps notificationsMuted, status, banFromFutureChannels, futureChannelBanExpires and deletedAt in extraData', + () { + final member = Member( + userId: 'user', + notificationsMuted: true, + status: 'member', + banFromFutureChannels: true, + futureChannelBanExpires: DateTime.utc(2021), + deletedAt: DateTime.utc(2020), + extraData: const {'color': 'red'}, + ); + + expect(member.extraData, { + 'color': 'red', + 'notifications_muted': true, + 'status': 'member', + 'ban_from_future_channels': true, + 'future_channel_ban_expires': DateTime.utc(2021).toIso8601String(), + 'deleted_at': DateTime.utc(2020).toIso8601String(), + }); + }, + ); + + test('Member.deletedAt returns null for an extraData value that is not a date', () { + final member = Member(userId: 'user', extraData: const {'deleted_at': 'yesterday'}); + + expect(member.deletedAt, isNull); + }); + + test('Member.futureChannelBanExpires returns null for an empty extraData value', () { + final member = Member(userId: 'user', extraData: const {'future_channel_ban_expires': ''}); + + expect(member.futureChannelBanExpires, isNull); }); group('MemberSortField', () { diff --git a/packages/stream_chat/test/src/repository/mapper/channel_mapper_test.dart b/packages/stream_chat/test/src/repository/mapper/channel_mapper_test.dart new file mode 100644 index 0000000000..4c02040c93 --- /dev/null +++ b/packages/stream_chat/test/src/repository/mapper/channel_mapper_test.dart @@ -0,0 +1,329 @@ +import 'package:stream_chat/open_api/api.dart' as api; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/chat_preferences.dart'; +import 'package:stream_chat/src/core/models/command.dart'; +import 'package:stream_chat/src/core/models/push_level.dart'; +import 'package:stream_chat/src/repository/mapper/channel_mapper.dart'; +import 'package:stream_chat/src/repository/mapper/user_mapper.dart'; +import 'package:test/test.dart'; + +void main() { + test('ChannelResponse.toModel maps every field the channel models', () { + final channel = _channelResponse().toModel(); + + expect(channel.id, 'general'); + expect(channel.type, 'messaging'); + expect(channel.cid, 'messaging:general'); + expect(channel.ownCapabilities, [ChannelCapability.sendMessage, ChannelCapability.readEvents]); + expect(channel.createdBy?.id, 'creator'); + expect(channel.frozen, isTrue); + expect(channel.lastMessageAt, DateTime.utc(2026, 3, 4)); + expect(channel.createdAt, DateTime.utc(2026)); + expect(channel.updatedAt, DateTime.utc(2026, 2)); + expect(channel.deletedAt, DateTime.utc(2026, 3)); + expect(channel.memberCount, 2); + expect(channel.members?.single.userId, 'member'); + expect(channel.team, 'blue'); + expect(channel.cooldown, 30); + expect(channel.disabled, isFalse); + expect(channel.hidden, isTrue); + expect(channel.muted, isTrue); + expect(channel.blocked, isFalse); + expect(channel.truncatedAt, DateTime.utc(2026, 3, 2)); + expect(channel.truncatedBy?.id, 'truncator'); + expect(channel.autoTranslationEnabled, isTrue); + expect(channel.autoTranslationLanguage, 'fr'); + expect(channel.messageCount, 42); + expect(channel.filterTags, ['support']); + }); + + test('ChannelResponse.toModel takes the name and image from the custom data', () { + final channel = _channelResponse().toModel(); + + expect(channel.name, 'General'); + expect(channel.extraData['image'], 'https://example.com/general.png'); + }); + + test('ChannelResponse.toModel keeps the custom data and the server fields in extraData', () { + final channel = _channelResponse().toModel(); + + expect(channel.extraData, { + 'name': 'General', + 'image': 'https://example.com/general.png', + 'topic': 'anything', + 'disabled': false, + 'hidden': true, + 'muted': true, + 'blocked': false, + 'truncated_at': DateTime.utc(2026, 3, 2).toIso8601String(), + 'truncated_by': _userResponse('truncator').toModel().toJson(), + 'auto_translation_enabled': true, + 'auto_translation_language': 'fr', + }); + }); + + test('ChannelResponse.toModel drops custom data named like a channel field', () { + final channel = _channelResponse(custom: const {'hidden': 'shadowed', 'cid': 'shadowed'}).toModel(); + + expect(channel.hidden, isTrue); + expect(channel.extraData, isNot(contains('cid'))); + }); + + test('ChannelResponse.toModel falls back to the defaults for the fields the response leaves out', () { + final channel = api.ChannelResponse( + cid: 'messaging:general', + id: 'general', + type: 'messaging', + createdAt: DateTime.utc(2026), + updatedAt: DateTime.utc(2026, 2), + custom: const {}, + disabled: false, + frozen: false, + ).toModel(); + + expect(channel.memberCount, 0); + expect(channel.cooldown, 0); + expect(channel.config.commands, isEmpty); + expect(channel.members, isNull); + }); + + test('ChannelConfigWithInfo.toModel maps every field the config models', () { + final config = _channelResponse().config!.toModel(); + + expect(config.automod, 'simple'); + expect(config.commands.single.name, 'giphy'); + expect(config.commands.single.description, 'Post a random gif'); + expect(config.commands.single.args, '[text]'); + expect(config.commands.single.set, CommandSet.fun); + expect(config.connectEvents, isTrue); + expect(config.createdAt, DateTime.utc(2025)); + expect(config.updatedAt, DateTime.utc(2025, 2)); + expect(config.maxMessageLength, 5000); + expect(config.messageRetention, 'infinite'); + expect(config.mutes, isTrue); + expect(config.reactions, isTrue); + expect(config.readEvents, isTrue); + expect(config.replies, isTrue); + expect(config.search, isTrue); + expect(config.polls, isTrue); + expect(config.pushLevel, PushLevel.directMentions); + expect(config.pushNotifications, isFalse); + expect( + config.chatPreferences, + const ChatPreferences( + channelMentions: ChatPreferenceLevel.all, + defaultPreference: ChatPreferenceLevel.none, + directMentions: ChatPreferenceLevel.all, + groupMentions: ChatPreferenceLevel.none, + hereMentions: ChatPreferenceLevel.all, + roleMentions: ChatPreferenceLevel.none, + threadReplies: ChatPreferenceLevel.all, + ), + ); + expect(config.typingEvents, isTrue); + expect(config.uploads, isTrue); + expect(config.urlEnrichment, isTrue); + expect(config.skipLastMsgUpdateForSystemMsgs, isTrue); + expect(config.userMessageReminders, isTrue); + expect(config.markMessagesPending, isTrue); + expect(config.deliveryEvents, isTrue); + expect(config.sharedLocations, isTrue); + }); + + test('ChannelMemberResponse.toModel maps every field the member models', () { + final member = _memberResponse().toModel(); + + expect(member.user?.id, 'member'); + expect(member.userId, 'member'); + expect(member.inviteAcceptedAt, DateTime.utc(2026, 1, 2)); + expect(member.inviteRejectedAt, DateTime.utc(2026, 1, 3)); + expect(member.invited, isTrue); + expect(member.channelRole, 'channel_moderator'); + expect(member.isModerator, isTrue); + expect(member.createdAt, DateTime.utc(2026)); + expect(member.updatedAt, DateTime.utc(2026, 2)); + expect(member.banned, isTrue); + expect(member.banExpires, DateTime.utc(2026, 6)); + expect(member.shadowBanned, isTrue); + expect(member.pinnedAt, DateTime.utc(2026, 1, 4)); + expect(member.archivedAt, DateTime.utc(2026, 1, 5)); + expect(member.deletedMessages, ['message-1']); + expect(member.notificationsMuted, isTrue); + expect(member.status, 'member'); + expect(member.banFromFutureChannels, isTrue); + expect(member.futureChannelBanExpires, DateTime.utc(2026, 7)); + expect(member.deletedAt, DateTime.utc(2026, 8)); + }); + + test('ChannelMemberResponse.toModel keeps the custom data and the server fields in extraData', () { + final member = _memberResponse().toModel(); + + expect(member.extraData, { + 'nickname': 'Mo', + 'role': 'user', + 'notifications_muted': true, + 'status': 'member', + 'ban_from_future_channels': true, + 'future_channel_ban_expires': DateTime.utc(2026, 7).toIso8601String(), + 'deleted_at': DateTime.utc(2026, 8).toIso8601String(), + }); + }); + + test('ChannelMemberResponse.toModel drops custom data named like a member field', () { + final member = _memberResponse(custom: const {'status': 'shadowed', 'user_id': 'shadowed'}).toModel(); + + expect(member.status, 'member'); + expect(member.extraData, isNot(contains('user_id'))); + }); + + test('ChannelMemberResponse.toModel falls back to the defaults for the fields the response leaves out', () { + final member = api.ChannelMemberResponse( + banned: false, + channelRole: 'channel_member', + createdAt: DateTime.utc(2026), + updatedAt: DateTime.utc(2026, 2), + custom: const {}, + notificationsMuted: false, + shadowBanned: false, + ).toModel(); + + expect(member.invited, isFalse); + expect(member.isModerator, isFalse); + expect(member.deletedMessages, isEmpty); + }); +} + +// A response with every field set. +api.ChannelResponse _channelResponse({ + Map custom = const { + 'name': 'General', + 'image': 'https://example.com/general.png', + 'topic': 'anything', + }, +}) => api.ChannelResponse( + autoTranslationEnabled: true, + autoTranslationLanguage: 'fr', + blocked: false, + cid: 'messaging:general', + config: api.ChannelConfigWithInfo( + allowedFlagReasons: const ['spam'], + automod: api.ChannelConfigWithInfoAutomod.simple, + automodBehavior: api.ChannelConfigWithInfoAutomodBehavior.flag, + blocklist: 'profanity', + blocklistBehavior: api.ChannelConfigWithInfoBlocklistBehavior.block, + chatPreferences: const api.ChatPreferences( + channelMentions: 'all', + defaultPreference: 'none', + directMentions: 'all', + distinctChannelMessages: 'all', + groupMentions: 'none', + hereMentions: 'all', + roleMentions: 'none', + threadReplies: 'all', + ), + commands: [ + api.Command( + args: '[text]', + createdAt: DateTime.utc(2025), + description: 'Post a random gif', + name: 'giphy', + set: 'fun_set', + updatedAt: DateTime.utc(2025, 2), + ), + ], + connectEvents: true, + countMessages: true, + createdAt: DateTime.utc(2025), + customEvents: true, + deliveryEvents: true, + grants: const { + 'user': ['read-channel'], + }, + markMessagesPending: true, + maxMessageLength: 5000, + messageRetention: 'infinite', + mutes: true, + name: 'messaging', + partitionSize: 10, + partitionTtl: '24h', + polls: true, + pushLevel: api.ChannelConfigWithInfoPushLevel.directMentions, + pushNotifications: false, + quotes: true, + reactions: true, + readEvents: true, + reminders: true, + replies: true, + search: true, + sharedLocations: true, + skipLastMsgUpdateForSystemMsgs: true, + typingEvents: true, + updatedAt: DateTime.utc(2025, 2), + uploads: true, + urlEnrichment: true, + userMessageReminders: true, + ), + cooldown: 30, + createdAt: DateTime.utc(2026), + createdBy: _userResponse('creator'), + custom: custom, + deletedAt: DateTime.utc(2026, 3), + disabled: false, + filterTags: const ['support'], + frozen: true, + hidden: true, + hideMessagesBefore: DateTime.utc(2026, 3, 1), + id: 'general', + lastMessageAt: DateTime.utc(2026, 3, 4), + memberCount: 2, + members: [_memberResponse()], + messageCount: 42, + muteExpiresAt: DateTime.utc(2026, 5), + muted: true, + ownCapabilities: const [api.ChannelOwnCapability.sendMessage, api.ChannelOwnCapability.readEvents], + team: 'blue', + truncatedAt: DateTime.utc(2026, 3, 2), + truncatedBy: _userResponse('truncator'), + type: 'messaging', + updatedAt: DateTime.utc(2026, 2), +); + +// A response with every field set. +api.ChannelMemberResponse _memberResponse({Map custom = const {'nickname': 'Mo'}}) => + api.ChannelMemberResponse( + archivedAt: DateTime.utc(2026, 1, 5), + banExpires: DateTime.utc(2026, 6), + banFromFutureChannels: true, + banned: true, + channelRole: 'channel_moderator', + createdAt: DateTime.utc(2026), + custom: custom, + deletedAt: DateTime.utc(2026, 8), + deletedMessages: const ['message-1'], + futureChannelBanExpires: DateTime.utc(2026, 7), + inviteAcceptedAt: DateTime.utc(2026, 1, 2), + inviteRejectedAt: DateTime.utc(2026, 1, 3), + invited: true, + isModerator: true, + notificationsMuted: true, + pinnedAt: DateTime.utc(2026, 1, 4), + role: 'user', + shadowBanned: true, + status: 'member', + updatedAt: DateTime.utc(2026, 2), + user: _userResponse('member'), + userId: 'member', + ); + +api.UserResponse _userResponse(String id) => api.UserResponse( + banned: false, + blockedUserIds: const [], + createdAt: DateTime.utc(2025), + custom: const {}, + id: id, + language: 'en', + online: false, + role: 'user', + teams: const [], + updatedAt: DateTime.utc(2025), +); diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md index 8d2e4e1b2e..e2a6ae74ef 100644 --- a/packages/stream_chat_persistence/CHANGELOG.md +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -14,6 +14,7 @@ 🐞 Fixed - A cached channel query given no sort came back in whatever order the lookup returned; it now applies `ChannelSort.defaultSort`, the ordering the API would have. +- A member's `banExpires` is now kept in the local cache. ## Upcoming 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 b8241b86af..43ef0b607c 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 + 3; + int get schemaVersion => 1100 + 4; // 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 f3449ea86d..04add63f91 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 @@ -9647,6 +9647,17 @@ class $MembersTable extends Members with TableInfo<$MembersTable, MemberEntity> ), defaultValue: const Constant(false), ); + static const VerificationMeta _banExpiresMeta = const VerificationMeta( + 'banExpires', + ); + @override + late final GeneratedColumn banExpires = GeneratedColumn( + 'ban_expires', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); static const VerificationMeta _shadowBannedMeta = const VerificationMeta( 'shadowBanned', ); @@ -9750,6 +9761,7 @@ class $MembersTable extends Members with TableInfo<$MembersTable, MemberEntity> inviteRejectedAt, invited, banned, + banExpires, shadowBanned, pinnedAt, archivedAt, @@ -9826,6 +9838,12 @@ class $MembersTable extends Members with TableInfo<$MembersTable, MemberEntity> banned.isAcceptableOrUnknown(data['banned']!, _bannedMeta), ); } + if (data.containsKey('ban_expires')) { + context.handle( + _banExpiresMeta, + banExpires.isAcceptableOrUnknown(data['ban_expires']!, _banExpiresMeta), + ); + } if (data.containsKey('shadow_banned')) { context.handle( _shadowBannedMeta, @@ -9905,6 +9923,10 @@ class $MembersTable extends Members with TableInfo<$MembersTable, MemberEntity> DriftSqlType.bool, data['${effectivePrefix}banned'], )!, + banExpires: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}ban_expires'], + ), shadowBanned: attachedDatabase.typeMapping.read( DriftSqlType.bool, data['${effectivePrefix}shadow_banned'], @@ -9978,6 +10000,9 @@ class MemberEntity extends DataClass implements Insertable { /// True if the member is banned from the channel final bool banned; + /// The date at which the ban will expire. + final DateTime? banExpires; + /// True if the member is shadow banned from the channel final bool shadowBanned; @@ -10012,6 +10037,7 @@ class MemberEntity extends DataClass implements Insertable { this.inviteRejectedAt, required this.invited, required this.banned, + this.banExpires, required this.shadowBanned, this.pinnedAt, this.archivedAt, @@ -10037,6 +10063,9 @@ class MemberEntity extends DataClass implements Insertable { } map['invited'] = Variable(invited); map['banned'] = Variable(banned); + if (!nullToAbsent || banExpires != null) { + map['ban_expires'] = Variable(banExpires); + } map['shadow_banned'] = Variable(shadowBanned); if (!nullToAbsent || pinnedAt != null) { map['pinned_at'] = Variable(pinnedAt); @@ -10077,6 +10106,7 @@ class MemberEntity extends DataClass implements Insertable { ), invited: serializer.fromJson(json['invited']), banned: serializer.fromJson(json['banned']), + banExpires: serializer.fromJson(json['banExpires']), shadowBanned: serializer.fromJson(json['shadowBanned']), pinnedAt: serializer.fromJson(json['pinnedAt']), archivedAt: serializer.fromJson(json['archivedAt']), @@ -10100,6 +10130,7 @@ class MemberEntity extends DataClass implements Insertable { 'inviteRejectedAt': serializer.toJson(inviteRejectedAt), 'invited': serializer.toJson(invited), 'banned': serializer.toJson(banned), + 'banExpires': serializer.toJson(banExpires), 'shadowBanned': serializer.toJson(shadowBanned), 'pinnedAt': serializer.toJson(pinnedAt), 'archivedAt': serializer.toJson(archivedAt), @@ -10119,6 +10150,7 @@ class MemberEntity extends DataClass implements Insertable { Value inviteRejectedAt = const Value.absent(), bool? invited, bool? banned, + Value banExpires = const Value.absent(), bool? shadowBanned, Value pinnedAt = const Value.absent(), Value archivedAt = const Value.absent(), @@ -10135,6 +10167,7 @@ class MemberEntity extends DataClass implements Insertable { inviteRejectedAt: inviteRejectedAt.present ? inviteRejectedAt.value : this.inviteRejectedAt, invited: invited ?? this.invited, banned: banned ?? this.banned, + banExpires: banExpires.present ? banExpires.value : this.banExpires, shadowBanned: shadowBanned ?? this.shadowBanned, pinnedAt: pinnedAt.present ? pinnedAt.value : this.pinnedAt, archivedAt: archivedAt.present ? archivedAt.value : this.archivedAt, @@ -10153,6 +10186,7 @@ class MemberEntity extends DataClass implements Insertable { inviteRejectedAt: data.inviteRejectedAt.present ? data.inviteRejectedAt.value : this.inviteRejectedAt, invited: data.invited.present ? data.invited.value : this.invited, banned: data.banned.present ? data.banned.value : this.banned, + banExpires: data.banExpires.present ? data.banExpires.value : this.banExpires, shadowBanned: data.shadowBanned.present ? data.shadowBanned.value : this.shadowBanned, pinnedAt: data.pinnedAt.present ? data.pinnedAt.value : this.pinnedAt, archivedAt: data.archivedAt.present ? data.archivedAt.value : this.archivedAt, @@ -10174,6 +10208,7 @@ class MemberEntity extends DataClass implements Insertable { ..write('inviteRejectedAt: $inviteRejectedAt, ') ..write('invited: $invited, ') ..write('banned: $banned, ') + ..write('banExpires: $banExpires, ') ..write('shadowBanned: $shadowBanned, ') ..write('pinnedAt: $pinnedAt, ') ..write('archivedAt: $archivedAt, ') @@ -10195,6 +10230,7 @@ class MemberEntity extends DataClass implements Insertable { inviteRejectedAt, invited, banned, + banExpires, shadowBanned, pinnedAt, archivedAt, @@ -10215,6 +10251,7 @@ class MemberEntity extends DataClass implements Insertable { other.inviteRejectedAt == this.inviteRejectedAt && other.invited == this.invited && other.banned == this.banned && + other.banExpires == this.banExpires && other.shadowBanned == this.shadowBanned && other.pinnedAt == this.pinnedAt && other.archivedAt == this.archivedAt && @@ -10233,6 +10270,7 @@ class MembersCompanion extends UpdateCompanion { final Value inviteRejectedAt; final Value invited; final Value banned; + final Value banExpires; final Value shadowBanned; final Value pinnedAt; final Value archivedAt; @@ -10250,6 +10288,7 @@ class MembersCompanion extends UpdateCompanion { this.inviteRejectedAt = const Value.absent(), this.invited = const Value.absent(), this.banned = const Value.absent(), + this.banExpires = const Value.absent(), this.shadowBanned = const Value.absent(), this.pinnedAt = const Value.absent(), this.archivedAt = const Value.absent(), @@ -10268,6 +10307,7 @@ class MembersCompanion extends UpdateCompanion { this.inviteRejectedAt = const Value.absent(), this.invited = const Value.absent(), this.banned = const Value.absent(), + this.banExpires = const Value.absent(), this.shadowBanned = const Value.absent(), this.pinnedAt = const Value.absent(), this.archivedAt = const Value.absent(), @@ -10288,6 +10328,7 @@ class MembersCompanion extends UpdateCompanion { Expression? inviteRejectedAt, Expression? invited, Expression? banned, + Expression? banExpires, Expression? shadowBanned, Expression? pinnedAt, Expression? archivedAt, @@ -10306,6 +10347,7 @@ class MembersCompanion extends UpdateCompanion { if (inviteRejectedAt != null) 'invite_rejected_at': inviteRejectedAt, if (invited != null) 'invited': invited, if (banned != null) 'banned': banned, + if (banExpires != null) 'ban_expires': banExpires, if (shadowBanned != null) 'shadow_banned': shadowBanned, if (pinnedAt != null) 'pinned_at': pinnedAt, if (archivedAt != null) 'archived_at': archivedAt, @@ -10326,6 +10368,7 @@ class MembersCompanion extends UpdateCompanion { Value? inviteRejectedAt, Value? invited, Value? banned, + Value? banExpires, Value? shadowBanned, Value? pinnedAt, Value? archivedAt, @@ -10344,6 +10387,7 @@ class MembersCompanion extends UpdateCompanion { inviteRejectedAt: inviteRejectedAt ?? this.inviteRejectedAt, invited: invited ?? this.invited, banned: banned ?? this.banned, + banExpires: banExpires ?? this.banExpires, shadowBanned: shadowBanned ?? this.shadowBanned, pinnedAt: pinnedAt ?? this.pinnedAt, archivedAt: archivedAt ?? this.archivedAt, @@ -10380,6 +10424,9 @@ class MembersCompanion extends UpdateCompanion { if (banned.present) { map['banned'] = Variable(banned.value); } + if (banExpires.present) { + map['ban_expires'] = Variable(banExpires.value); + } if (shadowBanned.present) { map['shadow_banned'] = Variable(shadowBanned.value); } @@ -10424,6 +10471,7 @@ class MembersCompanion extends UpdateCompanion { ..write('inviteRejectedAt: $inviteRejectedAt, ') ..write('invited: $invited, ') ..write('banned: $banned, ') + ..write('banExpires: $banExpires, ') ..write('shadowBanned: $shadowBanned, ') ..write('pinnedAt: $pinnedAt, ') ..write('archivedAt: $archivedAt, ') @@ -12078,7 +12126,7 @@ final class $$ChannelsTableReferences extends BaseReferences<_$DriftChatDatabase static MultiTypedResultKey<$MessagesTable, List> _messagesRefsTable(_$DriftChatDatabase db) => MultiTypedResultKey.fromTable( db.messages, - aliasName: $_aliasNameGenerator(db.channels.cid, db.messages.channelCid), + aliasName: 'channels__cid__messages__channel_cid', ); $$MessagesTableProcessedTableManager get messagesRefs { @@ -12097,10 +12145,7 @@ final class $$ChannelsTableReferences extends BaseReferences<_$DriftChatDatabase _$DriftChatDatabase db, ) => MultiTypedResultKey.fromTable( db.draftMessages, - aliasName: $_aliasNameGenerator( - db.channels.cid, - db.draftMessages.channelCid, - ), + aliasName: 'channels__cid__draft_messages__channel_cid', ); $$DraftMessagesTableProcessedTableManager get draftMessagesRefs { @@ -12118,7 +12163,7 @@ final class $$ChannelsTableReferences extends BaseReferences<_$DriftChatDatabase static MultiTypedResultKey<$LocationsTable, List> _locationsRefsTable(_$DriftChatDatabase db) => MultiTypedResultKey.fromTable( db.locations, - aliasName: $_aliasNameGenerator(db.channels.cid, db.locations.channelCid), + aliasName: 'channels__cid__locations__channel_cid', ); $$LocationsTableProcessedTableManager get locationsRefs { @@ -12136,7 +12181,7 @@ final class $$ChannelsTableReferences extends BaseReferences<_$DriftChatDatabase static MultiTypedResultKey<$MembersTable, List> _membersRefsTable(_$DriftChatDatabase db) => MultiTypedResultKey.fromTable( db.members, - aliasName: $_aliasNameGenerator(db.channels.cid, db.members.channelCid), + aliasName: 'channels__cid__members__channel_cid', ); $$MembersTableProcessedTableManager get membersRefs { @@ -12155,7 +12200,7 @@ final class $$ChannelsTableReferences extends BaseReferences<_$DriftChatDatabase _$DriftChatDatabase db, ) => MultiTypedResultKey.fromTable( db.reads, - aliasName: $_aliasNameGenerator(db.channels.cid, db.reads.channelCid), + aliasName: 'channels__cid__reads__channel_cid', ); $$ReadsTableProcessedTableManager get readsRefs { @@ -12759,7 +12804,7 @@ class $$ChannelsTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$ChannelsTable, ChannelEntity>(table), $$ChannelsTableReferences(db, table, e), ), ) @@ -12968,9 +13013,8 @@ typedef $$MessagesTableUpdateCompanionBuilder = final class $$MessagesTableReferences extends BaseReferences<_$DriftChatDatabase, $MessagesTable, MessageEntity> { $$MessagesTableReferences(super.$_db, super.$_table, super.$_typedResult); - static $ChannelsTable _channelCidTable(_$DriftChatDatabase db) => db.channels.createAlias( - $_aliasNameGenerator(db.messages.channelCid, db.channels.cid), - ); + static $ChannelsTable _channelCidTable(_$DriftChatDatabase db) => + db.channels.createAlias('messages__channel_cid__channels__cid'); $$ChannelsTableProcessedTableManager get channelCid { final $_column = $_itemColumn('channel_cid')!; @@ -12990,10 +13034,7 @@ final class $$MessagesTableReferences extends BaseReferences<_$DriftChatDatabase _$DriftChatDatabase db, ) => MultiTypedResultKey.fromTable( db.draftMessages, - aliasName: $_aliasNameGenerator( - db.messages.id, - db.draftMessages.parentId, - ), + aliasName: 'messages__id__draft_messages__parent_id', ); $$DraftMessagesTableProcessedTableManager get draftMessagesRefs { @@ -13011,7 +13052,7 @@ final class $$MessagesTableReferences extends BaseReferences<_$DriftChatDatabase static MultiTypedResultKey<$LocationsTable, List> _locationsRefsTable(_$DriftChatDatabase db) => MultiTypedResultKey.fromTable( db.locations, - aliasName: $_aliasNameGenerator(db.messages.id, db.locations.messageId), + aliasName: 'messages__id__locations__message_id', ); $$LocationsTableProcessedTableManager get locationsRefs { @@ -13029,7 +13070,7 @@ final class $$MessagesTableReferences extends BaseReferences<_$DriftChatDatabase static MultiTypedResultKey<$ReactionsTable, List> _reactionsRefsTable(_$DriftChatDatabase db) => MultiTypedResultKey.fromTable( db.reactions, - aliasName: $_aliasNameGenerator(db.messages.id, db.reactions.messageId), + aliasName: 'messages__id__reactions__message_id', ); $$ReactionsTableProcessedTableManager get reactionsRefs { @@ -13981,7 +14022,7 @@ class $$MessagesTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$MessagesTable, MessageEntity>(table), $$MessagesTableReferences(db, table, e), ), ) @@ -14145,9 +14186,8 @@ final class $$DraftMessagesTableReferences super.$_typedResult, ); - static $MessagesTable _parentIdTable(_$DriftChatDatabase db) => db.messages.createAlias( - $_aliasNameGenerator(db.draftMessages.parentId, db.messages.id), - ); + static $MessagesTable _parentIdTable(_$DriftChatDatabase db) => + db.messages.createAlias('draft_messages__parent_id__messages__id'); $$MessagesTableProcessedTableManager? get parentId { final $_column = $_itemColumn('parent_id'); @@ -14163,9 +14203,8 @@ final class $$DraftMessagesTableReferences ); } - static $ChannelsTable _channelCidTable(_$DriftChatDatabase db) => db.channels.createAlias( - $_aliasNameGenerator(db.draftMessages.channelCid, db.channels.cid), - ); + static $ChannelsTable _channelCidTable(_$DriftChatDatabase db) => + db.channels.createAlias('draft_messages__channel_cid__channels__cid'); $$ChannelsTableProcessedTableManager get channelCid { final $_column = $_itemColumn('channel_cid')!; @@ -14598,7 +14637,7 @@ class $$DraftMessagesTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$DraftMessagesTable, DraftMessageEntity>(table), $$DraftMessagesTableReferences(db, table, e), ), ) @@ -14699,9 +14738,8 @@ typedef $$LocationsTableUpdateCompanionBuilder = final class $$LocationsTableReferences extends BaseReferences<_$DriftChatDatabase, $LocationsTable, LocationEntity> { $$LocationsTableReferences(super.$_db, super.$_table, super.$_typedResult); - static $ChannelsTable _channelCidTable(_$DriftChatDatabase db) => db.channels.createAlias( - $_aliasNameGenerator(db.locations.channelCid, db.channels.cid), - ); + static $ChannelsTable _channelCidTable(_$DriftChatDatabase db) => + db.channels.createAlias('locations__channel_cid__channels__cid'); $$ChannelsTableProcessedTableManager? get channelCid { final $_column = $_itemColumn('channel_cid'); @@ -14717,9 +14755,8 @@ final class $$LocationsTableReferences extends BaseReferences<_$DriftChatDatabas ); } - static $MessagesTable _messageIdTable(_$DriftChatDatabase db) => db.messages.createAlias( - $_aliasNameGenerator(db.locations.messageId, db.messages.id), - ); + static $MessagesTable _messageIdTable(_$DriftChatDatabase db) => + db.messages.createAlias('locations__message_id__messages__id'); $$MessagesTableProcessedTableManager? get messageId { final $_column = $_itemColumn('message_id'); @@ -15056,7 +15093,7 @@ class $$LocationsTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$LocationsTable, LocationEntity>(table), $$LocationsTableReferences(db, table, e), ), ) @@ -15221,10 +15258,7 @@ final class $$PinnedMessagesTableReferences static MultiTypedResultKey<$PinnedMessageReactionsTable, List> _pinnedMessageReactionsRefsTable(_$DriftChatDatabase db) => MultiTypedResultKey.fromTable( db.pinnedMessageReactions, - aliasName: $_aliasNameGenerator( - db.pinnedMessages.id, - db.pinnedMessageReactions.messageId, - ), + aliasName: 'pinned_messages__id__pinned_message_reactions__message_id', ); $$PinnedMessageReactionsTableProcessedTableManager get pinnedMessageReactionsRefs { @@ -16028,7 +16062,7 @@ class $$PinnedMessagesTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$PinnedMessagesTable, PinnedMessageEntity>(table), $$PinnedMessagesTableReferences(db, table, e), ), ) @@ -16130,7 +16164,7 @@ final class $$PollsTableReferences extends BaseReferences<_$DriftChatDatabase, $ static MultiTypedResultKey<$PollVotesTable, List> _pollVotesRefsTable(_$DriftChatDatabase db) => MultiTypedResultKey.fromTable( db.pollVotes, - aliasName: $_aliasNameGenerator(db.polls.id, db.pollVotes.pollId), + aliasName: 'polls__id__poll_votes__poll_id', ); $$PollVotesTableProcessedTableManager get pollVotesRefs { @@ -16598,7 +16632,10 @@ class $$PollsTableTableManager ), withReferenceMapper: (p0) => p0 .map( - (e) => (e.readTable(table), $$PollsTableReferences(db, table, e)), + (e) => ( + e.readTable<$PollsTable, PollEntity>(table), + $$PollsTableReferences(db, table, e), + ), ) .toList(), prefetchHooksCallback: ({pollVotesRefs = false}) { @@ -16667,8 +16704,7 @@ typedef $$PollVotesTableUpdateCompanionBuilder = final class $$PollVotesTableReferences extends BaseReferences<_$DriftChatDatabase, $PollVotesTable, PollVoteEntity> { $$PollVotesTableReferences(super.$_db, super.$_table, super.$_typedResult); - static $PollsTable _pollIdTable(_$DriftChatDatabase db) => - db.polls.createAlias($_aliasNameGenerator(db.pollVotes.pollId, db.polls.id)); + static $PollsTable _pollIdTable(_$DriftChatDatabase db) => db.polls.createAlias('poll_votes__poll_id__polls__id'); $$PollsTableProcessedTableManager? get pollId { final $_column = $_itemColumn('poll_id'); @@ -16939,7 +16975,7 @@ class $$PollVotesTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$PollVotesTable, PollVoteEntity>(table), $$PollVotesTableReferences(db, table, e), ), ) @@ -17033,12 +17069,8 @@ final class $$PinnedMessageReactionsTableReferences super.$_typedResult, ); - static $PinnedMessagesTable _messageIdTable(_$DriftChatDatabase db) => db.pinnedMessages.createAlias( - $_aliasNameGenerator( - db.pinnedMessageReactions.messageId, - db.pinnedMessages.id, - ), - ); + static $PinnedMessagesTable _messageIdTable(_$DriftChatDatabase db) => + db.pinnedMessages.createAlias('pinned_message_reactions__message_id__pinned_messages__id'); $$PinnedMessagesTableProcessedTableManager? get messageId { final $_column = $_itemColumn('message_id'); @@ -17320,7 +17352,7 @@ class $$PinnedMessageReactionsTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$PinnedMessageReactionsTable, PinnedMessageReactionEntity>(table), $$PinnedMessageReactionsTableReferences(db, table, e), ), ) @@ -17409,9 +17441,8 @@ typedef $$ReactionsTableUpdateCompanionBuilder = final class $$ReactionsTableReferences extends BaseReferences<_$DriftChatDatabase, $ReactionsTable, ReactionEntity> { $$ReactionsTableReferences(super.$_db, super.$_table, super.$_typedResult); - static $MessagesTable _messageIdTable(_$DriftChatDatabase db) => db.messages.createAlias( - $_aliasNameGenerator(db.reactions.messageId, db.messages.id), - ); + static $MessagesTable _messageIdTable(_$DriftChatDatabase db) => + db.messages.createAlias('reactions__message_id__messages__id'); $$MessagesTableProcessedTableManager? get messageId { final $_column = $_itemColumn('message_id'); @@ -17677,7 +17708,7 @@ class $$ReactionsTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$ReactionsTable, ReactionEntity>(table), $$ReactionsTableReferences(db, table, e), ), ) @@ -18019,7 +18050,18 @@ class $$UsersTableTableManager extraData: extraData, rowid: rowid, ), - withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable<$UsersTable, UserEntity>(table), + BaseReferences<_$DriftChatDatabase, $UsersTable, UserEntity>( + db, + table, + e, + ), + ), + ) + .toList(), prefetchHooksCallback: null, ), ); @@ -18051,6 +18093,7 @@ typedef $$MembersTableCreateCompanionBuilder = Value inviteRejectedAt, Value invited, Value banned, + Value banExpires, Value shadowBanned, Value pinnedAt, Value archivedAt, @@ -18070,6 +18113,7 @@ typedef $$MembersTableUpdateCompanionBuilder = Value inviteRejectedAt, Value invited, Value banned, + Value banExpires, Value shadowBanned, Value pinnedAt, Value archivedAt, @@ -18084,9 +18128,8 @@ typedef $$MembersTableUpdateCompanionBuilder = final class $$MembersTableReferences extends BaseReferences<_$DriftChatDatabase, $MembersTable, MemberEntity> { $$MembersTableReferences(super.$_db, super.$_table, super.$_typedResult); - static $ChannelsTable _channelCidTable(_$DriftChatDatabase db) => db.channels.createAlias( - $_aliasNameGenerator(db.members.channelCid, db.channels.cid), - ); + static $ChannelsTable _channelCidTable(_$DriftChatDatabase db) => + db.channels.createAlias('members__channel_cid__channels__cid'); $$ChannelsTableProcessedTableManager get channelCid { final $_column = $_itemColumn('channel_cid')!; @@ -18141,6 +18184,11 @@ class $$MembersTableFilterComposer extends Composer<_$DriftChatDatabase, $Member builder: (column) => ColumnFilters(column), ); + ColumnFilters get banExpires => $composableBuilder( + column: $table.banExpires, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get shadowBanned => $composableBuilder( column: $table.shadowBanned, builder: (column) => ColumnFilters(column), @@ -18243,6 +18291,11 @@ class $$MembersTableOrderingComposer extends Composer<_$DriftChatDatabase, $Memb builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get banExpires => $composableBuilder( + column: $table.banExpires, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get shadowBanned => $composableBuilder( column: $table.shadowBanned, builder: (column) => ColumnOrderings(column), @@ -18335,6 +18388,11 @@ class $$MembersTableAnnotationComposer extends Composer<_$DriftChatDatabase, $Me GeneratedColumn get banned => $composableBuilder(column: $table.banned, builder: (column) => column); + GeneratedColumn get banExpires => $composableBuilder( + column: $table.banExpires, + builder: (column) => column, + ); + GeneratedColumn get shadowBanned => $composableBuilder( column: $table.shadowBanned, builder: (column) => column, @@ -18419,6 +18477,7 @@ class $$MembersTableTableManager Value inviteRejectedAt = const Value.absent(), Value invited = const Value.absent(), Value banned = const Value.absent(), + Value banExpires = const Value.absent(), Value shadowBanned = const Value.absent(), Value pinnedAt = const Value.absent(), Value archivedAt = const Value.absent(), @@ -18436,6 +18495,7 @@ class $$MembersTableTableManager inviteRejectedAt: inviteRejectedAt, invited: invited, banned: banned, + banExpires: banExpires, shadowBanned: shadowBanned, pinnedAt: pinnedAt, archivedAt: archivedAt, @@ -18455,6 +18515,7 @@ class $$MembersTableTableManager Value inviteRejectedAt = const Value.absent(), Value invited = const Value.absent(), Value banned = const Value.absent(), + Value banExpires = const Value.absent(), Value shadowBanned = const Value.absent(), Value pinnedAt = const Value.absent(), Value archivedAt = const Value.absent(), @@ -18472,6 +18533,7 @@ class $$MembersTableTableManager inviteRejectedAt: inviteRejectedAt, invited: invited, banned: banned, + banExpires: banExpires, shadowBanned: shadowBanned, pinnedAt: pinnedAt, archivedAt: archivedAt, @@ -18485,7 +18547,7 @@ class $$MembersTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$MembersTable, MemberEntity>(table), $$MembersTableReferences(db, table, e), ), ) @@ -18573,7 +18635,7 @@ final class $$ReadsTableReferences extends BaseReferences<_$DriftChatDatabase, $ $$ReadsTableReferences(super.$_db, super.$_table, super.$_typedResult); static $ChannelsTable _channelCidTable(_$DriftChatDatabase db) => - db.channels.createAlias($_aliasNameGenerator(db.reads.channelCid, db.channels.cid)); + db.channels.createAlias('reads__channel_cid__channels__cid'); $$ChannelsTableProcessedTableManager get channelCid { final $_column = $_itemColumn('channel_cid')!; @@ -18832,7 +18894,10 @@ class $$ReadsTableTableManager ), withReferenceMapper: (p0) => p0 .map( - (e) => (e.readTable(table), $$ReadsTableReferences(db, table, e)), + (e) => ( + e.readTable<$ReadsTable, ReadEntity>(table), + $$ReadsTableReferences(db, table, e), + ), ) .toList(), prefetchHooksCallback: ({channelCid = false}) { @@ -19006,7 +19071,14 @@ class $$ChannelQueriesTableTableManager channelCid: channelCid, rowid: rowid, ), - withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable<$ChannelQueriesTable, ChannelQueryEntity>(table), + BaseReferences<_$DriftChatDatabase, $ChannelQueriesTable, ChannelQueryEntity>(db, table, e), + ), + ) + .toList(), prefetchHooksCallback: null, ), ); @@ -19172,7 +19244,18 @@ class $$ChannelQueriesMetadataTableTableManager sort: sort, rowid: rowid, ), - withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable<$ChannelQueriesMetadataTable, ChannelQueryMetadataEntity>(table), + BaseReferences<_$DriftChatDatabase, $ChannelQueriesMetadataTable, ChannelQueryMetadataEntity>( + db, + table, + e, + ), + ), + ) + .toList(), prefetchHooksCallback: null, ), ); @@ -19404,7 +19487,16 @@ class $$ConnectionEventsTableTableManager lastEventAt: lastEventAt, lastSyncAt: lastSyncAt, ), - withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable<$ConnectionEventsTable, ConnectionEventEntity>( + table, + ), + BaseReferences<_$DriftChatDatabase, $ConnectionEventsTable, ConnectionEventEntity>(db, table, e), + ), + ) + .toList(), prefetchHooksCallback: null, ), ); diff --git a/packages/stream_chat_persistence/lib/src/entity/members.dart b/packages/stream_chat_persistence/lib/src/entity/members.dart index 920185a387..72079a7e0a 100644 --- a/packages/stream_chat_persistence/lib/src/entity/members.dart +++ b/packages/stream_chat_persistence/lib/src/entity/members.dart @@ -28,6 +28,11 @@ class Members extends Table { /// True if the member is banned from the channel BoolColumn get banned => boolean().withDefault(const Constant(false))(); + /// The date at which the ban will expire. + /// + /// Null when the ban has no expiry. + DateTimeColumn get banExpires => dateTime().nullable()(); + /// True if the member is shadow banned from the channel BoolColumn get shadowBanned => boolean().withDefault(const Constant(false))(); diff --git a/packages/stream_chat_persistence/lib/src/mapper/member_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/member_mapper.dart index 322c29ecb3..26e65bf224 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/member_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/member_mapper.dart @@ -8,6 +8,7 @@ extension MemberEntityX on MemberEntity { user: user, userId: userId, banned: banned, + banExpires: banExpires, shadowBanned: shadowBanned, updatedAt: updatedAt, createdAt: createdAt, @@ -29,6 +30,7 @@ extension MemberX on Member { MemberEntity toEntity({required String cid}) => MemberEntity( userId: user!.id, banned: banned, + banExpires: banExpires, shadowBanned: shadowBanned, channelCid: cid, createdAt: createdAt, diff --git a/packages/stream_chat_persistence/test/src/dao/member_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/member_dao_test.dart index f6353f52d3..46c2718d01 100644 --- a/packages/stream_chat_persistence/test/src/dao/member_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/member_dao_test.dart @@ -25,6 +25,7 @@ void main() { (index) => Member( user: users[index], banned: math.Random().nextBool(), + banExpires: DateTime.now(), shadowBanned: math.Random().nextBool(), createdAt: DateTime.now(), pinnedAt: DateTime.now(), @@ -61,6 +62,7 @@ void main() { final fetchedMember = fetchedMembers[i]; expect(fetchedMember.user!.id, member.user!.id); expect(fetchedMember.banned, member.banned); + expect(fetchedMember.banExpires, isSameDateAs(member.banExpires)); expect(fetchedMember.shadowBanned, member.shadowBanned); expect(fetchedMember.createdAt, isSameDateAs(member.createdAt)); expect(fetchedMember.pinnedAt, isSameDateAs(member.pinnedAt)); diff --git a/packages/stream_chat_persistence/test/src/mapper/member_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/member_mapper_test.dart index a577a6d078..4a0695ba3e 100644 --- a/packages/stream_chat_persistence/test/src/mapper/member_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/member_mapper_test.dart @@ -26,6 +26,7 @@ void main() { isModerator: math.Random().nextBool(), deletedMessages: ['msg1', 'msg2', 'msg3'], extraData: {'test_extra_data': 'testData'}, + banExpires: DateTime.now(), ); final member = entity.toMember(user: user); expect(member, isA()); @@ -43,6 +44,7 @@ void main() { expect(member.isModerator, entity.isModerator); expect(member.deletedMessages, entity.deletedMessages); expect(member.extraData, entity.extraData); + expect(member.banExpires, isSameDateAs(entity.banExpires)); }); test('toEntity show map member into MemberEntity', () { @@ -63,6 +65,7 @@ void main() { isModerator: math.Random().nextBool(), deletedMessages: const ['msg1', 'msg2', 'msg3'], extraData: const {'test_extra_data': 'testData'}, + banExpires: DateTime.now(), ); final entity = member.toEntity(cid: cid); expect(entity, isA()); @@ -81,5 +84,6 @@ void main() { expect(entity.isModerator, member.isModerator); expect(entity.deletedMessages, member.deletedMessages); expect(entity.extraData, member.extraData); + expect(entity.banExpires, isSameDateAs(member.banExpires)); }); } From 2c1ef9580dedb7cf218364d41451404bc950bfcd Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 5 Oct 2026 16:10:07 +0200 Subject: [PATCH 12/32] refactor(llc)!: route the partial channel and member updates through the generated client Co-Authored-By: Claude Opus 5.5 --- .claude/skills/openapi-migration/SKILL.md | 13 +- migrations/v11-migration.md | 80 ++++ openapi-migration/11-channels-and-members.md | 19 +- openapi-migration/15-partial-updates.md | 84 ++++ openapi-migration/README.md | 18 +- openapi-migration/tool/generate_plan.py | 97 ++++- packages/stream_chat/CHANGELOG.md | 8 + .../lib/src/client/channel/channel.dart | 108 ++--- .../stream_chat/lib/src/client/client.dart | 85 ++-- .../lib/src/core/api/channel_api.dart | 66 --- .../lib/src/core/api/requests.dart | 28 -- .../lib/src/core/api/requests.g.dart | 7 - .../lib/src/core/api/responses.dart | 25 -- .../lib/src/core/api/responses.g.dart | 15 - .../update_channel_partial_response.dart | 29 ++ ...date_channel_partial_response.freezed.dart | 97 +++++ .../update_member_partial_response.dart | 23 ++ ...pdate_member_partial_response.freezed.dart | 82 ++++ .../src/repository/channels_repository.dart | 53 +++ .../src/repository/mapper/channel_mapper.dart | 21 + packages/stream_chat/lib/stream_chat.dart | 2 + .../test/src/client/channel/channel_test.dart | 87 ++-- .../test/src/client/client_test.dart | 244 ----------- .../client_update_channel_partial_test.dart | 381 ++++++++++++++++++ .../client_update_member_partial_test.dart | 215 ++++++++++ .../test/src/core/api/channel_api_test.dart | 270 ------------- packages/stream_chat/test/src/fakes.dart | 84 +++- .../mapper/channel_mapper_test.dart | 329 --------------- sample_app/lib/widgets/edit_group_sheet.dart | 7 +- 29 files changed, 1400 insertions(+), 1177 deletions(-) create mode 100644 openapi-migration/15-partial-updates.md create mode 100644 packages/stream_chat/lib/src/core/models/response/update_channel_partial_response.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/update_channel_partial_response.freezed.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/update_member_partial_response.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/update_member_partial_response.freezed.dart create mode 100644 packages/stream_chat/lib/src/repository/channels_repository.dart create mode 100644 packages/stream_chat/test/src/client/client_update_channel_partial_test.dart create mode 100644 packages/stream_chat/test/src/client/client_update_member_partial_test.dart delete mode 100644 packages/stream_chat/test/src/repository/mapper/channel_mapper_test.dart diff --git a/.claude/skills/openapi-migration/SKILL.md b/.claude/skills/openapi-migration/SKILL.md index df25e3d946..f84b3ddd28 100644 --- a/.claude/skills/openapi-migration/SKILL.md +++ b/.claude/skills/openapi-migration/SKILL.md @@ -187,10 +187,13 @@ a public signature. **Per type, decide:** -- **Which v10 type it maps to, and under what name.** The v10 name is the default, even where the generated one - differs (`OGAttachmentResponse` for `GetOGResponse`, `AppSettings` for `AppResponseFields`). It is not a - requirement: the user may ask for another name, and you may propose one that fits clearly better. Renaming a - v10 type is a break, so propose it with the reason and wait for approval. +- **Which v10 type it maps to, and under what name.** The spec's name is the default, unless it is awkward or + describes something else, and sibling calls share one scheme: `UpdateChannelPartialResponse` and + `UpdateMemberPartialResponse` replaced v10's `PartialUpdateChannelResponse` and `PartialUpdateMemberResponse`, + and `updateMemberPartial` replaced `partialMemberUpdate`. v10's name stays where the generated one does not fit + (`OGAttachmentResponse` for `GetOGResponse`, `AppSettings` for `AppResponseFields`), and names an earlier group + settled stand. Renaming a v10 type or method is a break: list it with the reason, wait for approval, and call it + out in the PR description. - **Which fields it exposes.** v10's fields, with v10's defaults. A field only the generated type has (`AppResponseFields.id`, the twelve extra fields on `GetOGResponse`) stays out; exposing it later is additive. A field the spec marks optional is nullable by default, even if v10 typed it non-null; keeping it non-null needs a @@ -249,7 +252,7 @@ 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: `PartialUpdateUserRequest`, and also - `PaginationParams`, `ThreadOptions` and `MemberUpdatePayload`. Every other model stays in `models/` + `PaginationParams` and `ThreadOptions`. Every other model stays in `models/` (`UserGroup`). No `request/` type exists yet: the first arrives when a group migrates one out of `lib/src/core/api/requests.dart`. - **Models and envelopes** are `@freezed` classes with a const constructor and `@override final` fields. Copy diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index 357a82a115..d8665c0ba2 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -33,6 +33,7 @@ onto Stream's OpenAPI-generated API client. - [File Upload](#file-upload) - [User Groups](#user-groups) - [Link Previews](#link-previews) + - [Partial Updates](#partial-updates) - [Migration Checklist](#migration-checklist) - [For AI Agents](#for-ai-agents) - [Contributing to this guide](#contributing-to-this-guide) @@ -78,6 +79,7 @@ from the spec, so don't subclass them or depend on their private constructors. | [**App Settings**](#app-settings) | `getAppSettings` returns a `Result` instead of throwing; `GetAppSettingsResponse` is renamed `AppSettingsResponse` | | [**Guest Users**](#guest-users) | `connectGuestUser` is unchanged; `StreamChatApi.guest` and `ConnectGuestUserResponse` are removed | | [**File Upload**](#file-upload) | Uploads return a `Result` and deletes a `Result` instead of throwing; `AttachmentFileUploaderProvider` receives a `Dio` | +| [**Partial Updates**](#partial-updates) | Channel and member partial updates — `updatePartial`, `updateName`, `updateImage`, slow mode, pin and archive — return a `Result` instead of throwing; their responses take the API's names, `UpdateChannelPartialResponse` and `UpdateMemberPartialResponse`, and `partialMemberUpdate` becomes `updateMemberPartial` | | _(filled in per feature as PRs land)_ | | --- @@ -219,6 +221,22 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `SendAttachmentResponse`, `SendFileResponse`, `SendImageResponse`, `UploadImageResponse`, `UploadFileResponse` | `UploadedFile` (`stream_core`) | `removed` | `.file` becomes `.fileUrl`; `.thumbUrl` is unchanged; `duration` is gone | | `AttachmentFileUploaderProvider` = `AttachmentFileUploader Function(StreamHttpClient)` | `AttachmentFileUploader Function(Dio)` | `retyped` | Receives the client's `Dio`; `StreamAttachmentFileUploader(dio)` builds the default | | `StreamChatApi.fileUploader`, `StreamChatApi(attachmentFileUploaderProvider:)` | `StreamChatClient(attachmentFileUploaderProvider:)` | `removed` | The uploader belongs to `StreamChatClient` | +| `PartialUpdateChannelResponse` | `UpdateChannelPartialResponse` | `renamed` | The name of the API's response. Same fields: `duration`, `channel` and `members` | +| `PartialUpdateMemberResponse` | `UpdateMemberPartialResponse` | `renamed` | The name of the API's response. Same fields: `duration` and `channelMember` | +| `StreamChatClient.partialMemberUpdate` | `StreamChatClient.updateMemberPartial` | `renamed` | The name of the API's operation, matching `updateChannelPartial` | +| `StreamChatClient.updateChannelPartial` / `enableSlowdown` / `disableSlowdown` → `Future` | `Future>` | `retyped` | Returns a `Result` instead of throwing | +| `Channel.updatePartial` / `updateName` / `updateImage` / `enableSlowMode` / `disableSlowMode` → `Future` | `Future>` | `retyped` | Returns a `Result` instead of throwing. Calling one before the channel is initialized still throws a `StateError` | +| `StreamChatClient.partialMemberUpdate` / `pinChannel` / `unpinChannel` / `archiveChannel` / `unarchiveChannel` → `Future` | `Future>` | `retyped` | Returns a `Result` instead of throwing | +| `Channel.pin` → `Future`, `unpin` / `archive` / `unarchive` → `Future` | `Future>` | `retyped` | Returns a `Result` instead of throwing, and answers the whole response: read the member off `.channelMember`. Calling one before the channel is initialized still throws a `StateError` | +| `PartialUpdateChannelResponse.channel` (`ChannelModel`) | `UpdateChannelPartialResponse.channel` (`ChannelModel?`) | `retyped` | Nullable, as the API declares it | +| `PartialUpdateChannelResponse.members` (`List?`) | `UpdateChannelPartialResponse.members` (`List`) | `retyped` | Always present, empty when the channel has no members; drop any `?.` or `?? []` | +| `PartialUpdateMemberResponse.channelMember` (`Member`) | `UpdateMemberPartialResponse.channelMember` (`Member?`) | `retyped` | Nullable, as the API declares it | +| `PartialUpdateChannelResponse.duration` / `PartialUpdateMemberResponse.duration` (`String?`) | `String` | `retyped` | Always present; drop any `!` or `?? ''` | +| `PartialUpdateChannelResponse.fromJson` / `PartialUpdateMemberResponse.fromJson` | — | `removed` | The responses are plain classes; construct them directly | +| `PartialUpdateChannelResponse()..channel = …`, `PartialUpdateMemberResponse()..channelMember = …` and their other setters | `UpdateChannelPartialResponse(duration: …, channel: …)` / `UpdateMemberPartialResponse(duration: …, channelMember: …)` | `retyped` | Plain classes with a const constructor and final fields | +| `PartialUpdateChannelResponse` / `PartialUpdateMemberResponse` identity `==` | value `==`, plus `copyWith` | `retyped` | Two instances with the same fields are now equal | +| `StreamChatApi.channel.updateChannelPartial` / `enableSlowdown` / `disableSlowdown` / `updateMemberPartial` | the `StreamChatClient` methods | `removed` | The endpoints moved to the generated client | +| `MemberUpdatePayload(pinned: true).toJson()` / `MemberUpdateType.pinned.name` | `{'pinned': true}` / `'pinned'` | `removed` | Same for `archived`. Or call `pinChannel` / `unpinChannel` / `archiveChannel` / `unarchiveChannel` | | _(more added per feature as PRs land)_ | | | | --- @@ -608,6 +626,68 @@ result.fold( **`duration` is a non-nullable `String`**, where v10 typed it `String?`. +### Partial Updates + +**Channel and member partial updates return a `Result` instead of throwing.** On the channel side that covers +`StreamChatClient.updateChannelPartial`, `enableSlowdown` and `disableSlowdown`, and `Channel.updatePartial`, +`updateName`, `updateImage`, `enableSlowMode` and `disableSlowMode`. On the member side it covers +`StreamChatClient.updateMemberPartial` (v10's `partialMemberUpdate`), `pinChannel`, `unpinChannel`, +`archiveChannel` and `unarchiveChannel`, and `Channel.pin`, `unpin`, `archive` and `unarchive`. The full update, +`updateChannel` and `Channel.update`, still throws. A `try`/`catch` around a partial update still compiles, but +no longer catches a failed call: read the returned `Result` instead. + +```dart +// v10 +try { + final response = await channel.updatePartial(set: {'name': 'Support'}); + print(response.channel.name); +} on StreamChatException catch (e) { + report(e); +} + +// v11 +final result = await channel.updatePartial(set: {'name': 'Support'}); +result.fold( + onSuccess: (response) => print(response.channel?.name), + onFailure: (error, _) => report(error), +); +``` + +**The responses take the API's names.** `PartialUpdateChannelResponse` is now `UpdateChannelPartialResponse`, and +`PartialUpdateMemberResponse` is now `UpdateMemberPartialResponse`. Their fields keep their names. + +**`Channel.pin`, `unpin`, `archive` and `unarchive` answer the whole response**, where v10 returned the member: + +```dart +// v10 +final member = await channel.pin(); + +// v11 +final result = await channel.pin(); +final member = result.getOrThrow().channelMember; +``` + +**`UpdateChannelPartialResponse.channel` and `UpdateMemberPartialResponse.channelMember` are nullable**, where v10 +typed them non-null. `UpdateChannelPartialResponse.members` goes the other way: it is a `List`, empty when +the channel has none, where v10 typed it `List?`. + +**The responses no longer decode JSON.** They are plain classes; build them with their constructors — +`UpdateChannelPartialResponse(duration: '0ms', channel: channel)` where v10 wrote +`PartialUpdateChannelResponse()..channel = channel`. `duration` is a non-nullable `String`. + +**`StreamChatApi.channel.updateChannelPartial`, `enableSlowdown`, `disableSlowdown` and `updateMemberPartial` are +removed.** Call them on `StreamChatClient`. + +**`MemberUpdatePayload` and `MemberUpdateType` are removed.** They only named `pinned` and `archived`; pass the map +directly — `updateMemberPartial(channelId: …, channelType: …, set: {'pinned': true})` — or call `pinChannel`, +`archiveChannel` and their counterparts. + +> **Why:** the endpoints moved onto the generated client, which returns a `Result` for every call; the field types +> follow what the API declares, and the names follow the API's so the channel and member updates share one scheme. +> `Channel.pin` and its siblings answer the whole response because every migrated write returns its envelope, so a +> field the API adds later reaches you without another break. `MemberUpdatePayload` and `MemberUpdateType` named +> only two of the keys a membership accepts, so a plain map replaces them. + ### Moderation **Muting, banning and flagging return a `Result` instead of throwing**, on both `StreamChatClient` diff --git a/openapi-migration/11-channels-and-members.md b/openapi-migration/11-channels-and-members.md index 75e422037e..dbc1f7adb5 100644 --- a/openapi-migration/11-channels-and-members.md +++ b/openapi-migration/11-channels-and-members.md @@ -1,8 +1,8 @@ # 11 — Channels, Members & Sync -**Goal:** The biggest group, and the one every controller above it reads through `ChannelState`. +**Goal:** The biggest group, and the one every controller above it reads through `ChannelState`. [15](15-partial-updates.md) split the partial channel and member updates out of it. -**Size:** 27 hand-written method(s) across 2 file(s) → 24 generated operation(s). +**Size:** 23 hand-written method(s) across 2 file(s) → 22 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. @@ -17,9 +17,6 @@ | `channel_api.dart` | `queryChannels` | `QueryChannelsResponse` | | `channel_api.dart` | `markAllRead` | `EmptyResponse` | | `channel_api.dart` | `updateChannel` | `UpdateChannelResponse` | -| `channel_api.dart` | `updateChannelPartial` | `PartialUpdateChannelResponse` | -| `channel_api.dart` | `enableSlowdown` | `PartialUpdateChannelResponse` | -| `channel_api.dart` | `disableSlowdown` | `PartialUpdateChannelResponse` | | `channel_api.dart` | `acceptChannelInvite` | `AcceptInviteResponse` | | `channel_api.dart` | `rejectChannelInvite` | `RejectInviteResponse` | | `channel_api.dart` | `inviteChannelMembers` | `InviteMembersResponse` | @@ -36,7 +33,6 @@ | `channel_api.dart` | `markThreadRead` | `EmptyResponse` | | `channel_api.dart` | `markThreadUnread` | `EmptyResponse` | | `channel_api.dart` | `stopWatching` | `EmptyResponse` | -| `channel_api.dart` | `updateMemberPartial` | `PartialUpdateMemberResponse` | | `channel_api.dart` | `markChannelsDelivered` | `EmptyResponse` | | `general_api.dart` | `sync` | `SyncResponse` | | `general_api.dart` | `queryMembers` | `QueryMembersResponse` | @@ -67,8 +63,6 @@ | `POST` | `/api/v2/chat/sync` | `sync` | `SyncResponse` | | `POST` | `/api/v2/chat/channels/{type}/{id}/truncate` | `truncateChannel` | `TruncateChannelResponse` | | `POST` | `/api/v2/chat/channels/{type}/{id}` | `updateChannel` | `UpdateChannelResponse` | -| `PATCH` | `/api/v2/chat/channels/{type}/{id}` | `updateChannelPartial` | `UpdateChannelPartialResponse` | -| `PATCH` | `/api/v2/chat/channels/{type}/{id}/member` | `updateMemberPartial` | `UpdateMemberPartialResponse` | ## Decisions to make @@ -84,8 +78,8 @@ `lib/src/repository/mapper/channel_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 - `User`. Groups 06, 07 and 14 will reuse it. This group restructures those classes and re-points the - mapper. + `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.** The ones `ChannelModel` and `Member` did not read get a typed getter over `extraData`, the pattern `muted` and `blocked` already used: `ChannelModel.truncatedBy`, `autoTranslationEnabled` and @@ -104,9 +98,8 @@ user go through `user_mapper.dart`, which drops the user fields `User` does not model; and `mute_expires_at` and `hide_messages_before` are left out, where v1 would keep them in `extraData` if the server sent them on the channel. -- **The mapper has its own test file,** `test/src/repository/mapper/channel_mapper_test.dart`, an - exception to the precedent of testing mappers through the client: no client method returns a - channel through the generated client yet. Move its cases to client tests when `queryChannels` does. +- **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. ## Risks diff --git a/openapi-migration/15-partial-updates.md b/openapi-migration/15-partial-updates.md new file mode 100644 index 0000000000..ae24e7e9c1 --- /dev/null +++ b/openapi-migration/15-partial-updates.md @@ -0,0 +1,84 @@ +# 15 — Partial Updates + +**Goal:** Move the partial channel and member updates ahead of [11](11-channels-and-members.md): they answer a channel and its members through the mappers group 11 needs, without touching `ChannelState`, the channel list or the offline cache. + +**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 | +| --- | --- | --- | --- | +| `PATCH` | `/api/v2/chat/channels/{type}/{id}` | `updateChannelPartial` | `UpdateChannelPartialResponse` | +| `PATCH` | `/api/v2/chat/channels/{type}/{id}/member` | `updateMemberPartial` | `UpdateMemberPartialResponse` | + +## Decisions taken + +- **Split out of [11](11-channels-and-members.md), ahead of it.** The partial updates read nothing + into client state — the `channel.updated` and `member.updated` events do that — so they can move + before the queries and prove `channel_mapper.dart` on real calls. +- **The full update stays in 11, so the pair is split on purpose.** `updateChannel` takes and + answers a `Message`, and the generated `UpdateChannelRequest.message` / `UpdateChannelResponse.message` + need the `MessageRequest` and `MessageResponse` mappers [10](10-messages.md) writes; its `data` is + a typed `ChannelInputRequest` rather than a map, too. Until then `updateChannel` and + `Channel.update` still throw while the partial update returns a `Result`, and + `migrations/v11-migration.md` says so. +- **Moved off `ChannelApi`:** `updateChannelPartial`, `enableSlowdown`, `disableSlowdown` and + `updateMemberPartial`, each now a `StreamChatClient` method over `ChannelsRepository`. +- **`enableSlowdown` and `disableSlowdown` move with it.** Both are `updateChannelPartial` with a + fixed `cooldown`, so they call the repository's one method rather than an operation of their own. +- **The v2 route is the v1 handler.** `lib/chat/routes.go` mounts `UpdateChannelPartial` in the + shared `coreRoutes`; it is neither gated nor in beta. On v2 it also accepts `custom.` paths + in `set` and `unset`, and still takes flat keys, so `{'name': …}` updates the same field. +- **The envelopes and the member method take the spec's names:** `PartialUpdateChannelResponse` becomes + `UpdateChannelPartialResponse`, `PartialUpdateMemberResponse` becomes `UpdateMemberPartialResponse`, + and `StreamChatClient.partialMemberUpdate` becomes `updateMemberPartial`. Hard renames, as + `GetAppSettingsResponse` → `AppSettingsResponse` was: every call site changes for `Result` anyway, + and the channel and member calls now share one naming scheme. + The helpers with no spec counterpart keep their names (`pinChannel`, `archiveChannel`, + `enableSlowdown`, `Channel.pin` and the rest). +- **The envelopes are freezed, in `models/response/`.** `UpdateChannelPartialResponse.channel` and + `UpdateMemberPartialResponse.channelMember` are nullable, as the spec declares them, where v10 typed + them non-null. `UpdateChannelPartialResponse.members` is non-nullable, as the spec requires it, where + v10 typed it nullable: the server answers `[]` for a channel with no members. +- **`Channel.pin`, `unpin`, `archive` and `unarchive` answer the envelope,** where v10 returned the + bare `Member`: a write returns its envelope. +- **The v2 member route is a different handler from v1's, with the same behaviour.** v1 calls + `/member/{user_id}` (`UpdateMemberPartialDeprecated`), v2 calls `/member` (`UpdateMemberPartial`). + For a client-side request both take the user from the token, refuse `channel_role`, and run the same + update; checked live for pin, unpin, archive, unarchive and a custom field. +- **`MemberUpdatePayload` and `MemberUpdateType` are removed.** They only built the `set` and `unset` + values for pin and archive, named two of the fields the server accepts, and carried a public + `toJson` and an enum the model rules retire. The client writes `{'pinned': true}` / `['pinned']`. +- **The request always sends `set` and `unset`, `null` when not given,** where v1 left the key out. + The server reads both the same, and still rejects a call with neither. + +## Risks + +- `disableSlowdown` unsets `cooldown`, which the server rejects on both v1 and v2 as a reserved field. It fails before and after this group; the fix is tracked separately, so the request is unchanged. + +## Definition of done + +- [x] Every method above either routes through `DefaultApi` or is listed here as deliberately left + hand-written, with the reason. +- [x] Public methods return `Future>`; no `getOrThrow()` inside the SDK. +- [x] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [x] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group + persists anything. +- [x] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. +- [x] CHANGELOG entry under `🛑️ Breaking` for each break; PR title `refactor(llc)!:`. +- [x] Decisions recorded in this file, and the status box ticked in `README.md`. +- [x] Public dartdoc follows [`STYLE_GUIDE.md` § Documentation](../STYLE_GUIDE.md#documentation) + and [`EFFECTIVE_DART_DOC.md`](../EFFECTIVE_DART_DOC.md), including on symbols this group + retyped but whose docs it left alone. +- [x] Tests follow [`TESTING.md`](../TESTING.md): no `group` organizing a file by method, each + name states its subject and behaviour. diff --git a/openapi-migration/README.md b/openapi-migration/README.md index 52f4e1d1f3..abfe460f4d 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -22,12 +22,13 @@ generated operations in scope, the decisions that group has to make, its risks, | [08](08-moderation-and-blocklists.md) | Moderation & Blocklists | 0 | 34 | ☑ | | [09](09-users.md) | Users | 9 | 9 | ☐ | | [10](10-messages.md) | Messages & Search | 14 | 12 | ☐ | -| [11](11-channels-and-members.md) | Channels, Members & Sync | 27 | 24 | ☐ | +| [11](11-channels-and-members.md) | Channels, Members & Sync | 23 | 22 | ☐ | | [12](12-uploads-cdn.md) | Uploads (CDN) | 8 | 8 | ☑ | | [13](13-push-preferences.md) | Push Preferences | 1 | 1 | ☐ | | [14](14-banned-users.md) | Banned Users — split out of 08 | 1 | 1 | ☐ | +| [15](15-partial-updates.md) | Partial Updates — split out of 11 | 0 | 2 | ☑ | -**Coverage:** 85 hand-written methods across 11 files, and all 129 generated operations, each claimed by exactly +**Coverage:** 80 hand-written methods across 10 files, and all 129 generated operations, each claimed by exactly one group. Verified mechanically — see [Keeping this plan honest](#keeping-this-plan-honest). @@ -99,10 +100,13 @@ includes a named response that carries only `duration` today, such as `HideChann (see the breaks above). A field the server adds is exposed later, as an additive change. The one exception to "no JSON" is the temporary `@DataSerializable` storage codec in rule 8. - **Names default to v10's,** even where the generated name differs: `OGAttachmentResponse` rather than - `GetOGResponse`, `AppSettings` rather than `AppResponseFields`, `UploadConfig` rather than `FileUploadConfig`. - That is a default, not a requirement. The user may ask for another name, or a migration may propose one that - fits clearly better; renaming a v10 type is a break, so it needs approval and a Symbol Map row. + **Names follow the spec's, unless the spec's name is awkward or describes something else,** and calls the + caller sees as siblings share one scheme: `updateChannelPartial` and `updateMemberPartial` answer + `UpdateChannelPartialResponse` and `UpdateMemberPartialResponse`, where v10 had `PartialUpdateChannelResponse`, + `partialMemberUpdate` and `PartialUpdateMemberResponse`. v10's name stays where the generated one does not fit: + `OGAttachmentResponse` rather than `GetOGResponse`, `AppSettings` rather than `AppResponseFields`. Names an + earlier group settled stand, such as group 04's `getAppSettings` and `UploadConfig`. Renaming a v10 type or + method is a break, so it needs approval, a Symbol Map row, and a line in the PR description. 3. **Responses keep their v10 envelopes,** as `@freezed` classes carrying a non-nullable `duration` and the payload. Every envelope lives in `lib/src/core/models/response/`, and every public type a caller passes in to shape a request — a `*Request` class, or a parameter type such as `PaginationParams` or `ThreadOptions` — in @@ -164,6 +168,8 @@ surfaces before it reaches `Message` and `ChannelState`: - **14** is `queryBannedUsers`, split out of 08 because it is the only moderation call that answers with a model. The `User` mappers it needs landed with group 04. - **10–11** are the core of the SDK, and carry the `custom` / `extraData` promotion problem. +- **15** is the partial channel and member updates, split out of 11 so `channel_mapper.dart` lands on calls that + read nothing into `ChannelState`. It landed first; the full update waits in 11 for group 10's message mappers. - **12** comes late because it needs its own hand-written multipart client and is the highest-traffic path in the SDK. - **09** is last. Every group before it maps users through `user_mapper.dart` onto today's `User`; 09 migrates the diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 810169ed97..cb91b92b2a 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -63,14 +63,29 @@ def read_generated(): ) -def owns(*prefixes, unless=()): - def match(path): - if any(path.startswith(x) for x in unless): +# Every 'VERB /path' named by only_ops or unless_ops, so the report can flag one that matches no operation. +EXACT_OPS = set() + + +def owns(*prefixes, unless=(), unless_ops=()): + """Claims every operation under one of the path prefixes, except those under an `unless` prefix and the + 'VERB /path' operations in `unless_ops`.""" + EXACT_OPS.update(unless_ops) + def match(verb, path): + if any(path.startswith(x) for x in unless) or f'{verb} {path}' in unless_ops: return False return any(path.startswith(x) for x in prefixes) return match +def only_ops(*operations): + """Claims exactly the given 'VERB /path' operations, for a group split out of another one's path.""" + EXACT_OPS.update(operations) + def match(verb, path): + return f'{verb} {path}' in operations + return match + + DONE = textwrap.dedent("""\ - [ ] Every method above either routes through `DefaultApi` or is listed here as deliberately left hand-written, with the reason. @@ -570,8 +585,11 @@ def match(path): match=owns('/api/v2/chat/channels', '/api/v2/chat/members', '/api/v2/chat/sync', unless=('/api/v2/chat/channels/{type}/{id}/draft', '/api/v2/chat/channels/{type}/{id}/file', - '/api/v2/chat/channels/{type}/{id}/image')), - goal='The biggest group, and the one every controller above it reads through `ChannelState`.', + '/api/v2/chat/channels/{type}/{id}/image'), + unless_ops=('PATCH /api/v2/chat/channels/{type}/{id}', + 'PATCH /api/v2/chat/channels/{type}/{id}/member')), + goal='The biggest group, and the one every controller above it reads through `ChannelState`. ' + '[15](15-partial-updates.md) split the partial channel and member updates out of it.', decisions=[ '`ChannelState`, `ChannelModel` and `Member` are public, persisted, and rebuilt from WebSocket ' 'events. Keep ours and map.', @@ -634,8 +652,8 @@ def match(path): `lib/src/repository/mapper/channel_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 - `User`. Groups 06, 07 and 14 will reuse it. This group restructures those classes and re-points the - mapper. + `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.** The ones `ChannelModel` and `Member` did not read get a typed getter over `extraData`, the pattern `muted` and `blocked` already used: `ChannelModel.truncatedBy`, `autoTranslationEnabled` and @@ -654,9 +672,8 @@ def match(path): user go through `user_mapper.dart`, which drops the user fields `User` does not model; and `mute_expires_at` and `hide_messages_before` are left out, where v1 would keep them in `extraData` if the server sent them on the channel. - - **The mapper has its own test file,** `test/src/repository/mapper/channel_mapper_test.dart`, an - exception to the precedent of testing mappers through the client: no client method returns a - channel through the generated client yet. Move its cases to client tests when `queryChannels` does. + - **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. """), ), dict( @@ -759,6 +776,60 @@ def match(path): '`StreamChatApi.moderation` getter.', ], ), + dict( + num='15', slug='partial-updates', title='Partial Updates', + hand=[], + match=only_ops('PATCH /api/v2/chat/channels/{type}/{id}', 'PATCH /api/v2/chat/channels/{type}/{id}/member'), + goal='Move the partial channel and member updates ahead of [11](11-channels-and-members.md): they answer a ' + 'channel and its members through the mappers group 11 needs, without touching `ChannelState`, the ' + 'channel list or the offline cache.', + decisions=[], + taken=textwrap.dedent("""\ + - **Split out of [11](11-channels-and-members.md), ahead of it.** The partial updates read nothing + into client state — the `channel.updated` and `member.updated` events do that — so they can move + before the queries and prove `channel_mapper.dart` on real calls. + - **The full update stays in 11, so the pair is split on purpose.** `updateChannel` takes and + answers a `Message`, and the generated `UpdateChannelRequest.message` / `UpdateChannelResponse.message` + need the `MessageRequest` and `MessageResponse` mappers [10](10-messages.md) writes; its `data` is + a typed `ChannelInputRequest` rather than a map, too. Until then `updateChannel` and + `Channel.update` still throw while the partial update returns a `Result`, and + `migrations/v11-migration.md` says so. + - **Moved off `ChannelApi`:** `updateChannelPartial`, `enableSlowdown`, `disableSlowdown` and + `updateMemberPartial`, each now a `StreamChatClient` method over `ChannelsRepository`. + - **`enableSlowdown` and `disableSlowdown` move with it.** Both are `updateChannelPartial` with a + fixed `cooldown`, so they call the repository's one method rather than an operation of their own. + - **The v2 route is the v1 handler.** `lib/chat/routes.go` mounts `UpdateChannelPartial` in the + shared `coreRoutes`; it is neither gated nor in beta. On v2 it also accepts `custom.` paths + in `set` and `unset`, and still takes flat keys, so `{'name': …}` updates the same field. + - **The envelopes and the member method take the spec's names:** `PartialUpdateChannelResponse` becomes + `UpdateChannelPartialResponse`, `PartialUpdateMemberResponse` becomes `UpdateMemberPartialResponse`, + and `StreamChatClient.partialMemberUpdate` becomes `updateMemberPartial`. Hard renames, as + `GetAppSettingsResponse` → `AppSettingsResponse` was: every call site changes for `Result` anyway, + and the channel and member calls now share one naming scheme. + The helpers with no spec counterpart keep their names (`pinChannel`, `archiveChannel`, + `enableSlowdown`, `Channel.pin` and the rest). + - **The envelopes are freezed, in `models/response/`.** `UpdateChannelPartialResponse.channel` and + `UpdateMemberPartialResponse.channelMember` are nullable, as the spec declares them, where v10 typed + them non-null. `UpdateChannelPartialResponse.members` is non-nullable, as the spec requires it, where + v10 typed it nullable: the server answers `[]` for a channel with no members. + - **`Channel.pin`, `unpin`, `archive` and `unarchive` answer the envelope,** where v10 returned the + bare `Member`: a write returns its envelope. + - **The v2 member route is a different handler from v1's, with the same behaviour.** v1 calls + `/member/{user_id}` (`UpdateMemberPartialDeprecated`), v2 calls `/member` (`UpdateMemberPartial`). + For a client-side request both take the user from the token, refuse `channel_role`, and run the same + update; checked live for pin, unpin, archive, unarchive and a custom field. + - **`MemberUpdatePayload` and `MemberUpdateType` are removed.** They only built the `set` and `unset` + values for pin and archive, named two of the fields the server accepts, and carried a public + `toJson` and an enum the model rules retire. The client writes `{'pinned': true}` / `['pinned']`. + - **The request always sends `set` and `unset`, `null` when not given,** where v1 left the key out. + The server reads both the same, and still rejects a call with neither. + """), + risks=[ + '`disableSlowdown` unsets `cooldown`, which the server rejects on both v1 and v2 as a reserved field. ' + 'It fails before and after this group; the fix is tracked separately, so the request is unchanged.', + ], + done=DONE.replace('- [ ]', '- [x]'), + ), ] @@ -779,7 +850,7 @@ def hand_for(group, hand): def render(g, hand, ops): hand_methods = hand_for(g, hand) - gops = [o for o in ops if g['match'](o[1])] + gops = [o for o in ops if g['match'](o[0], o[1])] L = [ f"# {g['num']} — {g['title']}\n", f"**Goal:** {g['goal']}\n", @@ -856,7 +927,7 @@ def main(): claimed_ops = {} claimed_methods = {} for g in GROUPS: - for o in [o for o in ops if g['match'](o[1])]: + for o in [o for o in ops if g['match'](o[0], o[1])]: claimed_ops.setdefault((o[0], o[1]), []).append(g['num']) for f, n, _ in hand_for(g, hand): claimed_methods.setdefault((f, n), []).append(g['num']) @@ -874,6 +945,8 @@ def main(): problems.append(f'method claimed by {v}: {k[0]}::{k[1]}') for k in sorted(all_methods - set(claimed_methods)): problems.append(f'method unclaimed: {k[0]}::{k[1]}') + for spec in sorted(EXACT_OPS - {f'{o[0]} {o[1]}' for o in ops}): + problems.append(f'exact operation matches nothing in the generated client: {spec}') for line in generated_exports(): problems.append(f'barrel exports the generated client: {line}') for f, line in generated_imports(): diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index babdd4e4b8..944f878cc2 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -83,6 +83,14 @@ - `SendAttachmentResponse`, `SendFileResponse`, `SendImageResponse`, `UploadImageResponse` and `UploadFileResponse` are removed in favor of `UploadedFile`. - `AttachmentFileUploaderProvider` receives the client's `Dio` instead of a `StreamHttpClient`, and `StreamAttachmentFileUploader` is built from one. - `StreamChatApi.fileUploader` and `StreamChatApi`'s `attachmentFileUploaderProvider` are removed; pass `attachmentFileUploaderProvider` to `StreamChatClient` instead. +- `updateChannelPartial`, `enableSlowdown` and `disableSlowdown` on `StreamChatClient`, and `updatePartial`, `updateName`, `updateImage`, `enableSlowMode` and `disableSlowMode` on `Channel`, return a `Result` instead of throwing. +- `PartialUpdateChannelResponse` is renamed `UpdateChannelPartialResponse`, and `PartialUpdateMemberResponse` is renamed `UpdateMemberPartialResponse`. +- `StreamChatClient.partialMemberUpdate` is renamed `updateMemberPartial`. +- `updateMemberPartial`, `pinChannel`, `unpinChannel`, `archiveChannel` and `unarchiveChannel` on `StreamChatClient`, and `pin`, `unpin`, `archive` and `unarchive` on `Channel`, return a `Result` instead of throwing; the `Channel` methods no longer return the bare `Member`. +- `UpdateChannelPartialResponse` and `UpdateMemberPartialResponse` no longer decode from JSON, are immutable, built through a const constructor, compare by value, gain `copyWith`, and their `duration` is a non-nullable `String`. +- `UpdateChannelPartialResponse.channel` and `UpdateMemberPartialResponse.channelMember` are nullable, and `UpdateChannelPartialResponse.members` is a non-nullable list. +- `StreamChatApi.channel.updateChannelPartial`, `enableSlowdown`, `disableSlowdown` and `updateMemberPartial` are removed; call them on `StreamChatClient` instead. +- `MemberUpdatePayload` and `MemberUpdateType` are removed; pass `set: {'pinned': true}` or `unset: ['pinned']` (and `archived`) to `updateMemberPartial`, or call `pinChannel`, `archiveChannel` and their counterparts. 🐞 Fixed diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index 84f41d684c..06e4a6d226 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -1673,39 +1673,15 @@ class Channel { ); } - /// Update the channel's [name]. + /// Sets this channel's [name], leaving every other field as it is. /// - /// This is the same as calling [updatePartial] and providing a map with a - /// 'name' key: - /// - /// ```dart - /// channel.updatePartial( - /// set: {'name': 'Updated channel name'} - /// ); - /// ``` - /// - /// Instead do: - /// ```dart - /// channel.updateName('Updated channel name'); - /// ``` - Future updateName(String name) => updatePartial(set: {'name': name}); + /// The same as [updatePartial] with `set: {'name': name}`. + Future> updateName(String name) => updatePartial(set: {'name': name}); - /// Update the channel's [image]. - /// - /// This is the same as calling [updatePartial] and providing a map with an - /// 'image' key: + /// Sets this channel's [image] URL, leaving every other field as it is. /// - /// ```dart - /// channel.updatePartial( - /// set: {'image': 'https://getstream.io/new-image'} - /// ); - /// ``` - /// - /// Instead do: - /// ```dart - /// channel.updateImage('https://getstream.io/new-image'); - /// ``` - Future updateImage(String image) => updatePartial(set: {'image': image}); + /// The same as [updatePartial] with `set: {'image': image}`. + Future> updateImage(String image) => updatePartial(set: {'image': image}); /// Update the channel custom data. This replaces all of the channel data /// with the given [channelData]. @@ -1727,19 +1703,13 @@ class Channel { ); } - /// A partial update can be used to set and unset specific custom data fields - /// when it is necessary to retain additional custom data fields on the - /// object. - /// - /// - [set] will add, or update existing attributes. - /// - [unset] will remove the attributes with the provided list of - /// values (keys). + /// Sets the fields in [set] and removes the fields named in [unset] on this channel, leaving every other field + /// as it is. /// - /// If you want to do a full update/replacement, use [update] instead. + /// At least one of [set] and [unset] is required. /// - /// See, https://getstream.io/chat/docs/other-rest/channel_update/?language=dart - /// for more information. - Future updatePartial({ + /// Use [update] to replace every field. + Future> updatePartial({ Map? set, List? unset, }) async { @@ -1747,16 +1717,16 @@ class Channel { return _client.updateChannelPartial(id!, type, set: set, unset: unset); } - /// Enable slow mode - Future enableSlowMode({ + /// Turns on slow mode for this channel, so each member waits [cooldownInterval] seconds between messages. + Future> enableSlowMode({ required int cooldownInterval, }) async { _checkInitialized(); return _client.enableSlowdown(id!, type, cooldownInterval); } - /// Disable slow mode - Future disableSlowMode() async { + /// Turns off slow mode for this channel. + Future> disableSlowMode() async { _checkInitialized(); return _client.disableSlowdown(id!, type); } @@ -2364,52 +2334,28 @@ class Channel { return _client.showChannel(id!, type); } - /// Pins the channel for the current user. - Future pin() async { + /// Pins this channel for the current user. + Future> pin() async { _checkInitialized(); - - final response = await _client.pinChannel( - channelId: id!, - channelType: type, - ); - - return response.channelMember; + return _client.pinChannel(channelId: id!, channelType: type); } - /// Unpins the channel. - Future unpin() async { + /// Unpins this channel for the current user. + Future> unpin() async { _checkInitialized(); - - final response = await _client.unpinChannel( - channelId: id!, - channelType: type, - ); - - return response.channelMember; + return _client.unpinChannel(channelId: id!, channelType: type); } - /// Archives the channel. - Future archive() async { + /// Archives this channel for the current user. + Future> archive() async { _checkInitialized(); - - final response = await _client.archiveChannel( - channelId: id!, - channelType: type, - ); - - return response.channelMember; + return _client.archiveChannel(channelId: id!, channelType: type); } - /// Unarchives the channel for the current user. - Future unarchive() async { + /// Unarchives this channel for the current user. + Future> unarchive() async { _checkInitialized(); - - final response = await _client.unarchiveChannel( - channelId: id!, - channelType: type, - ); - - return response.channelMember; + return _client.unarchiveChannel(channelId: id!, channelType: type); } /// Stream of [Event] coming from websocket connection specific for the diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index c7085d23f4..c6dbdd40fe 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -70,6 +70,8 @@ import '../core/models/response/og_attachment_response.dart'; import '../core/models/response/remove_user_group_members_response.dart'; import '../core/models/response/search_roles_response.dart'; import '../core/models/response/search_user_groups_response.dart'; +import '../core/models/response/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/role_type.dart'; import '../core/models/thread.dart'; @@ -81,6 +83,7 @@ import '../core/util/utils.dart'; import '../db/chat_persistence_client.dart'; import '../event_type.dart'; import '../repository/app_settings_repository.dart'; +import '../repository/channels_repository.dart'; import '../repository/devices_repository.dart'; import '../repository/general_repository.dart'; import '../repository/moderation_repository.dart'; @@ -189,6 +192,7 @@ class StreamChatClient { _userGroupsRepository = UserGroupsRepository(api); _generalRepository = GeneralRepository(api); _moderationRepository = ModerationRepository(api); + _channelsRepository = ChannelsRepository(api); _appSettingsManager = AppSettingsManager(AppSettingsRepository(api)); moderation = ModerationClient(_moderationRepository); @@ -232,6 +236,7 @@ class StreamChatClient { late final UserGroupsRepository _userGroupsRepository; late final GeneralRepository _generalRepository; late final ModerationRepository _moderationRepository; + late final ChannelsRepository _channelsRepository; late final AppSettingsManager _appSettingsManager; /// Muting, banning and flagging, for the connected user. @@ -1238,16 +1243,18 @@ class StreamChatClient { message: message, ); - /// Partial update for the [channelId] of type [ChannelType]. Sets the - /// data provided in [set], and removes the attributes given in [unset]. + /// Sets the fields in [set] and removes the fields named in [unset] on the channel [channelId] of type + /// [channelType], leaving every other field as it is. + /// + /// At least one of [set] and [unset] is required. /// /// Use [updateChannel] for a full update. - Future updateChannelPartial( + Future> updateChannelPartial( String channelId, String channelType, { Map? set, List? unset, - }) => _chatApi.channel.updateChannelPartial( + }) => _channelsRepository.updateChannelPartial( channelId, channelType, set: set, @@ -2128,24 +2135,26 @@ class StreamChatClient { ); } - /// Enables slow mode - Future enableSlowdown( + /// Turns on slow mode for the channel [channelId] of type [channelType], so each member waits [cooldown] seconds + /// between messages. + Future> enableSlowdown( String channelId, String channelType, int cooldown, - ) async => _chatApi.channel.enableSlowdown( + ) => _channelsRepository.updateChannelPartial( channelId, channelType, - cooldown, + set: {'cooldown': cooldown}, ); - /// Disables slow mode - Future disableSlowdown( + /// Turns off slow mode for the channel [channelId] of type [channelType]. + Future> disableSlowdown( String channelId, String channelType, - ) async => _chatApi.channel.disableSlowdown( + ) => _channelsRepository.updateChannelPartial( channelId, channelType, + unset: ['cooldown'], ); /// Pins provided message @@ -2241,60 +2250,66 @@ class StreamChatClient { unset: unset, ); - /// Pins the channel for the current user. - Future pinChannel({ + /// Pins the channel [channelId] of type [channelType] for the current user. + Future> pinChannel({ required String channelId, required String channelType, }) { - return partialMemberUpdate( + return updateMemberPartial( channelId: channelId, channelType: channelType, - set: const MemberUpdatePayload(pinned: true).toJson(), + set: const {'pinned': true}, ); } - /// Unpins the channel for the current user. - Future unpinChannel({ + /// Unpins the channel [channelId] of type [channelType] for the current user. + Future> unpinChannel({ required String channelId, required String channelType, }) { - return partialMemberUpdate( + return updateMemberPartial( channelId: channelId, channelType: channelType, - unset: [MemberUpdateType.pinned.name], + unset: const ['pinned'], ); } - /// Archives the channel for the current user. - Future archiveChannel({ + /// Archives the channel [channelId] of type [channelType] for the current user. + Future> archiveChannel({ required String channelId, required String channelType, }) { - return partialMemberUpdate( + return updateMemberPartial( channelId: channelId, channelType: channelType, - set: const MemberUpdatePayload(archived: true).toJson(), + set: const {'archived': true}, ); } - /// Unarchives the channel for the current user. - Future unarchiveChannel({ + /// Unarchives the channel [channelId] of type [channelType] for the current user. + Future> unarchiveChannel({ required String channelId, required String channelType, }) { - return partialMemberUpdate( + return updateMemberPartial( channelId: channelId, channelType: channelType, - unset: [MemberUpdateType.archived.name], + unset: const ['archived'], ); } - /// Partially updates the member of the given channel. + /// Sets the fields in [set] and removes the fields named in [unset] on the current user's membership of the + /// channel [channelId] of type [channelType], leaving every other field as it is. /// - /// Use [set] to define values to be set. - /// Use [unset] to define values to be unset. - /// When [userId] is not provided, the current user will be used. - Future partialMemberUpdate({ + /// `pinned` and `archived` pin and archive the channel for the current user, as in `set: {'pinned': true}` or + /// `unset: ['archived']`; other keys set custom data on the membership. At least one of [set] and + /// [unset] is required. + /// + /// See also: + /// + /// * [pinChannel] and [unpinChannel], which pin and unpin the channel. + /// * [archiveChannel] and [unarchiveChannel], which archive and unarchive it. + Future> updateMemberPartial({ required String channelId, required String channelType, Map? set, @@ -2302,9 +2317,9 @@ class StreamChatClient { }) { assert(set != null || unset != null, 'Set or unset must be provided.'); - return _chatApi.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, + return _channelsRepository.updateMemberPartial( + channelId, + channelType, set: set, unset: unset, ); diff --git a/packages/stream_chat/lib/src/core/api/channel_api.dart b/packages/stream_chat/lib/src/core/api/channel_api.dart index 0971a61cc6..14a762c818 100644 --- a/packages/stream_chat/lib/src/core/api/channel_api.dart +++ b/packages/stream_chat/lib/src/core/api/channel_api.dart @@ -115,52 +115,6 @@ class ChannelApi { return UpdateChannelResponse.fromJson(response.data); } - /// Updates the [channelId] of type [ChannelType] data with [data] - Future updateChannelPartial( - String channelId, - String channelType, { - Map? set, - List? unset, - }) async { - final response = await _client.patch( - _getChannelUrl(channelId, channelType), - data: { - if (set != null) 'set': set, - if (unset != null) 'unset': unset, - }, - ); - return PartialUpdateChannelResponse.fromJson(response.data); - } - - /// Enable slowdown - Future enableSlowdown( - String channelId, - String channelType, - int cooldown, - ) async { - final response = await updateChannelPartial( - channelId, - channelType, - set: { - 'cooldown': cooldown, - }, - ); - return response; - } - - /// Disable slowdown - Future disableSlowdown( - String channelId, - String channelType, - ) async { - final response = await updateChannelPartial( - channelId, - channelType, - unset: ['cooldown'], - ); - return response; - } - /// Accept invitation to the channel Future acceptChannelInvite( String channelId, @@ -404,26 +358,6 @@ class ChannelApi { return EmptyResponse.fromJson(response.data); } - /// Updates some of the member data - Future updateMemberPartial({ - required String channelId, - required String channelType, - Map? set, - List? unset, - }) async { - final response = await _client.patch( - // Note: user_id is not required for client side Apis as it can be fetched - // directly from the user token but, for the api path is built with it - // so we need to pass it as a placeholder. - '${_getChannelUrl(channelId, channelType)}/member/{user_id}', - data: { - if (set != null) 'set': set, - if (unset != null) 'unset': unset, - }, - ); - return PartialUpdateMemberResponse.fromJson(response.data); - } - /// Sends delivery receipts for the latest messages in multiple channels. /// /// Accepts up to 100 channels per call. diff --git a/packages/stream_chat/lib/src/core/api/requests.dart b/packages/stream_chat/lib/src/core/api/requests.dart index 8d61e0434a..26ea16f9d7 100644 --- a/packages/stream_chat/lib/src/core/api/requests.dart +++ b/packages/stream_chat/lib/src/core/api/requests.dart @@ -206,31 +206,3 @@ class ThreadOptions extends Equatable { @override List get props => [watch, replyLimit, participantLimit, memberLimit]; } - -/// Payload for updating a member. -@JsonSerializable(createFactory: false, includeIfNull: false) -class MemberUpdatePayload { - /// Creates a new MemberUpdatePayload instance. - const MemberUpdatePayload({ - this.archived, - this.pinned, - }); - - /// Set to true to archive the channel for a user. - final bool? archived; - - /// Set to true to pin the channel for a user. - final bool? pinned; - - /// Serialize model to json - Map toJson() => _$MemberUpdatePayloadToJson(this); -} - -/// Type of member update to unset. -enum MemberUpdateType { - /// Unset the archived flag. - archived, - - /// Unset the pinned flag. - pinned, -} 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 466725b861..8d2380876a 100644 --- a/packages/stream_chat/lib/src/core/api/requests.g.dart +++ b/packages/stream_chat/lib/src/core/api/requests.g.dart @@ -62,10 +62,3 @@ Map _$ThreadOptionsToJson(ThreadOptions instance) => _$MemberUpdatePayloadToJson( - MemberUpdatePayload instance, -) => { - 'archived': ?instance.archived, - 'pinned': ?instance.pinned, -}; diff --git a/packages/stream_chat/lib/src/core/api/responses.dart b/packages/stream_chat/lib/src/core/api/responses.dart index f288c9cf9f..9e4234ad65 100644 --- a/packages/stream_chat/lib/src/core/api/responses.dart +++ b/packages/stream_chat/lib/src/core/api/responses.dart @@ -103,17 +103,6 @@ class QueryMembersResponse extends _BaseResponse { static QueryMembersResponse fromJson(Map json) => _$QueryMembersResponseFromJson(json); } -/// Model response for update member API calls, such as -/// [StreamChatClient.updateMemberPartial] -@JsonSerializable(createToJson: false) -class PartialUpdateMemberResponse extends _BaseResponse { - /// The updated member state - late Member channelMember; - - /// Create a new instance from a json - static PartialUpdateMemberResponse fromJson(Map json) => _$PartialUpdateMemberResponseFromJson(json); -} - /// Model response for [StreamChatClient.queryUsers] api call @JsonSerializable(createToJson: false) class QueryUsersResponse extends _BaseResponse { @@ -265,20 +254,6 @@ class UpdateChannelResponse extends _BaseResponse { static UpdateChannelResponse fromJson(Map json) => _$UpdateChannelResponseFromJson(json); } -/// Model response for [Channel.updatePartial] api call -@JsonSerializable(createToJson: false) -class PartialUpdateChannelResponse extends _BaseResponse { - /// Updated channel - late ChannelModel channel; - - /// Channel members - List? members; - - /// Create a new instance from a json - static PartialUpdateChannelResponse fromJson(Map json) => - _$PartialUpdateChannelResponseFromJson(json); -} - /// Model response for [Channel.inviteMembers] api call @JsonSerializable(createToJson: false) class InviteMembersResponse extends _BaseResponse { diff --git a/packages/stream_chat/lib/src/core/api/responses.g.dart b/packages/stream_chat/lib/src/core/api/responses.g.dart index f7d5e36ba1..c1b22b4a20 100644 --- a/packages/stream_chat/lib/src/core/api/responses.g.dart +++ b/packages/stream_chat/lib/src/core/api/responses.g.dart @@ -50,14 +50,6 @@ QueryMembersResponse _$QueryMembersResponseFromJson( ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList() ?? []; -PartialUpdateMemberResponse _$PartialUpdateMemberResponseFromJson( - Map json, -) => PartialUpdateMemberResponse() - ..duration = json['duration'] as String? - ..channelMember = Member.fromJson( - json['channel_member'] as Map, - ); - QueryUsersResponse _$QueryUsersResponseFromJson(Map json) => QueryUsersResponse() ..duration = json['duration'] as String? ..users = (json['users'] as List?)?.map((e) => User.fromJson(e as Map)).toList() ?? []; @@ -140,13 +132,6 @@ UpdateChannelResponse _$UpdateChannelResponseFromJson( ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList() ..message = json['message'] == null ? null : Message.fromJson(json['message'] as Map); -PartialUpdateChannelResponse _$PartialUpdateChannelResponseFromJson( - Map json, -) => PartialUpdateChannelResponse() - ..duration = json['duration'] as String? - ..channel = ChannelModel.fromJson(json['channel'] as Map) - ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList(); - InviteMembersResponse _$InviteMembersResponseFromJson( Map json, ) => InviteMembersResponse() diff --git a/packages/stream_chat/lib/src/core/models/response/update_channel_partial_response.dart b/packages/stream_chat/lib/src/core/models/response/update_channel_partial_response.dart new file mode 100644 index 0000000000..277875dd18 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/update_channel_partial_response.dart @@ -0,0 +1,29 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../channel_model.dart'; +import '../member.dart'; + +part 'update_channel_partial_response.freezed.dart'; + +/// A channel after some of its fields were set or unset. +@freezed +class UpdateChannelPartialResponse with _$UpdateChannelPartialResponse { + /// Creates a new [UpdateChannelPartialResponse]. + const UpdateChannelPartialResponse({ + required this.duration, + this.channel, + this.members = const [], + }); + + /// How long the request took to handle, such as `4.21ms`. + @override + final String duration; + + /// The updated channel. + @override + final ChannelModel? channel; + + /// A page of the channel's members after the update. + @override + final List members; +} diff --git a/packages/stream_chat/lib/src/core/models/response/update_channel_partial_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/update_channel_partial_response.freezed.dart new file mode 100644 index 0000000000..f408d1e3f0 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/update_channel_partial_response.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_channel_partial_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$UpdateChannelPartialResponse { + String get duration; + ChannelModel? get channel; + List get members; + + /// Create a copy of UpdateChannelPartialResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $UpdateChannelPartialResponseCopyWith get copyWith => + _$UpdateChannelPartialResponseCopyWithImpl( + this as UpdateChannelPartialResponse, + _$identity, + ); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is UpdateChannelPartialResponse && + (identical(other.duration, duration) || other.duration == duration) && + (identical(other.channel, channel) || other.channel == channel) && + const DeepCollectionEquality().equals(other.members, members)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + duration, + channel, + const DeepCollectionEquality().hash(members), + ); + + @override + String toString() { + return 'UpdateChannelPartialResponse(duration: $duration, channel: $channel, members: $members)'; + } +} + +/// @nodoc +abstract mixin class $UpdateChannelPartialResponseCopyWith<$Res> { + factory $UpdateChannelPartialResponseCopyWith( + UpdateChannelPartialResponse value, + $Res Function(UpdateChannelPartialResponse) _then, + ) = _$UpdateChannelPartialResponseCopyWithImpl; + @useResult + $Res call({String duration, ChannelModel? channel, List members}); +} + +/// @nodoc +class _$UpdateChannelPartialResponseCopyWithImpl<$Res> implements $UpdateChannelPartialResponseCopyWith<$Res> { + _$UpdateChannelPartialResponseCopyWithImpl(this._self, this._then); + + final UpdateChannelPartialResponse _self; + final $Res Function(UpdateChannelPartialResponse) _then; + + /// Create a copy of UpdateChannelPartialResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? duration = null, + Object? channel = freezed, + Object? members = null, + }) { + return _then( + UpdateChannelPartialResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + channel: freezed == channel + ? _self.channel + : channel // ignore: cast_nullable_to_non_nullable + as ChannelModel?, + members: null == members + ? _self.members + : members // ignore: cast_nullable_to_non_nullable + as List, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/response/update_member_partial_response.dart b/packages/stream_chat/lib/src/core/models/response/update_member_partial_response.dart new file mode 100644 index 0000000000..cf51cd941d --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/update_member_partial_response.dart @@ -0,0 +1,23 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../member.dart'; + +part 'update_member_partial_response.freezed.dart'; + +/// The current user's membership of a channel after some of its fields were set or unset. +@freezed +class UpdateMemberPartialResponse with _$UpdateMemberPartialResponse { + /// Creates a new [UpdateMemberPartialResponse]. + const UpdateMemberPartialResponse({ + required this.duration, + this.channelMember, + }); + + /// How long the request took to handle, such as `4.21ms`. + @override + final String duration; + + /// The updated membership. + @override + final Member? channelMember; +} diff --git a/packages/stream_chat/lib/src/core/models/response/update_member_partial_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/update_member_partial_response.freezed.dart new file mode 100644 index 0000000000..0f8cfbf156 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/update_member_partial_response.freezed.dart @@ -0,0 +1,82 @@ +// 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_member_partial_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$UpdateMemberPartialResponse { + String get duration; + Member? get channelMember; + + /// Create a copy of UpdateMemberPartialResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $UpdateMemberPartialResponseCopyWith get copyWith => + _$UpdateMemberPartialResponseCopyWithImpl( + this as UpdateMemberPartialResponse, + _$identity, + ); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is UpdateMemberPartialResponse && + (identical(other.duration, duration) || other.duration == duration) && + (identical(other.channelMember, channelMember) || other.channelMember == channelMember)); + } + + @override + int get hashCode => Object.hash(runtimeType, duration, channelMember); + + @override + String toString() { + return 'UpdateMemberPartialResponse(duration: $duration, channelMember: $channelMember)'; + } +} + +/// @nodoc +abstract mixin class $UpdateMemberPartialResponseCopyWith<$Res> { + factory $UpdateMemberPartialResponseCopyWith( + UpdateMemberPartialResponse value, + $Res Function(UpdateMemberPartialResponse) _then, + ) = _$UpdateMemberPartialResponseCopyWithImpl; + @useResult + $Res call({String duration, Member? channelMember}); +} + +/// @nodoc +class _$UpdateMemberPartialResponseCopyWithImpl<$Res> implements $UpdateMemberPartialResponseCopyWith<$Res> { + _$UpdateMemberPartialResponseCopyWithImpl(this._self, this._then); + + final UpdateMemberPartialResponse _self; + final $Res Function(UpdateMemberPartialResponse) _then; + + /// Create a copy of UpdateMemberPartialResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? duration = null, Object? channelMember = freezed}) { + return _then( + UpdateMemberPartialResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + channelMember: freezed == channelMember + ? _self.channelMember + : channelMember // ignore: cast_nullable_to_non_nullable + as Member?, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/repository/channels_repository.dart b/packages/stream_chat/lib/src/repository/channels_repository.dart new file mode 100644 index 0000000000..91f418578e --- /dev/null +++ b/packages/stream_chat/lib/src/repository/channels_repository.dart @@ -0,0 +1,53 @@ +import 'package:stream_core/stream_core.dart' show PatternMatching, Result; + +import '../../open_api/api.dart' as api; +import '../core/models/response/update_channel_partial_response.dart'; +import '../core/models/response/update_member_partial_response.dart'; +import 'mapper/channel_mapper.dart'; + +/// Repository dedicated to channel operations. +class ChannelsRepository { + /// Creates a new channels repository. + const ChannelsRepository(this._api); + + final api.DefaultApi _api; + + /// Sets the fields in [set] and removes the fields named in [unset] on the channel [channelId] of type + /// [channelType], leaving every other field as it is. + /// + /// At least one of [set] and [unset] is required. + Future> updateChannelPartial( + String channelId, + String channelType, { + Map? set, + List? unset, + }) async { + final result = await _api.updateChannelPartial( + type: channelType, + id: channelId, + updateChannelPartialRequest: api.UpdateChannelPartialRequest(set: set, unset: unset), + ); + + return result.map((response) => response.toModel()); + } + + /// Sets the fields in [set] and removes the fields named in [unset] on the current user's membership of the + /// channel [channelId] of type [channelType], leaving every other field as it is. + /// + /// `pinned` and `archived` pin and archive the channel for the current user; other keys set custom data on the + /// membership. At least one of [set] and [unset] is required. + Future> updateMemberPartial( + String channelId, + String channelType, { + Map? set, + List? unset, + }) async { + final result = await _api.updateMemberPartial( + type: channelType, + id: channelId, + updateMemberPartialRequest: api.UpdateMemberPartialRequest(set: set, unset: unset), + ); + + return result.map((response) => response.toModel()); + } +} diff --git a/packages/stream_chat/lib/src/repository/mapper/channel_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/channel_mapper.dart index ee9c582b14..0010f37353 100644 --- a/packages/stream_chat/lib/src/repository/mapper/channel_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/channel_mapper.dart @@ -7,6 +7,8 @@ import '../../core/models/chat_preferences.dart'; import '../../core/models/command.dart'; import '../../core/models/member.dart'; import '../../core/models/push_level.dart'; +import '../../core/models/response/update_channel_partial_response.dart'; +import '../../core/models/response/update_member_partial_response.dart'; import 'user_mapper.dart'; // TODO(openapi-migration): re-point these mappers in group 11. @@ -164,3 +166,22 @@ extension ChannelMemberResponseMapper on api.ChannelMemberResponse { deletedAt: deletedAt, ); } + +/// Maps a generated [api.UpdateChannelPartialResponse] to an [UpdateChannelPartialResponse]. +extension UpdateChannelPartialResponseMapper on api.UpdateChannelPartialResponse { + /// Converts this response into an [UpdateChannelPartialResponse]. + UpdateChannelPartialResponse toModel() => UpdateChannelPartialResponse( + duration: duration, + channel: channel?.toModel(), + members: [for (final member in members) member.toModel()], + ); +} + +/// Maps a generated [api.UpdateMemberPartialResponse] to an [UpdateMemberPartialResponse]. +extension UpdateMemberPartialResponseMapper on api.UpdateMemberPartialResponse { + /// Converts this response into an [UpdateMemberPartialResponse]. + UpdateMemberPartialResponse toModel() => UpdateMemberPartialResponse( + duration: duration, + channelMember: channelMember?.toModel(), + ); +} diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index a4877847c5..4f5e10febe 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -133,6 +133,8 @@ export 'src/core/models/response/remove_user_group_members_response.dart'; export 'src/core/models/response/search_roles_response.dart'; export 'src/core/models/response/search_user_groups_response.dart'; export 'src/core/models/response/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/role.dart'; export 'src/core/models/role_type.dart'; diff --git a/packages/stream_chat/test/src/client/channel/channel_test.dart b/packages/stream_chat/test/src/client/channel/channel_test.dart index e6838e2d60..b7a8639409 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -3723,7 +3723,7 @@ void main() { verify(() => client.updateChannel(channelId, channelType, channelData, message: any(named: 'message'))).called(1); }); - test('`.updateImage`', () async { + test('Channel.updateImage sets the image through a partial update', () async { const image = 'https://getstream.io/new-image'; final channelModel = ChannelModel( @@ -3738,13 +3738,13 @@ void main() { set: {'image': image}, ), ).thenAnswer( - (_) async => PartialUpdateChannelResponse()..channel = channelModel, + (_) async => Result.success(UpdateChannelPartialResponse(duration: '0.01ms', channel: channelModel)), ); final res = await channel.updateImage(image); - expect(res, isNotNull); - expect(res.channel.extraData['image'], image); + expect(res.isSuccess, isTrue); + expect(res.getOrNull()!.channel!.extraData['image'], image); verify( () => client.updateChannelPartial( @@ -3755,7 +3755,7 @@ void main() { ).called(1); }); - test('`.updateName`', () async { + test('Channel.updateName sets the name through a partial update', () async { const name = 'Name'; final channelModel = ChannelModel( @@ -3770,13 +3770,13 @@ void main() { set: {'name': name}, ), ).thenAnswer( - (_) async => PartialUpdateChannelResponse()..channel = channelModel, + (_) async => Result.success(UpdateChannelPartialResponse(duration: '0.01ms', channel: channelModel)), ); final res = await channel.updateName(name); - expect(res, isNotNull); - expect(res.channel.extraData['name'], name); + expect(res.isSuccess, isTrue); + expect(res.getOrNull()!.channel!.extraData['name'], name); verify( () => client.updateChannelPartial( @@ -3787,7 +3787,7 @@ void main() { ).called(1); }); - test('`.updatePartial`', () async { + test('Channel.updatePartial sends the set and unset fields for this channel', () async { const set = { 'name': 'Stream Team', 'profile_image': 'test-profile-image', @@ -3811,15 +3811,15 @@ void main() { unset: unset, ), ).thenAnswer( - (_) async => PartialUpdateChannelResponse()..channel = channelModel, + (_) async => Result.success(UpdateChannelPartialResponse(duration: '0.01ms', channel: channelModel)), ); final res = await channel.updatePartial(set: set, unset: unset); - expect(res, isNotNull); - expect(res.channel.cid, channelModel.cid); + expect(res.isSuccess, isTrue); + expect(res.getOrNull()!.channel!.cid, channelModel.cid); expect( - res.channel.extraData, + res.getOrNull()!.channel!.extraData, {'coolness': 999, ...set}, ); @@ -4699,7 +4699,7 @@ void main() { ).called(1); }); - test('`.enableSlowMode`', () async { + test("Channel.enableSlowMode returns the client's enableSlowdown response", () async { const cooldown = 10; final channelModel = ChannelModel( @@ -4713,11 +4713,13 @@ void main() { channelType, cooldown, ), - ).thenAnswer((_) async => PartialUpdateChannelResponse()..channel = channelModel); + ).thenAnswer( + (_) async => Result.success(UpdateChannelPartialResponse(duration: '0.01ms', channel: channelModel)), + ); final res = await channel.enableSlowMode(cooldownInterval: 10); - expect(res, isNotNull); + expect(res.getOrNull(), UpdateChannelPartialResponse(duration: '0.01ms', channel: channelModel)); verify( () => client.enableSlowdown( @@ -4728,7 +4730,7 @@ void main() { ).called(1); }); - test('`.disableSlowMode`', () async { + test("Channel.disableSlowMode returns the client's disableSlowdown response", () async { final channelModel = ChannelModel( cid: channelCid, ); @@ -4738,11 +4740,13 @@ void main() { channelId, channelType, ), - ).thenAnswer((_) async => PartialUpdateChannelResponse()..channel = channelModel); + ).thenAnswer( + (_) async => Result.success(UpdateChannelPartialResponse(duration: '0.01ms', channel: channelModel)), + ); final res = await channel.disableSlowMode(); - expect(res, isNotNull); + expect(res.getOrNull(), UpdateChannelPartialResponse(duration: '0.01ms', channel: channelModel)); verify(() => client.disableSlowdown(channelId, channelType)).called(1); }); @@ -4863,51 +4867,70 @@ void main() { }); // testing archiving - test('`.archive`', () async { + test("Channel.archive returns the client's archiveChannel response", () async { + final response = UpdateMemberPartialResponse( + duration: '0.01ms', + channelMember: Member(userId: 'test-user-id'), + ); when(() => client.archiveChannel(channelId: channelId, channelType: channelType)).thenAnswer( - (_) async => FakePartialUpdateMemberResponse(), + (_) async => Result.success(response), ); final res = await channel.archive(); - expect(res, isNotNull); + expect(res.getOrNull(), response); verify(() => client.archiveChannel(channelId: channelId, channelType: channelType)).called(1); }); - test('`.unarchive`', () async { + test("Channel.unarchive returns the client's unarchiveChannel response", () async { + final response = UpdateMemberPartialResponse( + duration: '0.01ms', + channelMember: Member(userId: 'test-user-id'), + ); when(() => client.unarchiveChannel(channelId: channelId, channelType: channelType)).thenAnswer( - (_) async => FakePartialUpdateMemberResponse(), + (_) async => Result.success(response), ); final res = await channel.unarchive(); - expect(res, isNotNull); + expect(res.getOrNull(), response); verify(() => client.unarchiveChannel(channelId: channelId, channelType: channelType)).called(1); }); - // testing pinning - test('`.pin`', () async { + test("Channel.pin returns the client's pinChannel response", () async { + final response = UpdateMemberPartialResponse( + duration: '0.01ms', + channelMember: Member(userId: 'test-user-id'), + ); when( () => client.pinChannel(channelId: channelId, channelType: channelType), - ).thenAnswer((_) async => FakePartialUpdateMemberResponse()); + ).thenAnswer( + (_) async => Result.success(response), + ); final res = await channel.pin(); - expect(res, isNotNull); + expect(res.getOrNull(), response); verify(() => client.pinChannel(channelId: channelId, channelType: channelType)).called(1); }); - test('`.unpin`', () async { + test("Channel.unpin returns the client's unpinChannel response", () async { + final response = UpdateMemberPartialResponse( + duration: '0.01ms', + channelMember: Member(userId: 'test-user-id'), + ); when( () => client.unpinChannel(channelId: channelId, channelType: channelType), - ).thenAnswer((_) async => FakePartialUpdateMemberResponse()); + ).thenAnswer( + (_) async => Result.success(response), + ); final res = await channel.unpin(); - expect(res, isNotNull); + expect(res.getOrNull(), response); verify(() => client.unpinChannel(channelId: channelId, channelType: channelType)).called(1); }); diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index c4a0a6ce7e..be8da5fc1c 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -1638,38 +1638,6 @@ void main() { verifyNoMoreInteractions(fakeChatApi.channel); }); - test('`.updateChannelPartial`', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - const set = { - 'name': 'Stream Team', - 'profile_image': 'test-profile-image', - }; - const unset = ['tag', 'last_name']; - - when(() => fakeChatApi.channel.updateChannelPartial(channelId, channelType, set: set, unset: unset)).thenAnswer( - (invocation) async => PartialUpdateChannelResponse() - ..channel = ChannelModel( - id: channelId, - type: channelType, - extraData: {...set}, - ), - ); - - final res = await client.updateChannelPartial( - channelId, - channelType, - set: set, - unset: unset, - ); - expect(res, isNotNull); - expect(res.channel.cid, '$channelType:$channelId'); - expect(res.channel.extraData, set); - - verify(() => fakeChatApi.channel.updateChannelPartial(channelId, channelType, set: set, unset: unset)).called(1); - verifyNoMoreInteractions(fakeChatApi.channel); - }); - test('StreamChatClient.addDevice sends the device and returns a success', () async { const id = 'test-device-id'; const request = api.CreateDeviceRequest( @@ -2838,218 +2806,6 @@ void main() { verifyNoMoreInteractions(fakeChatApi.channel); }); - test('`.partialMemberUpdate with userId`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const otherUserId = 'test-other-user-id'; - const set = {'pinned': true}; - const unset = ['pinned']; - - when( - () => fakeChatApi.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: set, - unset: unset, - ), - ).thenAnswer( - (_) async => FakePartialUpdateMemberResponse( - channelMember: Member(userId: otherUserId), - ), - ); - - final res = await client.partialMemberUpdate( - channelId: channelId, - channelType: channelType, - set: set, - unset: unset, - ); - - expect(res, isNotNull); - expect(res.channelMember.userId, otherUserId); - - verify( - () => fakeChatApi.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: set, - unset: unset, - ), - ).called(1); - verifyNoMoreInteractions(fakeChatApi.channel); - }); - - test('`.partialMemberUpdate with current user`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const set = {'pinned': true}; - const unset = ['pinned']; - - when( - () => fakeChatApi.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: set, - unset: unset, - ), - ).thenAnswer( - (_) async => FakePartialUpdateMemberResponse( - channelMember: Member(userId: userId), - ), - ); - - final res = await client.partialMemberUpdate( - channelId: channelId, - channelType: channelType, - set: set, - unset: unset, - ); - - expect(res, isNotNull); - expect(res.channelMember.userId, userId); - verify( - () => fakeChatApi.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: set, - unset: unset, - ), - ).called(1); - verifyNoMoreInteractions(fakeChatApi.channel); - }); - - test('`.pinChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - - when( - () => fakeChatApi.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: const MemberUpdatePayload(pinned: true).toJson(), - ), - ).thenAnswer( - (_) async => FakePartialUpdateMemberResponse( - channelMember: Member(userId: userId, pinnedAt: DateTime.now()), - ), - ); - - final res = await client.pinChannel( - channelId: channelId, - channelType: channelType, - ); - - expect(res, isNotNull); - - verify( - () => fakeChatApi.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: const MemberUpdatePayload(pinned: true).toJson(), - ), - ).called(1); - verifyNoMoreInteractions(fakeChatApi.channel); - }); - - test('`.unpinChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - - when( - () => fakeChatApi.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - unset: [MemberUpdateType.pinned.name], - ), - ).thenAnswer( - (_) async => FakePartialUpdateMemberResponse( - channelMember: Member(userId: userId, pinnedAt: DateTime.now()), - ), - ); - - final res = await client.unpinChannel( - channelId: channelId, - channelType: channelType, - ); - - expect(res, isNotNull); - - verify( - () => fakeChatApi.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - unset: [MemberUpdateType.pinned.name], - ), - ).called(1); - verifyNoMoreInteractions(fakeChatApi.channel); - }); - - test('`.archiveChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - - when( - () => fakeChatApi.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: const MemberUpdatePayload(archived: true).toJson(), - ), - ).thenAnswer( - (_) async => FakePartialUpdateMemberResponse( - channelMember: Member(userId: userId, archivedAt: DateTime.now()), - ), - ); - - final res = await client.archiveChannel( - channelId: channelId, - channelType: channelType, - ); - - expect(res, isNotNull); - - verify( - () => fakeChatApi.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: const MemberUpdatePayload(archived: true).toJson(), - ), - ).called(1); - verifyNoMoreInteractions(fakeChatApi.channel); - }); - - test('`.unarchiveChannel`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - - when( - () => fakeChatApi.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - unset: [MemberUpdateType.archived.name], - ), - ).thenAnswer( - (_) async => FakePartialUpdateMemberResponse( - channelMember: Member(userId: userId, pinnedAt: DateTime.now()), - ), - ); - - final res = await client.unarchiveChannel( - channelId: channelId, - channelType: channelType, - ); - - expect(res, isNotNull); - - verify( - () => fakeChatApi.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - unset: [MemberUpdateType.archived.name], - ), - ).called(1); - verifyNoMoreInteractions(fakeChatApi.channel); - }); - test('`.acceptChannelInvite`', () async { const channelType = 'test-channel-type'; const channelId = 'test-channel-id'; diff --git a/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart b/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart new file mode 100644 index 0000000000..089a00821c --- /dev/null +++ b/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart @@ -0,0 +1,381 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/open_api/api.dart' as api; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../fakes.dart'; +import '../mocks.dart'; +import '../ws/fake_chat_server.dart'; + +void main() { + setUpAll(() => registerFallbackValue(const api.UpdateChannelPartialRequest())); + + test( + 'StreamChatClient.updateChannelPartial sends the set and unset fields and returns the updated members', + () async { + final defaultApi = _defaultApiAnswering(Result.success(_updateChannelPartialResponse())); + final client = _client(defaultApi); + + final res = await client.updateChannelPartial( + 'general', + 'messaging', + set: const {'name': 'General'}, + unset: const ['topic'], + ); + + expect(res.getOrNull()!.duration, '0.01ms'); + expect(res.getOrNull()!.members, [fakeChannelMember()]); + verify( + () => defaultApi.updateChannelPartial( + type: 'messaging', + id: 'general', + updateChannelPartialRequest: const api.UpdateChannelPartialRequest( + set: {'name': 'General'}, + unset: ['topic'], + ), + ), + ).called(1); + verifyNoMoreInteractions(defaultApi); + }, + ); + + test('StreamChatClient.updateChannelPartial returns the channel with every field it models', () async { + final client = _client(_defaultApiAnswering(Result.success(_updateChannelPartialResponse()))); + + final res = await client.updateChannelPartial('general', 'messaging', set: const {'name': 'General'}); + + final channel = res.getOrNull()!.channel!; + expect(channel.id, 'general'); + expect(channel.type, 'messaging'); + expect(channel.cid, 'messaging:general'); + expect(channel.ownCapabilities, [ChannelCapability.sendMessage, ChannelCapability.readEvents]); + expect(channel.createdBy, fakeUser('creator')); + expect(channel.frozen, isTrue); + expect(channel.lastMessageAt, DateTime.utc(2026, 3, 4)); + expect(channel.createdAt, DateTime.utc(2026)); + expect(channel.updatedAt, DateTime.utc(2026, 2)); + expect(channel.deletedAt, DateTime.utc(2026, 3)); + expect(channel.memberCount, 2); + expect(channel.members, [fakeChannelMember()]); + expect(channel.team, 'blue'); + expect(channel.cooldown, 30); + expect(channel.disabled, isFalse); + expect(channel.hidden, isTrue); + expect(channel.muted, isTrue); + expect(channel.blocked, isFalse); + expect(channel.truncatedAt, DateTime.utc(2026, 3, 2)); + expect(channel.truncatedBy, fakeUser('truncator')); + expect(channel.autoTranslationEnabled, isTrue); + expect(channel.autoTranslationLanguage, 'fr'); + expect(channel.messageCount, 42); + expect(channel.filterTags, ['support']); + }); + + test('StreamChatClient.updateChannelPartial returns the channel name and image from its custom data', () async { + final client = _client(_defaultApiAnswering(Result.success(_updateChannelPartialResponse()))); + + final res = await client.updateChannelPartial('general', 'messaging', set: const {'name': 'General'}); + + final channel = res.getOrNull()!.channel!; + expect(channel.name, 'General'); + expect(channel.extraData['image'], 'https://example.com/general.png'); + }); + + test('StreamChatClient.updateChannelPartial keeps the custom data and the server fields in extraData', () async { + final client = _client(_defaultApiAnswering(Result.success(_updateChannelPartialResponse()))); + + final res = await client.updateChannelPartial('general', 'messaging', set: const {'name': 'General'}); + + expect(res.getOrNull()!.channel!.extraData, { + 'name': 'General', + 'image': 'https://example.com/general.png', + 'topic': 'anything', + 'disabled': false, + 'hidden': true, + 'muted': true, + 'blocked': false, + 'truncated_at': DateTime.utc(2026, 3, 2).toIso8601String(), + 'truncated_by': fakeUser('truncator').toJson(), + 'auto_translation_enabled': true, + 'auto_translation_language': 'fr', + }); + }); + + test('StreamChatClient.updateChannelPartial drops channel custom data named like a channel field', () async { + final response = _updateChannelPartialResponse( + channel: _channelResponse(custom: const {'hidden': 'shadowed', 'cid': 'shadowed'}), + ); + final client = _client(_defaultApiAnswering(Result.success(response))); + + final res = await client.updateChannelPartial('general', 'messaging', set: const {'name': 'General'}); + + final channel = res.getOrNull()!.channel!; + expect(channel.hidden, isTrue); + expect(channel.extraData, isNot(contains('cid'))); + }); + + test('StreamChatClient.updateChannelPartial falls back to the defaults for channel fields left out', () async { + final response = _updateChannelPartialResponse( + channel: api.ChannelResponse( + cid: 'messaging:general', + id: 'general', + type: 'messaging', + createdAt: DateTime.utc(2026), + updatedAt: DateTime.utc(2026, 2), + custom: const {}, + disabled: false, + frozen: false, + ), + ); + final client = _client(_defaultApiAnswering(Result.success(response))); + + final res = await client.updateChannelPartial('general', 'messaging', set: const {'name': 'General'}); + + final channel = res.getOrNull()!.channel!; + expect(channel.memberCount, 0); + expect(channel.cooldown, 0); + expect(channel.config.commands, isEmpty); + expect(channel.members, isNull); + }); + + test('StreamChatClient.updateChannelPartial returns the channel config with every field it models', () async { + final client = _client(_defaultApiAnswering(Result.success(_updateChannelPartialResponse()))); + + final res = await client.updateChannelPartial('general', 'messaging', set: const {'name': 'General'}); + + final config = res.getOrNull()!.channel!.config; + expect(config.automod, 'simple'); + expect(config.commands.single.name, 'giphy'); + expect(config.commands.single.description, 'Post a random gif'); + expect(config.commands.single.args, '[text]'); + expect(config.commands.single.set, CommandSet.fun); + expect(config.connectEvents, isTrue); + expect(config.createdAt, DateTime.utc(2025)); + expect(config.updatedAt, DateTime.utc(2025, 2)); + expect(config.maxMessageLength, 5000); + expect(config.messageRetention, 'infinite'); + expect(config.mutes, isTrue); + expect(config.reactions, isTrue); + expect(config.readEvents, isTrue); + expect(config.replies, isTrue); + expect(config.search, isTrue); + expect(config.polls, isTrue); + expect(config.pushLevel, PushLevel.directMentions); + expect(config.pushNotifications, isFalse); + expect( + config.chatPreferences, + const ChatPreferences( + channelMentions: ChatPreferenceLevel.all, + defaultPreference: ChatPreferenceLevel.none, + directMentions: ChatPreferenceLevel.all, + groupMentions: ChatPreferenceLevel.none, + hereMentions: ChatPreferenceLevel.all, + roleMentions: ChatPreferenceLevel.none, + threadReplies: ChatPreferenceLevel.all, + ), + ); + expect(config.typingEvents, isTrue); + expect(config.uploads, isTrue); + expect(config.urlEnrichment, isTrue); + expect(config.skipLastMsgUpdateForSystemMsgs, isTrue); + expect(config.userMessageReminders, isTrue); + expect(config.markMessagesPending, isTrue); + expect(config.deliveryEvents, isTrue); + expect(config.sharedLocations, isTrue); + }); + + test('StreamChatClient.updateChannelPartial returns a null channel when the response has none', () async { + const response = api.UpdateChannelPartialResponse(duration: '0.01ms', members: []); + final client = _client(_defaultApiAnswering(const Result.success(response))); + + final res = await client.updateChannelPartial('general', 'messaging', set: const {'name': 'General'}); + + expect(res, const Result.success(UpdateChannelPartialResponse(duration: '0.01ms'))); + }); + + test('StreamChatClient.updateChannelPartial returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final client = _client(_defaultApiAnswering(const Result.failure(error))); + + final res = await client.updateChannelPartial('general', 'messaging', set: const {'name': 'General'}); + + expect(res.exceptionOrNull(), error); + }); + + test('StreamChatClient.enableSlowdown sends the cooldown and returns a success', () async { + final defaultApi = _defaultApiAnswering(Result.success(_updateChannelPartialResponse())); + final client = _client(defaultApi); + + final res = await client.enableSlowdown('general', 'messaging', 30); + + expect(res.isSuccess, isTrue); + + verify( + () => defaultApi.updateChannelPartial( + type: 'messaging', + id: 'general', + updateChannelPartialRequest: const api.UpdateChannelPartialRequest(set: {'cooldown': 30}), + ), + ).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.enableSlowdown returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final client = _client(_defaultApiAnswering(const Result.failure(error))); + + final res = await client.enableSlowdown('general', 'messaging', 30); + + expect(res.exceptionOrNull(), error); + }); + + test('StreamChatClient.disableSlowdown sends the cooldown unset and returns a success', () async { + final defaultApi = _defaultApiAnswering(Result.success(_updateChannelPartialResponse())); + final client = _client(defaultApi); + + final res = await client.disableSlowdown('general', 'messaging'); + + expect(res.isSuccess, isTrue); + + verify( + () => defaultApi.updateChannelPartial( + type: 'messaging', + id: 'general', + updateChannelPartialRequest: const api.UpdateChannelPartialRequest(unset: ['cooldown']), + ), + ).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.disableSlowdown returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final client = _client(_defaultApiAnswering(const Result.failure(error))); + + final res = await client.disableSlowdown('general', 'messaging'); + + expect(res.exceptionOrNull(), error); + }); +} + +StreamChatClient _client(api.DefaultApi defaultApi) { + final client = StreamChatClient('test-api-key', defaultApi: defaultApi, wsProvider: FakeChatServer().connect); + addTearDown(client.dispose); + return client; +} + +MockDefaultApi _defaultApiAnswering(Result result) { + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateChannelPartial( + type: any(named: 'type'), + id: any(named: 'id'), + updateChannelPartialRequest: any(named: 'updateChannelPartialRequest'), + ), + ).thenAnswer((_) async => result); + return defaultApi; +} + +// A response with every field set. +api.UpdateChannelPartialResponse _updateChannelPartialResponse({ + api.ChannelResponse? channel, + List? members, +}) => api.UpdateChannelPartialResponse( + channel: channel ?? _channelResponse(), + duration: '0.01ms', + members: members ?? [fakeChannelMemberResponse()], +); + +// A channel with every field set. +api.ChannelResponse _channelResponse({ + Map custom = const { + 'name': 'General', + 'image': 'https://example.com/general.png', + 'topic': 'anything', + }, +}) => api.ChannelResponse( + autoTranslationEnabled: true, + autoTranslationLanguage: 'fr', + blocked: false, + cid: 'messaging:general', + config: api.ChannelConfigWithInfo( + allowedFlagReasons: const ['spam'], + automod: api.ChannelConfigWithInfoAutomod.simple, + automodBehavior: api.ChannelConfigWithInfoAutomodBehavior.flag, + blocklist: 'profanity', + blocklistBehavior: api.ChannelConfigWithInfoBlocklistBehavior.block, + chatPreferences: const api.ChatPreferences( + channelMentions: 'all', + defaultPreference: 'none', + directMentions: 'all', + distinctChannelMessages: 'all', + groupMentions: 'none', + hereMentions: 'all', + roleMentions: 'none', + threadReplies: 'all', + ), + commands: [ + api.Command( + args: '[text]', + createdAt: DateTime.utc(2025), + description: 'Post a random gif', + name: 'giphy', + set: 'fun_set', + updatedAt: DateTime.utc(2025, 2), + ), + ], + connectEvents: true, + countMessages: true, + createdAt: DateTime.utc(2025), + customEvents: true, + deliveryEvents: true, + grants: const { + 'user': ['read-channel'], + }, + markMessagesPending: true, + maxMessageLength: 5000, + messageRetention: 'infinite', + mutes: true, + name: 'messaging', + partitionSize: 10, + partitionTtl: '24h', + polls: true, + pushLevel: api.ChannelConfigWithInfoPushLevel.directMentions, + pushNotifications: false, + quotes: true, + reactions: true, + readEvents: true, + reminders: true, + replies: true, + search: true, + sharedLocations: true, + skipLastMsgUpdateForSystemMsgs: true, + typingEvents: true, + updatedAt: DateTime.utc(2025, 2), + uploads: true, + urlEnrichment: true, + userMessageReminders: true, + ), + cooldown: 30, + createdAt: DateTime.utc(2026), + createdBy: fakeUserResponse('creator'), + custom: custom, + deletedAt: DateTime.utc(2026, 3), + disabled: false, + filterTags: const ['support'], + frozen: true, + hidden: true, + hideMessagesBefore: DateTime.utc(2026, 3, 1), + id: 'general', + lastMessageAt: DateTime.utc(2026, 3, 4), + memberCount: 2, + members: [fakeChannelMemberResponse()], + messageCount: 42, + muteExpiresAt: DateTime.utc(2026, 5), + muted: true, + ownCapabilities: const [api.ChannelOwnCapability.sendMessage, api.ChannelOwnCapability.readEvents], + team: 'blue', + truncatedAt: DateTime.utc(2026, 3, 2), + truncatedBy: fakeUserResponse('truncator'), + type: 'messaging', + updatedAt: DateTime.utc(2026, 2), +); diff --git a/packages/stream_chat/test/src/client/client_update_member_partial_test.dart b/packages/stream_chat/test/src/client/client_update_member_partial_test.dart new file mode 100644 index 0000000000..f59b392a65 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_update_member_partial_test.dart @@ -0,0 +1,215 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/open_api/api.dart' as api; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../fakes.dart'; +import '../mocks.dart'; +import '../ws/fake_chat_server.dart'; + +void main() { + setUpAll(() => registerFallbackValue(const api.UpdateMemberPartialRequest())); + + test('StreamChatClient.updateMemberPartial sends the set and unset fields and returns the updated member', () async { + final defaultApi = _defaultApiAnswering(Result.success(_updateMemberPartialResponse())); + final client = _client(defaultApi); + + final res = await client.updateMemberPartial( + channelId: 'general', + channelType: 'messaging', + set: const {'nickname': 'Mo'}, + unset: const ['status'], + ); + + expect(res.getOrNull(), UpdateMemberPartialResponse(duration: '0.01ms', channelMember: fakeChannelMember())); + verify( + () => defaultApi.updateMemberPartial( + type: 'messaging', + id: 'general', + updateMemberPartialRequest: const api.UpdateMemberPartialRequest(set: {'nickname': 'Mo'}, unset: ['status']), + ), + ).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.updateMemberPartial returns a null member when the response has none', () async { + const response = api.UpdateMemberPartialResponse(duration: '0.01ms'); + final client = _client(_defaultApiAnswering(const Result.success(response))); + + final res = await client.updateMemberPartial(channelId: 'general', channelType: 'messaging', set: const {'a': 1}); + + expect(res, const Result.success(UpdateMemberPartialResponse(duration: '0.01ms'))); + }); + + test('StreamChatClient.updateMemberPartial returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final client = _client(_defaultApiAnswering(const Result.failure(error))); + + final res = await client.updateMemberPartial(channelId: 'general', channelType: 'messaging', set: const {'a': 1}); + + expect(res.exceptionOrNull(), error); + }); + + test('StreamChatClient.updateMemberPartial drops custom data named like a member field', () async { + final response = api.UpdateMemberPartialResponse( + duration: '0.01ms', + channelMember: fakeChannelMemberResponse(custom: const {'status': 'shadowed', 'user_id': 'shadowed'}), + ); + final client = _client(_defaultApiAnswering(Result.success(response))); + + final res = await client.updateMemberPartial(channelId: 'general', channelType: 'messaging', set: const {'a': 1}); + + final member = res.getOrNull()!.channelMember!; + expect(member.status, 'member'); + expect(member.extraData, isNot(contains('user_id'))); + }); + + test('StreamChatClient.updateMemberPartial falls back to the defaults for member fields left out', () async { + final response = api.UpdateMemberPartialResponse( + duration: '0.01ms', + channelMember: api.ChannelMemberResponse( + banned: false, + channelRole: 'channel_member', + createdAt: DateTime.utc(2026), + updatedAt: DateTime.utc(2026, 2), + custom: const {}, + notificationsMuted: false, + shadowBanned: false, + ), + ); + final client = _client(_defaultApiAnswering(Result.success(response))); + + final res = await client.updateMemberPartial(channelId: 'general', channelType: 'messaging', set: const {'a': 1}); + + final member = res.getOrNull()!.channelMember!; + expect(member.invited, isFalse); + expect(member.isModerator, isFalse); + expect(member.deletedMessages, isEmpty); + }); + + test('StreamChatClient.pinChannel sends the pinned flag and returns the updated member', () async { + final defaultApi = _defaultApiAnswering(Result.success(_updateMemberPartialResponse())); + final client = _client(defaultApi); + + final res = await client.pinChannel(channelId: 'general', channelType: 'messaging'); + + expect(res.getOrNull(), UpdateMemberPartialResponse(duration: '0.01ms', channelMember: fakeChannelMember())); + verify( + () => defaultApi.updateMemberPartial( + type: 'messaging', + id: 'general', + updateMemberPartialRequest: const api.UpdateMemberPartialRequest(set: {'pinned': true}), + ), + ).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.pinChannel returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final client = _client(_defaultApiAnswering(const Result.failure(error))); + + final res = await client.pinChannel(channelId: 'general', channelType: 'messaging'); + + expect(res.exceptionOrNull(), error); + }); + + test('StreamChatClient.unpinChannel sends the pinned flag unset and returns the updated member', () async { + final defaultApi = _defaultApiAnswering(Result.success(_updateMemberPartialResponse())); + final client = _client(defaultApi); + + final res = await client.unpinChannel(channelId: 'general', channelType: 'messaging'); + + expect(res.getOrNull(), UpdateMemberPartialResponse(duration: '0.01ms', channelMember: fakeChannelMember())); + verify( + () => defaultApi.updateMemberPartial( + type: 'messaging', + id: 'general', + updateMemberPartialRequest: const api.UpdateMemberPartialRequest(unset: ['pinned']), + ), + ).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.unpinChannel returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final client = _client(_defaultApiAnswering(const Result.failure(error))); + + final res = await client.unpinChannel(channelId: 'general', channelType: 'messaging'); + + expect(res.exceptionOrNull(), error); + }); + + test('StreamChatClient.archiveChannel sends the archived flag and returns the updated member', () async { + final defaultApi = _defaultApiAnswering(Result.success(_updateMemberPartialResponse())); + final client = _client(defaultApi); + + final res = await client.archiveChannel(channelId: 'general', channelType: 'messaging'); + + expect(res.getOrNull(), UpdateMemberPartialResponse(duration: '0.01ms', channelMember: fakeChannelMember())); + verify( + () => defaultApi.updateMemberPartial( + type: 'messaging', + id: 'general', + updateMemberPartialRequest: const api.UpdateMemberPartialRequest(set: {'archived': true}), + ), + ).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.archiveChannel returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final client = _client(_defaultApiAnswering(const Result.failure(error))); + + final res = await client.archiveChannel(channelId: 'general', channelType: 'messaging'); + + expect(res.exceptionOrNull(), error); + }); + + test('StreamChatClient.unarchiveChannel sends the archived flag unset and returns the updated member', () async { + final defaultApi = _defaultApiAnswering(Result.success(_updateMemberPartialResponse())); + final client = _client(defaultApi); + + final res = await client.unarchiveChannel(channelId: 'general', channelType: 'messaging'); + + expect(res.getOrNull(), UpdateMemberPartialResponse(duration: '0.01ms', channelMember: fakeChannelMember())); + verify( + () => defaultApi.updateMemberPartial( + type: 'messaging', + id: 'general', + updateMemberPartialRequest: const api.UpdateMemberPartialRequest(unset: ['archived']), + ), + ).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.unarchiveChannel returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final client = _client(_defaultApiAnswering(const Result.failure(error))); + + final res = await client.unarchiveChannel(channelId: 'general', channelType: 'messaging'); + + expect(res.exceptionOrNull(), error); + }); +} + +StreamChatClient _client(api.DefaultApi defaultApi) { + final client = StreamChatClient('test-api-key', defaultApi: defaultApi, wsProvider: FakeChatServer().connect); + addTearDown(client.dispose); + return client; +} + +MockDefaultApi _defaultApiAnswering(Result result) { + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateMemberPartial( + type: any(named: 'type'), + id: any(named: 'id'), + updateMemberPartialRequest: any(named: 'updateMemberPartialRequest'), + ), + ).thenAnswer((_) async => result); + return defaultApi; +} + +// A response with every field set. +api.UpdateMemberPartialResponse _updateMemberPartialResponse() => + api.UpdateMemberPartialResponse(duration: '0.01ms', channelMember: fakeChannelMemberResponse()); diff --git a/packages/stream_chat/test/src/core/api/channel_api_test.dart b/packages/stream_chat/test/src/core/api/channel_api_test.dart index 794e96f8a5..a3edda1866 100644 --- a/packages/stream_chat/test/src/core/api/channel_api_test.dart +++ b/packages/stream_chat/test/src/core/api/channel_api_test.dart @@ -324,50 +324,6 @@ void main() { verifyNoMoreInteractions(client); }); - test('updateChannelPartial', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - const set = { - 'name': 'Stream Team', - 'profile_image': 'test-profile-image', - }; - - const unset = ['tag', 'last_name']; - - final path = _getChannelUrl(channelId, channelType); - - final channelModel = ChannelModel( - id: channelId, - type: channelType, - extraData: set, - ); - - when( - () => client.patch(path, data: {'set': set, 'unset': unset}), - ).thenAnswer( - (_) async => successResponse( - path, - data: { - 'channel': channelModel.toJson(), - }, - ), - ); - - final res = await channelApi.updateChannelPartial( - channelId, - channelType, - set: set, - unset: unset, - ); - - expect(res, isNotNull); - - verify( - () => client.patch(path, data: {'set': set, 'unset': unset}), - ).called(1); - verifyNoMoreInteractions(client); - }); - test('acceptChannelInvite', () async { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; @@ -890,142 +846,6 @@ void main() { verifyNoMoreInteractions(client); }); - test('archiveChannel', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - - final path = '${_getChannelUrl(channelId, channelType)}/member/{user_id}'; - const archivedAt = '2025-04-10 10:27:03.150349'; - - when( - () => client.patch( - path, - data: { - 'set': {'archived': true}, - }, - ), - ).thenAnswer( - (_) async => successResponse( - path, - data: { - 'channel_member': { - 'archived_at': archivedAt, - }, - }, - ), - ); - - final res = await channelApi.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: const MemberUpdatePayload(archived: true).toJson(), - ); - - expect(res, isNotNull); - expect(res.channelMember.archivedAt, DateTime.parse(archivedAt)); - - verify(() => client.patch(path, data: any(named: 'data'))).called(1); - verifyNoMoreInteractions(client); - }); - - test('unarchiveChannel', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - - final path = '${_getChannelUrl(channelId, channelType)}/member/{user_id}'; - - when( - () => client.patch( - path, - data: { - 'unset': ['archived'], - }, - ), - ).thenAnswer( - (_) async => successResponse(path, data: {'channel_member': {}}), - ); - - final res = await channelApi.updateMemberPartial( - channelId: channelId, - channelType: channelType, - unset: [MemberUpdateType.archived.name], - ); - - expect(res, isNotNull); - expect(res.channelMember.archivedAt, null); - - verify(() => client.patch(path, data: any(named: 'data'))).called(1); - verifyNoMoreInteractions(client); - }); - - test('pinChannel', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - - final path = '${_getChannelUrl(channelId, channelType)}/member/{user_id}'; - const pinnedAt = '2025-04-10 10:27:03.150349'; - - when( - () => client.patch( - path, - data: { - 'set': {'pinned': true}, - }, - ), - ).thenAnswer( - (_) async => successResponse( - path, - data: { - 'channel_member': { - 'pinned_at': pinnedAt, - }, - }, - ), - ); - - final res = await channelApi.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: const MemberUpdatePayload(pinned: true).toJson(), - ); - - expect(res, isNotNull); - expect(res.channelMember.pinnedAt, DateTime.parse(pinnedAt)); - - verify(() => client.patch(path, data: any(named: 'data'))).called(1); - verifyNoMoreInteractions(client); - }); - - test('unpinChannel', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - - final path = '${_getChannelUrl(channelId, channelType)}/member/{user_id}'; - - when( - () => client.patch( - path, - data: { - 'unset': ['pinned'], - }, - ), - ).thenAnswer( - (_) async => successResponse(path, data: {'channel_member': {}}), - ); - - final res = await channelApi.updateMemberPartial( - channelId: channelId, - channelType: channelType, - unset: [MemberUpdateType.pinned.name], - ); - - expect(res, isNotNull); - expect(res.channelMember.pinnedAt, null); - - verify(() => client.patch(path, data: any(named: 'data'))).called(1); - verifyNoMoreInteractions(client); - }); - test('stopWatching', () async { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; @@ -1042,96 +862,6 @@ void main() { verifyNoMoreInteractions(client); }); - test('enableSlowdown', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - const cooldown = 10; - const set = { - 'cooldown': 10, - }; - - final path = _getChannelUrl(channelId, channelType); - - final channelModel = ChannelModel( - id: channelId, - type: channelType, - extraData: set, - ); - - when( - () => client.patch( - path, - data: { - 'set': set, - }, - ), - ).thenAnswer( - (_) async => successResponse( - path, - data: { - 'channel': channelModel.toJson(), - }, - ), - ); - - final res = await channelApi.enableSlowdown(channelId, channelType, cooldown); - - expect(res, isNotNull); - - verify( - () => client.patch( - path, - data: { - 'set': set, - }, - ), - ).called(1); - verifyNoMoreInteractions(client); - }); - - test('disableSlowdown', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - const unset = ['cooldown']; - - final path = _getChannelUrl(channelId, channelType); - - final channelModel = ChannelModel( - id: channelId, - type: channelType, - ); - - when( - () => client.patch( - path, - data: { - 'unset': unset, - }, - ), - ).thenAnswer( - (_) async => successResponse( - path, - data: { - 'channel': channelModel.toJson(), - }, - ), - ); - - final res = await channelApi.disableSlowdown(channelId, channelType); - - expect(res, isNotNull); - - verify( - () => client.patch( - path, - data: { - 'unset': unset, - }, - ), - ).called(1); - verifyNoMoreInteractions(client); - }); - test('markChannelsDelivered', () async { const path = '/channels/delivered'; diff --git a/packages/stream_chat/test/src/fakes.dart b/packages/stream_chat/test/src/fakes.dart index 9021e9a1df..e65ab48e05 100644 --- a/packages/stream_chat/test/src/fakes.dart +++ b/packages/stream_chat/test/src/fakes.dart @@ -252,12 +252,80 @@ class FakePollVote extends Fake implements PollVote {} class FakeChannelState extends Fake implements ChannelState {} -class FakePartialUpdateMemberResponse extends Fake implements PartialUpdateMemberResponse { - FakePartialUpdateMemberResponse({ - Member? channelMember, - }) : _channelMember = channelMember ?? Member(); +/// A generated channel member with every field set. +api.ChannelMemberResponse fakeChannelMemberResponse({Map custom = const {'nickname': 'Mo'}}) => + api.ChannelMemberResponse( + archivedAt: DateTime.utc(2026, 1, 5), + banExpires: DateTime.utc(2026, 6), + banFromFutureChannels: true, + banned: true, + channelRole: 'channel_moderator', + createdAt: DateTime.utc(2026), + custom: custom, + deletedAt: DateTime.utc(2026, 8), + deletedMessages: const ['message-1'], + futureChannelBanExpires: DateTime.utc(2026, 7), + inviteAcceptedAt: DateTime.utc(2026, 1, 2), + inviteRejectedAt: DateTime.utc(2026, 1, 3), + invited: true, + isModerator: true, + notificationsMuted: true, + pinnedAt: DateTime.utc(2026, 1, 4), + role: 'user', + shadowBanned: true, + status: 'member', + updatedAt: DateTime.utc(2026, 2), + user: fakeUserResponse('member'), + userId: 'member', + ); - final Member _channelMember; - @override - Member get channelMember => _channelMember; -} +/// A generated [api.UserResponse] with the id [id] and only the fields it requires. +api.UserResponse fakeUserResponse(String id) => api.UserResponse( + banned: false, + blockedUserIds: const [], + createdAt: DateTime.utc(2025), + custom: const {}, + id: id, + language: 'en', + online: false, + role: 'user', + teams: const [], + updatedAt: DateTime.utc(2025), +); + +/// The [Member] that [fakeChannelMemberResponse] maps to, with its default custom data. +Member fakeChannelMember() => Member( + user: fakeUser('member'), + userId: 'member', + inviteAcceptedAt: DateTime.utc(2026, 1, 2), + inviteRejectedAt: DateTime.utc(2026, 1, 3), + invited: true, + channelRole: 'channel_moderator', + isModerator: true, + createdAt: DateTime.utc(2026), + updatedAt: DateTime.utc(2026, 2), + banned: true, + banExpires: DateTime.utc(2026, 6), + shadowBanned: true, + pinnedAt: DateTime.utc(2026, 1, 4), + archivedAt: DateTime.utc(2026, 1, 5), + deletedMessages: const ['message-1'], + extraData: const {'nickname': 'Mo', 'role': 'user'}, + notificationsMuted: true, + status: 'member', + banFromFutureChannels: true, + futureChannelBanExpires: DateTime.utc(2026, 7), + deletedAt: DateTime.utc(2026, 8), +); + +/// The [User] that [fakeUserResponse] maps to. +User fakeUser(String id) => User( + id: id, + role: 'user', + createdAt: DateTime.utc(2025), + updatedAt: DateTime.utc(2025), + online: false, + banned: false, + teams: const [], + language: 'en', +); diff --git a/packages/stream_chat/test/src/repository/mapper/channel_mapper_test.dart b/packages/stream_chat/test/src/repository/mapper/channel_mapper_test.dart deleted file mode 100644 index 4c02040c93..0000000000 --- a/packages/stream_chat/test/src/repository/mapper/channel_mapper_test.dart +++ /dev/null @@ -1,329 +0,0 @@ -import 'package:stream_chat/open_api/api.dart' as api; -import 'package:stream_chat/src/core/models/channel_model.dart'; -import 'package:stream_chat/src/core/models/chat_preferences.dart'; -import 'package:stream_chat/src/core/models/command.dart'; -import 'package:stream_chat/src/core/models/push_level.dart'; -import 'package:stream_chat/src/repository/mapper/channel_mapper.dart'; -import 'package:stream_chat/src/repository/mapper/user_mapper.dart'; -import 'package:test/test.dart'; - -void main() { - test('ChannelResponse.toModel maps every field the channel models', () { - final channel = _channelResponse().toModel(); - - expect(channel.id, 'general'); - expect(channel.type, 'messaging'); - expect(channel.cid, 'messaging:general'); - expect(channel.ownCapabilities, [ChannelCapability.sendMessage, ChannelCapability.readEvents]); - expect(channel.createdBy?.id, 'creator'); - expect(channel.frozen, isTrue); - expect(channel.lastMessageAt, DateTime.utc(2026, 3, 4)); - expect(channel.createdAt, DateTime.utc(2026)); - expect(channel.updatedAt, DateTime.utc(2026, 2)); - expect(channel.deletedAt, DateTime.utc(2026, 3)); - expect(channel.memberCount, 2); - expect(channel.members?.single.userId, 'member'); - expect(channel.team, 'blue'); - expect(channel.cooldown, 30); - expect(channel.disabled, isFalse); - expect(channel.hidden, isTrue); - expect(channel.muted, isTrue); - expect(channel.blocked, isFalse); - expect(channel.truncatedAt, DateTime.utc(2026, 3, 2)); - expect(channel.truncatedBy?.id, 'truncator'); - expect(channel.autoTranslationEnabled, isTrue); - expect(channel.autoTranslationLanguage, 'fr'); - expect(channel.messageCount, 42); - expect(channel.filterTags, ['support']); - }); - - test('ChannelResponse.toModel takes the name and image from the custom data', () { - final channel = _channelResponse().toModel(); - - expect(channel.name, 'General'); - expect(channel.extraData['image'], 'https://example.com/general.png'); - }); - - test('ChannelResponse.toModel keeps the custom data and the server fields in extraData', () { - final channel = _channelResponse().toModel(); - - expect(channel.extraData, { - 'name': 'General', - 'image': 'https://example.com/general.png', - 'topic': 'anything', - 'disabled': false, - 'hidden': true, - 'muted': true, - 'blocked': false, - 'truncated_at': DateTime.utc(2026, 3, 2).toIso8601String(), - 'truncated_by': _userResponse('truncator').toModel().toJson(), - 'auto_translation_enabled': true, - 'auto_translation_language': 'fr', - }); - }); - - test('ChannelResponse.toModel drops custom data named like a channel field', () { - final channel = _channelResponse(custom: const {'hidden': 'shadowed', 'cid': 'shadowed'}).toModel(); - - expect(channel.hidden, isTrue); - expect(channel.extraData, isNot(contains('cid'))); - }); - - test('ChannelResponse.toModel falls back to the defaults for the fields the response leaves out', () { - final channel = api.ChannelResponse( - cid: 'messaging:general', - id: 'general', - type: 'messaging', - createdAt: DateTime.utc(2026), - updatedAt: DateTime.utc(2026, 2), - custom: const {}, - disabled: false, - frozen: false, - ).toModel(); - - expect(channel.memberCount, 0); - expect(channel.cooldown, 0); - expect(channel.config.commands, isEmpty); - expect(channel.members, isNull); - }); - - test('ChannelConfigWithInfo.toModel maps every field the config models', () { - final config = _channelResponse().config!.toModel(); - - expect(config.automod, 'simple'); - expect(config.commands.single.name, 'giphy'); - expect(config.commands.single.description, 'Post a random gif'); - expect(config.commands.single.args, '[text]'); - expect(config.commands.single.set, CommandSet.fun); - expect(config.connectEvents, isTrue); - expect(config.createdAt, DateTime.utc(2025)); - expect(config.updatedAt, DateTime.utc(2025, 2)); - expect(config.maxMessageLength, 5000); - expect(config.messageRetention, 'infinite'); - expect(config.mutes, isTrue); - expect(config.reactions, isTrue); - expect(config.readEvents, isTrue); - expect(config.replies, isTrue); - expect(config.search, isTrue); - expect(config.polls, isTrue); - expect(config.pushLevel, PushLevel.directMentions); - expect(config.pushNotifications, isFalse); - expect( - config.chatPreferences, - const ChatPreferences( - channelMentions: ChatPreferenceLevel.all, - defaultPreference: ChatPreferenceLevel.none, - directMentions: ChatPreferenceLevel.all, - groupMentions: ChatPreferenceLevel.none, - hereMentions: ChatPreferenceLevel.all, - roleMentions: ChatPreferenceLevel.none, - threadReplies: ChatPreferenceLevel.all, - ), - ); - expect(config.typingEvents, isTrue); - expect(config.uploads, isTrue); - expect(config.urlEnrichment, isTrue); - expect(config.skipLastMsgUpdateForSystemMsgs, isTrue); - expect(config.userMessageReminders, isTrue); - expect(config.markMessagesPending, isTrue); - expect(config.deliveryEvents, isTrue); - expect(config.sharedLocations, isTrue); - }); - - test('ChannelMemberResponse.toModel maps every field the member models', () { - final member = _memberResponse().toModel(); - - expect(member.user?.id, 'member'); - expect(member.userId, 'member'); - expect(member.inviteAcceptedAt, DateTime.utc(2026, 1, 2)); - expect(member.inviteRejectedAt, DateTime.utc(2026, 1, 3)); - expect(member.invited, isTrue); - expect(member.channelRole, 'channel_moderator'); - expect(member.isModerator, isTrue); - expect(member.createdAt, DateTime.utc(2026)); - expect(member.updatedAt, DateTime.utc(2026, 2)); - expect(member.banned, isTrue); - expect(member.banExpires, DateTime.utc(2026, 6)); - expect(member.shadowBanned, isTrue); - expect(member.pinnedAt, DateTime.utc(2026, 1, 4)); - expect(member.archivedAt, DateTime.utc(2026, 1, 5)); - expect(member.deletedMessages, ['message-1']); - expect(member.notificationsMuted, isTrue); - expect(member.status, 'member'); - expect(member.banFromFutureChannels, isTrue); - expect(member.futureChannelBanExpires, DateTime.utc(2026, 7)); - expect(member.deletedAt, DateTime.utc(2026, 8)); - }); - - test('ChannelMemberResponse.toModel keeps the custom data and the server fields in extraData', () { - final member = _memberResponse().toModel(); - - expect(member.extraData, { - 'nickname': 'Mo', - 'role': 'user', - 'notifications_muted': true, - 'status': 'member', - 'ban_from_future_channels': true, - 'future_channel_ban_expires': DateTime.utc(2026, 7).toIso8601String(), - 'deleted_at': DateTime.utc(2026, 8).toIso8601String(), - }); - }); - - test('ChannelMemberResponse.toModel drops custom data named like a member field', () { - final member = _memberResponse(custom: const {'status': 'shadowed', 'user_id': 'shadowed'}).toModel(); - - expect(member.status, 'member'); - expect(member.extraData, isNot(contains('user_id'))); - }); - - test('ChannelMemberResponse.toModel falls back to the defaults for the fields the response leaves out', () { - final member = api.ChannelMemberResponse( - banned: false, - channelRole: 'channel_member', - createdAt: DateTime.utc(2026), - updatedAt: DateTime.utc(2026, 2), - custom: const {}, - notificationsMuted: false, - shadowBanned: false, - ).toModel(); - - expect(member.invited, isFalse); - expect(member.isModerator, isFalse); - expect(member.deletedMessages, isEmpty); - }); -} - -// A response with every field set. -api.ChannelResponse _channelResponse({ - Map custom = const { - 'name': 'General', - 'image': 'https://example.com/general.png', - 'topic': 'anything', - }, -}) => api.ChannelResponse( - autoTranslationEnabled: true, - autoTranslationLanguage: 'fr', - blocked: false, - cid: 'messaging:general', - config: api.ChannelConfigWithInfo( - allowedFlagReasons: const ['spam'], - automod: api.ChannelConfigWithInfoAutomod.simple, - automodBehavior: api.ChannelConfigWithInfoAutomodBehavior.flag, - blocklist: 'profanity', - blocklistBehavior: api.ChannelConfigWithInfoBlocklistBehavior.block, - chatPreferences: const api.ChatPreferences( - channelMentions: 'all', - defaultPreference: 'none', - directMentions: 'all', - distinctChannelMessages: 'all', - groupMentions: 'none', - hereMentions: 'all', - roleMentions: 'none', - threadReplies: 'all', - ), - commands: [ - api.Command( - args: '[text]', - createdAt: DateTime.utc(2025), - description: 'Post a random gif', - name: 'giphy', - set: 'fun_set', - updatedAt: DateTime.utc(2025, 2), - ), - ], - connectEvents: true, - countMessages: true, - createdAt: DateTime.utc(2025), - customEvents: true, - deliveryEvents: true, - grants: const { - 'user': ['read-channel'], - }, - markMessagesPending: true, - maxMessageLength: 5000, - messageRetention: 'infinite', - mutes: true, - name: 'messaging', - partitionSize: 10, - partitionTtl: '24h', - polls: true, - pushLevel: api.ChannelConfigWithInfoPushLevel.directMentions, - pushNotifications: false, - quotes: true, - reactions: true, - readEvents: true, - reminders: true, - replies: true, - search: true, - sharedLocations: true, - skipLastMsgUpdateForSystemMsgs: true, - typingEvents: true, - updatedAt: DateTime.utc(2025, 2), - uploads: true, - urlEnrichment: true, - userMessageReminders: true, - ), - cooldown: 30, - createdAt: DateTime.utc(2026), - createdBy: _userResponse('creator'), - custom: custom, - deletedAt: DateTime.utc(2026, 3), - disabled: false, - filterTags: const ['support'], - frozen: true, - hidden: true, - hideMessagesBefore: DateTime.utc(2026, 3, 1), - id: 'general', - lastMessageAt: DateTime.utc(2026, 3, 4), - memberCount: 2, - members: [_memberResponse()], - messageCount: 42, - muteExpiresAt: DateTime.utc(2026, 5), - muted: true, - ownCapabilities: const [api.ChannelOwnCapability.sendMessage, api.ChannelOwnCapability.readEvents], - team: 'blue', - truncatedAt: DateTime.utc(2026, 3, 2), - truncatedBy: _userResponse('truncator'), - type: 'messaging', - updatedAt: DateTime.utc(2026, 2), -); - -// A response with every field set. -api.ChannelMemberResponse _memberResponse({Map custom = const {'nickname': 'Mo'}}) => - api.ChannelMemberResponse( - archivedAt: DateTime.utc(2026, 1, 5), - banExpires: DateTime.utc(2026, 6), - banFromFutureChannels: true, - banned: true, - channelRole: 'channel_moderator', - createdAt: DateTime.utc(2026), - custom: custom, - deletedAt: DateTime.utc(2026, 8), - deletedMessages: const ['message-1'], - futureChannelBanExpires: DateTime.utc(2026, 7), - inviteAcceptedAt: DateTime.utc(2026, 1, 2), - inviteRejectedAt: DateTime.utc(2026, 1, 3), - invited: true, - isModerator: true, - notificationsMuted: true, - pinnedAt: DateTime.utc(2026, 1, 4), - role: 'user', - shadowBanned: true, - status: 'member', - updatedAt: DateTime.utc(2026, 2), - user: _userResponse('member'), - userId: 'member', - ); - -api.UserResponse _userResponse(String id) => api.UserResponse( - banned: false, - blockedUserIds: const [], - createdAt: DateTime.utc(2025), - custom: const {}, - id: id, - language: 'en', - online: false, - role: 'user', - teams: const [], - updatedAt: DateTime.utc(2025), -); diff --git a/sample_app/lib/widgets/edit_group_sheet.dart b/sample_app/lib/widgets/edit_group_sheet.dart index b1779c98c1..f18f2c8662 100644 --- a/sample_app/lib/widgets/edit_group_sheet.dart +++ b/sample_app/lib/widgets/edit_group_sheet.dart @@ -268,7 +268,12 @@ class _EditGroupSheetState extends State { } if (set.isNotEmpty || unset.isNotEmpty) { - await _channel.updatePartial(set: set, unset: unset); + final result = await _channel.updatePartial(set: set, unset: unset); + if (result case Failure(:final error)) { + messenger.showSnackBar(SnackBar(content: Text('Failed to save: $error'))); + if (mounted) setState(() => _saving = false); + return; + } } // Strip the saved URL from the orphan list so dispose() doesn't From 2dea3a6274733f021fdea4aa46fb8e15365964fd Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 5 Oct 2026 16:17:59 +0200 Subject: [PATCH 13/32] refactor(llc)!: rename enableSlowdown and disableSlowdown to enableSlowMode and disableSlowMode Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 9 +++++++-- openapi-migration/15-partial-updates.md | 12 +++++++----- openapi-migration/tool/generate_plan.py | 12 +++++++----- packages/stream_chat/CHANGELOG.md | 4 ++-- .../lib/src/client/channel/channel.dart | 4 ++-- packages/stream_chat/lib/src/client/client.dart | 4 ++-- .../test/src/client/channel/channel_test.dart | 12 ++++++------ .../client_update_channel_partial_test.dart | 16 ++++++++-------- 8 files changed, 41 insertions(+), 32 deletions(-) diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index d8665c0ba2..47af5489f9 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -224,7 +224,8 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `PartialUpdateChannelResponse` | `UpdateChannelPartialResponse` | `renamed` | The name of the API's response. Same fields: `duration`, `channel` and `members` | | `PartialUpdateMemberResponse` | `UpdateMemberPartialResponse` | `renamed` | The name of the API's response. Same fields: `duration` and `channelMember` | | `StreamChatClient.partialMemberUpdate` | `StreamChatClient.updateMemberPartial` | `renamed` | The name of the API's operation, matching `updateChannelPartial` | -| `StreamChatClient.updateChannelPartial` / `enableSlowdown` / `disableSlowdown` → `Future` | `Future>` | `retyped` | Returns a `Result` instead of throwing | +| `StreamChatClient.enableSlowdown` / `disableSlowdown` | `StreamChatClient.enableSlowMode` / `disableSlowMode` | `renamed` | Matches `Channel.enableSlowMode` / `disableSlowMode` and `ChannelCapability.slowMode` | +| `StreamChatClient.updateChannelPartial` / `enableSlowdown` / `disableSlowdown` → `Future` | `updateChannelPartial` / `enableSlowMode` / `disableSlowMode` → `Future>` | `retyped` | Returns a `Result` instead of throwing | | `Channel.updatePartial` / `updateName` / `updateImage` / `enableSlowMode` / `disableSlowMode` → `Future` | `Future>` | `retyped` | Returns a `Result` instead of throwing. Calling one before the channel is initialized still throws a `StateError` | | `StreamChatClient.partialMemberUpdate` / `pinChannel` / `unpinChannel` / `archiveChannel` / `unarchiveChannel` → `Future` | `Future>` | `retyped` | Returns a `Result` instead of throwing | | `Channel.pin` → `Future`, `unpin` / `archive` / `unarchive` → `Future` | `Future>` | `retyped` | Returns a `Result` instead of throwing, and answers the whole response: read the member off `.channelMember`. Calling one before the channel is initialized still throws a `StateError` | @@ -629,7 +630,8 @@ result.fold( ### Partial Updates **Channel and member partial updates return a `Result` instead of throwing.** On the channel side that covers -`StreamChatClient.updateChannelPartial`, `enableSlowdown` and `disableSlowdown`, and `Channel.updatePartial`, +`StreamChatClient.updateChannelPartial`, `enableSlowMode` and `disableSlowMode` (v10's `enableSlowdown` and +`disableSlowdown`), and `Channel.updatePartial`, `updateName`, `updateImage`, `enableSlowMode` and `disableSlowMode`. On the member side it covers `StreamChatClient.updateMemberPartial` (v10's `partialMemberUpdate`), `pinChannel`, `unpinChannel`, `archiveChannel` and `unarchiveChannel`, and `Channel.pin`, `unpin`, `archive` and `unarchive`. The full update, @@ -656,6 +658,9 @@ result.fold( **The responses take the API's names.** `PartialUpdateChannelResponse` is now `UpdateChannelPartialResponse`, and `PartialUpdateMemberResponse` is now `UpdateMemberPartialResponse`. Their fields keep their names. +**`StreamChatClient.enableSlowdown` and `disableSlowdown` are renamed `enableSlowMode` and `disableSlowMode`,** the +names `Channel` already used. + **`Channel.pin`, `unpin`, `archive` and `unarchive` answer the whole response**, where v10 returned the member: ```dart diff --git a/openapi-migration/15-partial-updates.md b/openapi-migration/15-partial-updates.md index ae24e7e9c1..4743982d6b 100644 --- a/openapi-migration/15-partial-updates.md +++ b/openapi-migration/15-partial-updates.md @@ -34,8 +34,10 @@ `migrations/v11-migration.md` says so. - **Moved off `ChannelApi`:** `updateChannelPartial`, `enableSlowdown`, `disableSlowdown` and `updateMemberPartial`, each now a `StreamChatClient` method over `ChannelsRepository`. -- **`enableSlowdown` and `disableSlowdown` move with it.** Both are `updateChannelPartial` with a - fixed `cooldown`, so they call the repository's one method rather than an operation of their own. +- **`enableSlowdown` and `disableSlowdown` move with it, renamed `enableSlowMode` and + `disableSlowMode`** to match the `Channel` helpers and `ChannelCapability.slowMode`. Both are + `updateChannelPartial` with a fixed `cooldown`, so they call the repository's one method rather than + an operation of their own. - **The v2 route is the v1 handler.** `lib/chat/routes.go` mounts `UpdateChannelPartial` in the shared `coreRoutes`; it is neither gated nor in beta. On v2 it also accepts `custom.` paths in `set` and `unset`, and still takes flat keys, so `{'name': …}` updates the same field. @@ -44,8 +46,8 @@ and `StreamChatClient.partialMemberUpdate` becomes `updateMemberPartial`. Hard renames, as `GetAppSettingsResponse` → `AppSettingsResponse` was: every call site changes for `Result` anyway, and the channel and member calls now share one naming scheme. - The helpers with no spec counterpart keep their names (`pinChannel`, `archiveChannel`, - `enableSlowdown`, `Channel.pin` and the rest). + The other helpers with no spec counterpart keep their names (`pinChannel`, `archiveChannel`, + `Channel.pin` and the rest); the slow-mode pair is renamed above. - **The envelopes are freezed, in `models/response/`.** `UpdateChannelPartialResponse.channel` and `UpdateMemberPartialResponse.channelMember` are nullable, as the spec declares them, where v10 typed them non-null. `UpdateChannelPartialResponse.members` is non-nullable, as the spec requires it, where @@ -64,7 +66,7 @@ ## Risks -- `disableSlowdown` unsets `cooldown`, which the server rejects on both v1 and v2 as a reserved field. It fails before and after this group; the fix is tracked separately, so the request is unchanged. +- `disableSlowMode` unsets `cooldown`, which the server rejects on both v1 and v2 as a reserved field. It fails before and after this group; the fix is tracked separately, so the request is unchanged. ## Definition of done diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index cb91b92b2a..cf4ef9b662 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -796,8 +796,10 @@ def match(verb, path): `migrations/v11-migration.md` says so. - **Moved off `ChannelApi`:** `updateChannelPartial`, `enableSlowdown`, `disableSlowdown` and `updateMemberPartial`, each now a `StreamChatClient` method over `ChannelsRepository`. - - **`enableSlowdown` and `disableSlowdown` move with it.** Both are `updateChannelPartial` with a - fixed `cooldown`, so they call the repository's one method rather than an operation of their own. + - **`enableSlowdown` and `disableSlowdown` move with it, renamed `enableSlowMode` and + `disableSlowMode`** to match the `Channel` helpers and `ChannelCapability.slowMode`. Both are + `updateChannelPartial` with a fixed `cooldown`, so they call the repository's one method rather than + an operation of their own. - **The v2 route is the v1 handler.** `lib/chat/routes.go` mounts `UpdateChannelPartial` in the shared `coreRoutes`; it is neither gated nor in beta. On v2 it also accepts `custom.` paths in `set` and `unset`, and still takes flat keys, so `{'name': …}` updates the same field. @@ -806,8 +808,8 @@ def match(verb, path): and `StreamChatClient.partialMemberUpdate` becomes `updateMemberPartial`. Hard renames, as `GetAppSettingsResponse` → `AppSettingsResponse` was: every call site changes for `Result` anyway, and the channel and member calls now share one naming scheme. - The helpers with no spec counterpart keep their names (`pinChannel`, `archiveChannel`, - `enableSlowdown`, `Channel.pin` and the rest). + The other helpers with no spec counterpart keep their names (`pinChannel`, `archiveChannel`, + `Channel.pin` and the rest); the slow-mode pair is renamed above. - **The envelopes are freezed, in `models/response/`.** `UpdateChannelPartialResponse.channel` and `UpdateMemberPartialResponse.channelMember` are nullable, as the spec declares them, where v10 typed them non-null. `UpdateChannelPartialResponse.members` is non-nullable, as the spec requires it, where @@ -825,7 +827,7 @@ def match(verb, path): The server reads both the same, and still rejects a call with neither. """), risks=[ - '`disableSlowdown` unsets `cooldown`, which the server rejects on both v1 and v2 as a reserved field. ' + '`disableSlowMode` unsets `cooldown`, which the server rejects on both v1 and v2 as a reserved field. ' 'It fails before and after this group; the fix is tracked separately, so the request is unchanged.', ], done=DONE.replace('- [ ]', '- [x]'), diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 944f878cc2..745a17d133 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -83,9 +83,9 @@ - `SendAttachmentResponse`, `SendFileResponse`, `SendImageResponse`, `UploadImageResponse` and `UploadFileResponse` are removed in favor of `UploadedFile`. - `AttachmentFileUploaderProvider` receives the client's `Dio` instead of a `StreamHttpClient`, and `StreamAttachmentFileUploader` is built from one. - `StreamChatApi.fileUploader` and `StreamChatApi`'s `attachmentFileUploaderProvider` are removed; pass `attachmentFileUploaderProvider` to `StreamChatClient` instead. -- `updateChannelPartial`, `enableSlowdown` and `disableSlowdown` on `StreamChatClient`, and `updatePartial`, `updateName`, `updateImage`, `enableSlowMode` and `disableSlowMode` on `Channel`, return a `Result` instead of throwing. +- `updateChannelPartial`, `enableSlowMode` and `disableSlowMode` on `StreamChatClient`, and `updatePartial`, `updateName`, `updateImage`, `enableSlowMode` and `disableSlowMode` on `Channel`, return a `Result` instead of throwing. - `PartialUpdateChannelResponse` is renamed `UpdateChannelPartialResponse`, and `PartialUpdateMemberResponse` is renamed `UpdateMemberPartialResponse`. -- `StreamChatClient.partialMemberUpdate` is renamed `updateMemberPartial`. +- `StreamChatClient.partialMemberUpdate` is renamed `updateMemberPartial`, and `enableSlowdown` and `disableSlowdown` are renamed `enableSlowMode` and `disableSlowMode`. - `updateMemberPartial`, `pinChannel`, `unpinChannel`, `archiveChannel` and `unarchiveChannel` on `StreamChatClient`, and `pin`, `unpin`, `archive` and `unarchive` on `Channel`, return a `Result` instead of throwing; the `Channel` methods no longer return the bare `Member`. - `UpdateChannelPartialResponse` and `UpdateMemberPartialResponse` no longer decode from JSON, are immutable, built through a const constructor, compare by value, gain `copyWith`, and their `duration` is a non-nullable `String`. - `UpdateChannelPartialResponse.channel` and `UpdateMemberPartialResponse.channelMember` are nullable, and `UpdateChannelPartialResponse.members` is a non-nullable list. diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index 06e4a6d226..e44c5d04df 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -1722,13 +1722,13 @@ class Channel { required int cooldownInterval, }) async { _checkInitialized(); - return _client.enableSlowdown(id!, type, cooldownInterval); + return _client.enableSlowMode(id!, type, cooldownInterval); } /// Turns off slow mode for this channel. Future> disableSlowMode() async { _checkInitialized(); - return _client.disableSlowdown(id!, type); + return _client.disableSlowMode(id!, type); } /// Delete this channel. Messages are permanently removed. diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index c6dbdd40fe..1723377769 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -2137,7 +2137,7 @@ class StreamChatClient { /// Turns on slow mode for the channel [channelId] of type [channelType], so each member waits [cooldown] seconds /// between messages. - Future> enableSlowdown( + Future> enableSlowMode( String channelId, String channelType, int cooldown, @@ -2148,7 +2148,7 @@ class StreamChatClient { ); /// Turns off slow mode for the channel [channelId] of type [channelType]. - Future> disableSlowdown( + Future> disableSlowMode( String channelId, String channelType, ) => _channelsRepository.updateChannelPartial( diff --git a/packages/stream_chat/test/src/client/channel/channel_test.dart b/packages/stream_chat/test/src/client/channel/channel_test.dart index b7a8639409..03f7f11d42 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -4699,7 +4699,7 @@ void main() { ).called(1); }); - test("Channel.enableSlowMode returns the client's enableSlowdown response", () async { + test("Channel.enableSlowMode returns the client's enableSlowMode response", () async { const cooldown = 10; final channelModel = ChannelModel( @@ -4708,7 +4708,7 @@ void main() { ); when( - () => client.enableSlowdown( + () => client.enableSlowMode( channelId, channelType, cooldown, @@ -4722,7 +4722,7 @@ void main() { expect(res.getOrNull(), UpdateChannelPartialResponse(duration: '0.01ms', channel: channelModel)); verify( - () => client.enableSlowdown( + () => client.enableSlowMode( channelId, channelType, cooldown, @@ -4730,13 +4730,13 @@ void main() { ).called(1); }); - test("Channel.disableSlowMode returns the client's disableSlowdown response", () async { + test("Channel.disableSlowMode returns the client's disableSlowMode response", () async { final channelModel = ChannelModel( cid: channelCid, ); when( - () => client.disableSlowdown( + () => client.disableSlowMode( channelId, channelType, ), @@ -4748,7 +4748,7 @@ void main() { expect(res.getOrNull(), UpdateChannelPartialResponse(duration: '0.01ms', channel: channelModel)); - verify(() => client.disableSlowdown(channelId, channelType)).called(1); + verify(() => client.disableSlowMode(channelId, channelType)).called(1); }); test('`.banMember` scopes the ban to the channel cid', () async { diff --git a/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart b/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart index 089a00821c..173e1ccbba 100644 --- a/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart +++ b/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart @@ -202,11 +202,11 @@ void main() { expect(res.exceptionOrNull(), error); }); - test('StreamChatClient.enableSlowdown sends the cooldown and returns a success', () async { + test('StreamChatClient.enableSlowMode sends the cooldown and returns a success', () async { final defaultApi = _defaultApiAnswering(Result.success(_updateChannelPartialResponse())); final client = _client(defaultApi); - final res = await client.enableSlowdown('general', 'messaging', 30); + final res = await client.enableSlowMode('general', 'messaging', 30); expect(res.isSuccess, isTrue); @@ -220,20 +220,20 @@ void main() { verifyNoMoreInteractions(defaultApi); }); - test('StreamChatClient.enableSlowdown returns the failure without throwing', () async { + test('StreamChatClient.enableSlowMode returns the failure without throwing', () async { const error = StreamClientException(message: 'boom'); final client = _client(_defaultApiAnswering(const Result.failure(error))); - final res = await client.enableSlowdown('general', 'messaging', 30); + final res = await client.enableSlowMode('general', 'messaging', 30); expect(res.exceptionOrNull(), error); }); - test('StreamChatClient.disableSlowdown sends the cooldown unset and returns a success', () async { + test('StreamChatClient.disableSlowMode sends the cooldown unset and returns a success', () async { final defaultApi = _defaultApiAnswering(Result.success(_updateChannelPartialResponse())); final client = _client(defaultApi); - final res = await client.disableSlowdown('general', 'messaging'); + final res = await client.disableSlowMode('general', 'messaging'); expect(res.isSuccess, isTrue); @@ -247,11 +247,11 @@ void main() { verifyNoMoreInteractions(defaultApi); }); - test('StreamChatClient.disableSlowdown returns the failure without throwing', () async { + test('StreamChatClient.disableSlowMode returns the failure without throwing', () async { const error = StreamClientException(message: 'boom'); final client = _client(_defaultApiAnswering(const Result.failure(error))); - final res = await client.disableSlowdown('general', 'messaging'); + final res = await client.disableSlowMode('general', 'messaging'); expect(res.exceptionOrNull(), error); }); From 18bae3d74a930f51b91088c877f8b06a7f5e5038 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 5 Oct 2026 16:30:23 +0200 Subject: [PATCH 14/32] docs(llc): simplify the partial update and slow mode docs Co-Authored-By: Claude Opus 5.5 --- .../lib/src/client/channel/channel.dart | 4 +-- .../stream_chat/lib/src/client/client.dart | 30 +++++++------------ .../src/repository/channels_repository.dart | 11 ++++--- 3 files changed, 18 insertions(+), 27 deletions(-) diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index e44c5d04df..927670bdd1 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -1717,7 +1717,7 @@ class Channel { return _client.updateChannelPartial(id!, type, set: set, unset: unset); } - /// Turns on slow mode for this channel, so each member waits [cooldownInterval] seconds between messages. + /// Enables slow mode on this channel, so members wait [cooldownInterval] seconds between messages. Future> enableSlowMode({ required int cooldownInterval, }) async { @@ -1725,7 +1725,7 @@ class Channel { return _client.enableSlowMode(id!, type, cooldownInterval); } - /// Turns off slow mode for this channel. + /// Disables slow mode on this channel. Future> disableSlowMode() async { _checkInitialized(); return _client.disableSlowMode(id!, type); diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 1723377769..49d2ff4e92 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -1243,8 +1243,8 @@ class StreamChatClient { message: message, ); - /// Sets the fields in [set] and removes the fields named in [unset] on the channel [channelId] of type - /// [channelType], leaving every other field as it is. + /// Partially updates a channel: sets the fields in [set] and removes the fields named in [unset], leaving every + /// other field as it is. /// /// At least one of [set] and [unset] is required. /// @@ -2135,8 +2135,7 @@ class StreamChatClient { ); } - /// Turns on slow mode for the channel [channelId] of type [channelType], so each member waits [cooldown] seconds - /// between messages. + /// Enables slow mode on a channel, so members wait [cooldown] seconds between messages. Future> enableSlowMode( String channelId, String channelType, @@ -2147,7 +2146,7 @@ class StreamChatClient { set: {'cooldown': cooldown}, ); - /// Turns off slow mode for the channel [channelId] of type [channelType]. + /// Disables slow mode on a channel. Future> disableSlowMode( String channelId, String channelType, @@ -2250,7 +2249,7 @@ class StreamChatClient { unset: unset, ); - /// Pins the channel [channelId] of type [channelType] for the current user. + /// Pins the channel for the current user. Future> pinChannel({ required String channelId, required String channelType, @@ -2262,7 +2261,7 @@ class StreamChatClient { ); } - /// Unpins the channel [channelId] of type [channelType] for the current user. + /// Unpins the channel for the current user. Future> unpinChannel({ required String channelId, required String channelType, @@ -2274,7 +2273,7 @@ class StreamChatClient { ); } - /// Archives the channel [channelId] of type [channelType] for the current user. + /// Archives the channel for the current user. Future> archiveChannel({ required String channelId, required String channelType, @@ -2286,7 +2285,7 @@ class StreamChatClient { ); } - /// Unarchives the channel [channelId] of type [channelType] for the current user. + /// Unarchives the channel for the current user. Future> unarchiveChannel({ required String channelId, required String channelType, @@ -2298,17 +2297,10 @@ class StreamChatClient { ); } - /// Sets the fields in [set] and removes the fields named in [unset] on the current user's membership of the - /// channel [channelId] of type [channelType], leaving every other field as it is. + /// Partially updates the current user's membership of a channel: sets the fields in [set] and removes the fields + /// named in [unset], leaving every other field as it is. /// - /// `pinned` and `archived` pin and archive the channel for the current user, as in `set: {'pinned': true}` or - /// `unset: ['archived']`; other keys set custom data on the membership. At least one of [set] and - /// [unset] is required. - /// - /// See also: - /// - /// * [pinChannel] and [unpinChannel], which pin and unpin the channel. - /// * [archiveChannel] and [unarchiveChannel], which archive and unarchive it. + /// At least one of [set] and [unset] is required. Future> updateMemberPartial({ required String channelId, required String channelType, diff --git a/packages/stream_chat/lib/src/repository/channels_repository.dart b/packages/stream_chat/lib/src/repository/channels_repository.dart index 91f418578e..3bf0d558fe 100644 --- a/packages/stream_chat/lib/src/repository/channels_repository.dart +++ b/packages/stream_chat/lib/src/repository/channels_repository.dart @@ -12,8 +12,8 @@ class ChannelsRepository { final api.DefaultApi _api; - /// Sets the fields in [set] and removes the fields named in [unset] on the channel [channelId] of type - /// [channelType], leaving every other field as it is. + /// Partially updates a channel: sets the fields in [set] and removes the fields named in [unset], leaving every + /// other field as it is. /// /// At least one of [set] and [unset] is required. Future> updateChannelPartial( @@ -31,11 +31,10 @@ class ChannelsRepository { return result.map((response) => response.toModel()); } - /// Sets the fields in [set] and removes the fields named in [unset] on the current user's membership of the - /// channel [channelId] of type [channelType], leaving every other field as it is. + /// Partially updates the current user's membership of a channel: sets the fields in [set] and removes the fields + /// named in [unset], leaving every other field as it is. /// - /// `pinned` and `archived` pin and archive the channel for the current user; other keys set custom data on the - /// membership. At least one of [set] and [unset] is required. + /// At least one of [set] and [unset] is required. Future> updateMemberPartial( String channelId, String channelType, { From 60f73d66289fa990c8a1e4183af56d422f3a2512 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 5 Oct 2026 16:37:22 +0200 Subject: [PATCH 15/32] docs(llc): restore the extraData compatibility comments Co-Authored-By: Claude Opus 5.5 --- packages/stream_chat/lib/src/core/models/channel_model.dart | 3 ++- packages/stream_chat/lib/src/core/models/member.dart | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/stream_chat/lib/src/core/models/channel_model.dart b/packages/stream_chat/lib/src/core/models/channel_model.dart index db8f265b1d..d819422c3c 100644 --- a/packages/stream_chat/lib/src/core/models/channel_model.dart +++ b/packages/stream_chat/lib/src/core/models/channel_model.dart @@ -54,7 +54,8 @@ class ChannelModel { updatedAt = updatedAt ?? DateTime.now(), ownCapabilities = ownCapabilities?.map(ChannelCapability.new).toList(), - // These fields live in [extraData], where [ChannelModel.fromJson] leaves them, rather than in fields of their own. + // For backwards compatibility, set 'disabled', 'hidden', 'muted', 'blocked', 'truncated_at', 'truncated_by', + // 'auto_translation_enabled' and 'auto_translation_language' in [extraData]. extraData = { ...extraData, if (disabled != null) 'disabled': disabled, diff --git a/packages/stream_chat/lib/src/core/models/member.dart b/packages/stream_chat/lib/src/core/models/member.dart index 2eeaff6f32..e0dae9ae13 100644 --- a/packages/stream_chat/lib/src/core/models/member.dart +++ b/packages/stream_chat/lib/src/core/models/member.dart @@ -42,7 +42,8 @@ class Member extends Equatable { }) : userId = userId ?? user?.id, createdAt = createdAt ?? DateTime.now(), updatedAt = updatedAt ?? DateTime.now(), - // These fields live in [extraData], where [Member.fromJson] leaves them, rather than in fields of their own. + // For backwards compatibility, set 'notifications_muted', 'status', 'ban_from_future_channels', + // 'future_channel_ban_expires' and 'deleted_at' in [extraData]. extraData = { ...extraData, if (notificationsMuted != null) 'notifications_muted': notificationsMuted, From 704e5088ea5f99f850936ff0b80804bb00297d7f Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 5 Oct 2026 16:42:07 +0200 Subject: [PATCH 16/32] test(llc): stub the exact partial update requests Co-Authored-By: Claude Opus 5.5 --- .../client_update_channel_partial_test.dart | 143 +++++++++++----- .../client_update_member_partial_test.dart | 154 ++++++++++++------ 2 files changed, 200 insertions(+), 97 deletions(-) diff --git a/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart b/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart index 173e1ccbba..e03c3ef440 100644 --- a/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart +++ b/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart @@ -5,7 +5,6 @@ import 'package:test/test.dart'; import '../fakes.dart'; import '../mocks.dart'; -import '../ws/fake_chat_server.dart'; void main() { setUpAll(() => registerFallbackValue(const api.UpdateChannelPartialRequest())); @@ -13,7 +12,12 @@ void main() { test( 'StreamChatClient.updateChannelPartial sends the set and unset fields and returns the updated members', () async { - final defaultApi = _defaultApiAnswering(Result.success(_updateChannelPartialResponse())); + const request = api.UpdateChannelPartialRequest(set: {'name': 'General'}, unset: ['topic']); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateChannelPartial(type: 'messaging', id: 'general', updateChannelPartialRequest: request), + ).thenAnswer((_) async => Result.success(_updateChannelPartialResponse())); final client = _client(defaultApi); final res = await client.updateChannelPartial( @@ -26,21 +30,20 @@ void main() { expect(res.getOrNull()!.duration, '0.01ms'); expect(res.getOrNull()!.members, [fakeChannelMember()]); verify( - () => defaultApi.updateChannelPartial( - type: 'messaging', - id: 'general', - updateChannelPartialRequest: const api.UpdateChannelPartialRequest( - set: {'name': 'General'}, - unset: ['topic'], - ), - ), + () => defaultApi.updateChannelPartial(type: 'messaging', id: 'general', updateChannelPartialRequest: request), ).called(1); verifyNoMoreInteractions(defaultApi); }, ); test('StreamChatClient.updateChannelPartial returns the channel with every field it models', () async { - final client = _client(_defaultApiAnswering(Result.success(_updateChannelPartialResponse()))); + const request = api.UpdateChannelPartialRequest(set: {'name': 'General'}); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateChannelPartial(type: 'messaging', id: 'general', updateChannelPartialRequest: request), + ).thenAnswer((_) async => Result.success(_updateChannelPartialResponse())); + final client = _client(defaultApi); final res = await client.updateChannelPartial('general', 'messaging', set: const {'name': 'General'}); @@ -72,7 +75,13 @@ void main() { }); test('StreamChatClient.updateChannelPartial returns the channel name and image from its custom data', () async { - final client = _client(_defaultApiAnswering(Result.success(_updateChannelPartialResponse()))); + const request = api.UpdateChannelPartialRequest(set: {'name': 'General'}); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateChannelPartial(type: 'messaging', id: 'general', updateChannelPartialRequest: request), + ).thenAnswer((_) async => Result.success(_updateChannelPartialResponse())); + final client = _client(defaultApi); final res = await client.updateChannelPartial('general', 'messaging', set: const {'name': 'General'}); @@ -82,7 +91,13 @@ void main() { }); test('StreamChatClient.updateChannelPartial keeps the custom data and the server fields in extraData', () async { - final client = _client(_defaultApiAnswering(Result.success(_updateChannelPartialResponse()))); + const request = api.UpdateChannelPartialRequest(set: {'name': 'General'}); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateChannelPartial(type: 'messaging', id: 'general', updateChannelPartialRequest: request), + ).thenAnswer((_) async => Result.success(_updateChannelPartialResponse())); + final client = _client(defaultApi); final res = await client.updateChannelPartial('general', 'messaging', set: const {'name': 'General'}); @@ -105,7 +120,13 @@ void main() { final response = _updateChannelPartialResponse( channel: _channelResponse(custom: const {'hidden': 'shadowed', 'cid': 'shadowed'}), ); - final client = _client(_defaultApiAnswering(Result.success(response))); + const request = api.UpdateChannelPartialRequest(set: {'name': 'General'}); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateChannelPartial(type: 'messaging', id: 'general', updateChannelPartialRequest: request), + ).thenAnswer((_) async => Result.success(response)); + final client = _client(defaultApi); final res = await client.updateChannelPartial('general', 'messaging', set: const {'name': 'General'}); @@ -127,7 +148,13 @@ void main() { frozen: false, ), ); - final client = _client(_defaultApiAnswering(Result.success(response))); + const request = api.UpdateChannelPartialRequest(set: {'name': 'General'}); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateChannelPartial(type: 'messaging', id: 'general', updateChannelPartialRequest: request), + ).thenAnswer((_) async => Result.success(response)); + final client = _client(defaultApi); final res = await client.updateChannelPartial('general', 'messaging', set: const {'name': 'General'}); @@ -139,7 +166,13 @@ void main() { }); test('StreamChatClient.updateChannelPartial returns the channel config with every field it models', () async { - final client = _client(_defaultApiAnswering(Result.success(_updateChannelPartialResponse()))); + const request = api.UpdateChannelPartialRequest(set: {'name': 'General'}); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateChannelPartial(type: 'messaging', id: 'general', updateChannelPartialRequest: request), + ).thenAnswer((_) async => Result.success(_updateChannelPartialResponse())); + final client = _client(defaultApi); final res = await client.updateChannelPartial('general', 'messaging', set: const {'name': 'General'}); @@ -186,7 +219,13 @@ void main() { test('StreamChatClient.updateChannelPartial returns a null channel when the response has none', () async { const response = api.UpdateChannelPartialResponse(duration: '0.01ms', members: []); - final client = _client(_defaultApiAnswering(const Result.success(response))); + const request = api.UpdateChannelPartialRequest(set: {'name': 'General'}); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateChannelPartial(type: 'messaging', id: 'general', updateChannelPartialRequest: request), + ).thenAnswer((_) async => const Result.success(response)); + final client = _client(defaultApi); final res = await client.updateChannelPartial('general', 'messaging', set: const {'name': 'General'}); @@ -195,7 +234,15 @@ void main() { test('StreamChatClient.updateChannelPartial returns the failure without throwing', () async { const error = StreamClientException(message: 'boom'); - final client = _client(_defaultApiAnswering(const Result.failure(error))); + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateChannelPartial( + type: any(named: 'type'), + id: any(named: 'id'), + updateChannelPartialRequest: any(named: 'updateChannelPartialRequest'), + ), + ).thenAnswer((_) async => const Result.failure(error)); + final client = _client(defaultApi); final res = await client.updateChannelPartial('general', 'messaging', set: const {'name': 'General'}); @@ -203,7 +250,12 @@ void main() { }); test('StreamChatClient.enableSlowMode sends the cooldown and returns a success', () async { - final defaultApi = _defaultApiAnswering(Result.success(_updateChannelPartialResponse())); + const request = api.UpdateChannelPartialRequest(set: {'cooldown': 30}); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateChannelPartial(type: 'messaging', id: 'general', updateChannelPartialRequest: request), + ).thenAnswer((_) async => Result.success(_updateChannelPartialResponse())); final client = _client(defaultApi); final res = await client.enableSlowMode('general', 'messaging', 30); @@ -211,18 +263,22 @@ void main() { expect(res.isSuccess, isTrue); verify( - () => defaultApi.updateChannelPartial( - type: 'messaging', - id: 'general', - updateChannelPartialRequest: const api.UpdateChannelPartialRequest(set: {'cooldown': 30}), - ), + () => defaultApi.updateChannelPartial(type: 'messaging', id: 'general', updateChannelPartialRequest: request), ).called(1); verifyNoMoreInteractions(defaultApi); }); test('StreamChatClient.enableSlowMode returns the failure without throwing', () async { const error = StreamClientException(message: 'boom'); - final client = _client(_defaultApiAnswering(const Result.failure(error))); + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateChannelPartial( + type: any(named: 'type'), + id: any(named: 'id'), + updateChannelPartialRequest: any(named: 'updateChannelPartialRequest'), + ), + ).thenAnswer((_) async => const Result.failure(error)); + final client = _client(defaultApi); final res = await client.enableSlowMode('general', 'messaging', 30); @@ -230,7 +286,12 @@ void main() { }); test('StreamChatClient.disableSlowMode sends the cooldown unset and returns a success', () async { - final defaultApi = _defaultApiAnswering(Result.success(_updateChannelPartialResponse())); + const request = api.UpdateChannelPartialRequest(unset: ['cooldown']); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateChannelPartial(type: 'messaging', id: 'general', updateChannelPartialRequest: request), + ).thenAnswer((_) async => Result.success(_updateChannelPartialResponse())); final client = _client(defaultApi); final res = await client.disableSlowMode('general', 'messaging'); @@ -238,18 +299,22 @@ void main() { expect(res.isSuccess, isTrue); verify( - () => defaultApi.updateChannelPartial( - type: 'messaging', - id: 'general', - updateChannelPartialRequest: const api.UpdateChannelPartialRequest(unset: ['cooldown']), - ), + () => defaultApi.updateChannelPartial(type: 'messaging', id: 'general', updateChannelPartialRequest: request), ).called(1); verifyNoMoreInteractions(defaultApi); }); test('StreamChatClient.disableSlowMode returns the failure without throwing', () async { const error = StreamClientException(message: 'boom'); - final client = _client(_defaultApiAnswering(const Result.failure(error))); + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateChannelPartial( + type: any(named: 'type'), + id: any(named: 'id'), + updateChannelPartialRequest: any(named: 'updateChannelPartialRequest'), + ), + ).thenAnswer((_) async => const Result.failure(error)); + final client = _client(defaultApi); final res = await client.disableSlowMode('general', 'messaging'); @@ -258,23 +323,11 @@ void main() { } StreamChatClient _client(api.DefaultApi defaultApi) { - final client = StreamChatClient('test-api-key', defaultApi: defaultApi, wsProvider: FakeChatServer().connect); + final client = StreamChatClient('test-api-key', defaultApi: defaultApi); addTearDown(client.dispose); return client; } -MockDefaultApi _defaultApiAnswering(Result result) { - final defaultApi = MockDefaultApi(); - when( - () => defaultApi.updateChannelPartial( - type: any(named: 'type'), - id: any(named: 'id'), - updateChannelPartialRequest: any(named: 'updateChannelPartialRequest'), - ), - ).thenAnswer((_) async => result); - return defaultApi; -} - // A response with every field set. api.UpdateChannelPartialResponse _updateChannelPartialResponse({ api.ChannelResponse? channel, diff --git a/packages/stream_chat/test/src/client/client_update_member_partial_test.dart b/packages/stream_chat/test/src/client/client_update_member_partial_test.dart index f59b392a65..156f29a20e 100644 --- a/packages/stream_chat/test/src/client/client_update_member_partial_test.dart +++ b/packages/stream_chat/test/src/client/client_update_member_partial_test.dart @@ -5,13 +5,17 @@ import 'package:test/test.dart'; import '../fakes.dart'; import '../mocks.dart'; -import '../ws/fake_chat_server.dart'; void main() { setUpAll(() => registerFallbackValue(const api.UpdateMemberPartialRequest())); test('StreamChatClient.updateMemberPartial sends the set and unset fields and returns the updated member', () async { - final defaultApi = _defaultApiAnswering(Result.success(_updateMemberPartialResponse())); + const request = api.UpdateMemberPartialRequest(set: {'nickname': 'Mo'}, unset: ['status']); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateMemberPartial(type: 'messaging', id: 'general', updateMemberPartialRequest: request), + ).thenAnswer((_) async => Result.success(_updateMemberPartialResponse())); final client = _client(defaultApi); final res = await client.updateMemberPartial( @@ -23,18 +27,20 @@ void main() { expect(res.getOrNull(), UpdateMemberPartialResponse(duration: '0.01ms', channelMember: fakeChannelMember())); verify( - () => defaultApi.updateMemberPartial( - type: 'messaging', - id: 'general', - updateMemberPartialRequest: const api.UpdateMemberPartialRequest(set: {'nickname': 'Mo'}, unset: ['status']), - ), + () => defaultApi.updateMemberPartial(type: 'messaging', id: 'general', updateMemberPartialRequest: request), ).called(1); verifyNoMoreInteractions(defaultApi); }); test('StreamChatClient.updateMemberPartial returns a null member when the response has none', () async { const response = api.UpdateMemberPartialResponse(duration: '0.01ms'); - final client = _client(_defaultApiAnswering(const Result.success(response))); + const request = api.UpdateMemberPartialRequest(set: {'a': 1}); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateMemberPartial(type: 'messaging', id: 'general', updateMemberPartialRequest: request), + ).thenAnswer((_) async => const Result.success(response)); + final client = _client(defaultApi); final res = await client.updateMemberPartial(channelId: 'general', channelType: 'messaging', set: const {'a': 1}); @@ -43,7 +49,15 @@ void main() { test('StreamChatClient.updateMemberPartial returns the failure without throwing', () async { const error = StreamClientException(message: 'boom'); - final client = _client(_defaultApiAnswering(const Result.failure(error))); + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateMemberPartial( + type: any(named: 'type'), + id: any(named: 'id'), + updateMemberPartialRequest: any(named: 'updateMemberPartialRequest'), + ), + ).thenAnswer((_) async => const Result.failure(error)); + final client = _client(defaultApi); final res = await client.updateMemberPartial(channelId: 'general', channelType: 'messaging', set: const {'a': 1}); @@ -55,7 +69,13 @@ void main() { duration: '0.01ms', channelMember: fakeChannelMemberResponse(custom: const {'status': 'shadowed', 'user_id': 'shadowed'}), ); - final client = _client(_defaultApiAnswering(Result.success(response))); + const request = api.UpdateMemberPartialRequest(set: {'a': 1}); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateMemberPartial(type: 'messaging', id: 'general', updateMemberPartialRequest: request), + ).thenAnswer((_) async => Result.success(response)); + final client = _client(defaultApi); final res = await client.updateMemberPartial(channelId: 'general', channelType: 'messaging', set: const {'a': 1}); @@ -77,7 +97,13 @@ void main() { shadowBanned: false, ), ); - final client = _client(_defaultApiAnswering(Result.success(response))); + const request = api.UpdateMemberPartialRequest(set: {'a': 1}); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateMemberPartial(type: 'messaging', id: 'general', updateMemberPartialRequest: request), + ).thenAnswer((_) async => Result.success(response)); + final client = _client(defaultApi); final res = await client.updateMemberPartial(channelId: 'general', channelType: 'messaging', set: const {'a': 1}); @@ -88,25 +114,34 @@ void main() { }); test('StreamChatClient.pinChannel sends the pinned flag and returns the updated member', () async { - final defaultApi = _defaultApiAnswering(Result.success(_updateMemberPartialResponse())); + const request = api.UpdateMemberPartialRequest(set: {'pinned': true}); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateMemberPartial(type: 'messaging', id: 'general', updateMemberPartialRequest: request), + ).thenAnswer((_) async => Result.success(_updateMemberPartialResponse())); final client = _client(defaultApi); final res = await client.pinChannel(channelId: 'general', channelType: 'messaging'); expect(res.getOrNull(), UpdateMemberPartialResponse(duration: '0.01ms', channelMember: fakeChannelMember())); verify( - () => defaultApi.updateMemberPartial( - type: 'messaging', - id: 'general', - updateMemberPartialRequest: const api.UpdateMemberPartialRequest(set: {'pinned': true}), - ), + () => defaultApi.updateMemberPartial(type: 'messaging', id: 'general', updateMemberPartialRequest: request), ).called(1); verifyNoMoreInteractions(defaultApi); }); test('StreamChatClient.pinChannel returns the failure without throwing', () async { const error = StreamClientException(message: 'boom'); - final client = _client(_defaultApiAnswering(const Result.failure(error))); + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateMemberPartial( + type: any(named: 'type'), + id: any(named: 'id'), + updateMemberPartialRequest: any(named: 'updateMemberPartialRequest'), + ), + ).thenAnswer((_) async => const Result.failure(error)); + final client = _client(defaultApi); final res = await client.pinChannel(channelId: 'general', channelType: 'messaging'); @@ -114,25 +149,34 @@ void main() { }); test('StreamChatClient.unpinChannel sends the pinned flag unset and returns the updated member', () async { - final defaultApi = _defaultApiAnswering(Result.success(_updateMemberPartialResponse())); + const request = api.UpdateMemberPartialRequest(unset: ['pinned']); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateMemberPartial(type: 'messaging', id: 'general', updateMemberPartialRequest: request), + ).thenAnswer((_) async => Result.success(_updateMemberPartialResponse())); final client = _client(defaultApi); final res = await client.unpinChannel(channelId: 'general', channelType: 'messaging'); expect(res.getOrNull(), UpdateMemberPartialResponse(duration: '0.01ms', channelMember: fakeChannelMember())); verify( - () => defaultApi.updateMemberPartial( - type: 'messaging', - id: 'general', - updateMemberPartialRequest: const api.UpdateMemberPartialRequest(unset: ['pinned']), - ), + () => defaultApi.updateMemberPartial(type: 'messaging', id: 'general', updateMemberPartialRequest: request), ).called(1); verifyNoMoreInteractions(defaultApi); }); test('StreamChatClient.unpinChannel returns the failure without throwing', () async { const error = StreamClientException(message: 'boom'); - final client = _client(_defaultApiAnswering(const Result.failure(error))); + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateMemberPartial( + type: any(named: 'type'), + id: any(named: 'id'), + updateMemberPartialRequest: any(named: 'updateMemberPartialRequest'), + ), + ).thenAnswer((_) async => const Result.failure(error)); + final client = _client(defaultApi); final res = await client.unpinChannel(channelId: 'general', channelType: 'messaging'); @@ -140,25 +184,34 @@ void main() { }); test('StreamChatClient.archiveChannel sends the archived flag and returns the updated member', () async { - final defaultApi = _defaultApiAnswering(Result.success(_updateMemberPartialResponse())); + const request = api.UpdateMemberPartialRequest(set: {'archived': true}); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateMemberPartial(type: 'messaging', id: 'general', updateMemberPartialRequest: request), + ).thenAnswer((_) async => Result.success(_updateMemberPartialResponse())); final client = _client(defaultApi); final res = await client.archiveChannel(channelId: 'general', channelType: 'messaging'); expect(res.getOrNull(), UpdateMemberPartialResponse(duration: '0.01ms', channelMember: fakeChannelMember())); verify( - () => defaultApi.updateMemberPartial( - type: 'messaging', - id: 'general', - updateMemberPartialRequest: const api.UpdateMemberPartialRequest(set: {'archived': true}), - ), + () => defaultApi.updateMemberPartial(type: 'messaging', id: 'general', updateMemberPartialRequest: request), ).called(1); verifyNoMoreInteractions(defaultApi); }); test('StreamChatClient.archiveChannel returns the failure without throwing', () async { const error = StreamClientException(message: 'boom'); - final client = _client(_defaultApiAnswering(const Result.failure(error))); + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateMemberPartial( + type: any(named: 'type'), + id: any(named: 'id'), + updateMemberPartialRequest: any(named: 'updateMemberPartialRequest'), + ), + ).thenAnswer((_) async => const Result.failure(error)); + final client = _client(defaultApi); final res = await client.archiveChannel(channelId: 'general', channelType: 'messaging'); @@ -166,25 +219,34 @@ void main() { }); test('StreamChatClient.unarchiveChannel sends the archived flag unset and returns the updated member', () async { - final defaultApi = _defaultApiAnswering(Result.success(_updateMemberPartialResponse())); + const request = api.UpdateMemberPartialRequest(unset: ['archived']); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateMemberPartial(type: 'messaging', id: 'general', updateMemberPartialRequest: request), + ).thenAnswer((_) async => Result.success(_updateMemberPartialResponse())); final client = _client(defaultApi); final res = await client.unarchiveChannel(channelId: 'general', channelType: 'messaging'); expect(res.getOrNull(), UpdateMemberPartialResponse(duration: '0.01ms', channelMember: fakeChannelMember())); verify( - () => defaultApi.updateMemberPartial( - type: 'messaging', - id: 'general', - updateMemberPartialRequest: const api.UpdateMemberPartialRequest(unset: ['archived']), - ), + () => defaultApi.updateMemberPartial(type: 'messaging', id: 'general', updateMemberPartialRequest: request), ).called(1); verifyNoMoreInteractions(defaultApi); }); test('StreamChatClient.unarchiveChannel returns the failure without throwing', () async { const error = StreamClientException(message: 'boom'); - final client = _client(_defaultApiAnswering(const Result.failure(error))); + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateMemberPartial( + type: any(named: 'type'), + id: any(named: 'id'), + updateMemberPartialRequest: any(named: 'updateMemberPartialRequest'), + ), + ).thenAnswer((_) async => const Result.failure(error)); + final client = _client(defaultApi); final res = await client.unarchiveChannel(channelId: 'general', channelType: 'messaging'); @@ -193,23 +255,11 @@ void main() { } StreamChatClient _client(api.DefaultApi defaultApi) { - final client = StreamChatClient('test-api-key', defaultApi: defaultApi, wsProvider: FakeChatServer().connect); + final client = StreamChatClient('test-api-key', defaultApi: defaultApi); addTearDown(client.dispose); return client; } -MockDefaultApi _defaultApiAnswering(Result result) { - final defaultApi = MockDefaultApi(); - when( - () => defaultApi.updateMemberPartial( - type: any(named: 'type'), - id: any(named: 'id'), - updateMemberPartialRequest: any(named: 'updateMemberPartialRequest'), - ), - ).thenAnswer((_) async => result); - return defaultApi; -} - // A response with every field set. api.UpdateMemberPartialResponse _updateMemberPartialResponse() => api.UpdateMemberPartialResponse(duration: '0.01ms', channelMember: fakeChannelMemberResponse()); From 881d3b9a6a2bef6e38de1c141d5d25d3095d5243 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 9 Oct 2026 16:24:01 +0200 Subject: [PATCH 17/32] refactor(llc): name the mappers and their files consistently Co-Authored-By: Claude Opus 5.5 --- .../stream_chat/lib/src/repository/channels_repository.dart | 2 +- .../mapper/{channel_mapper.dart => channels_mapper.dart} | 0 .../lib/src/repository/mapper/devices_mapper.dart | 4 ++-- .../lib/src/repository/mapper/moderation_mapper.dart | 6 +++--- .../stream_chat/lib/src/repository/mapper/roles_mapper.dart | 2 +- .../lib/src/repository/mapper/user_groups_mapper.dart | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) rename packages/stream_chat/lib/src/repository/mapper/{channel_mapper.dart => channels_mapper.dart} (100%) diff --git a/packages/stream_chat/lib/src/repository/channels_repository.dart b/packages/stream_chat/lib/src/repository/channels_repository.dart index 3bf0d558fe..2d0e995089 100644 --- a/packages/stream_chat/lib/src/repository/channels_repository.dart +++ b/packages/stream_chat/lib/src/repository/channels_repository.dart @@ -3,7 +3,7 @@ import 'package:stream_core/stream_core.dart' show PatternMatching, Result; import '../../open_api/api.dart' as api; import '../core/models/response/update_channel_partial_response.dart'; import '../core/models/response/update_member_partial_response.dart'; -import 'mapper/channel_mapper.dart'; +import 'mapper/channels_mapper.dart'; /// Repository dedicated to channel operations. class ChannelsRepository { diff --git a/packages/stream_chat/lib/src/repository/mapper/channel_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/channels_mapper.dart similarity index 100% rename from packages/stream_chat/lib/src/repository/mapper/channel_mapper.dart rename to packages/stream_chat/lib/src/repository/mapper/channels_mapper.dart diff --git a/packages/stream_chat/lib/src/repository/mapper/devices_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/devices_mapper.dart index 0de090a582..fb340e5c80 100644 --- a/packages/stream_chat/lib/src/repository/mapper/devices_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/devices_mapper.dart @@ -18,8 +18,8 @@ extension ListDevicesResponseMapper on api.ListDevicesResponse { ); } -/// Maps a [PushProvider] to the generated request enum. -extension PushProviderMapper on PushProvider { +/// Maps a [PushProvider] to the generated [api.CreateDeviceRequestPushProvider]. +extension PushProviderRequestMapper on PushProvider { /// Converts this provider into an [api.CreateDeviceRequestPushProvider]. api.CreateDeviceRequestPushProvider toRequest() => api.CreateDeviceRequestPushProvider.fromJson(rawType); } diff --git a/packages/stream_chat/lib/src/repository/mapper/moderation_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/moderation_mapper.dart index bc55e12eb3..fd756d53a0 100644 --- a/packages/stream_chat/lib/src/repository/mapper/moderation_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/moderation_mapper.dart @@ -31,8 +31,8 @@ extension FlagItemResponseMapper on api.FlagItemResponse { ); } -/// Maps a [DeleteType] to the generated request type. -extension DeleteTypeMapper on DeleteType { - /// Converts this treatment into an [api.BanRequestDeleteMessages]. +/// Maps a [DeleteType] to the generated [api.BanRequestDeleteMessages]. +extension DeleteTypeRequestMapper on DeleteType { + /// Converts this delete type into an [api.BanRequestDeleteMessages]. api.BanRequestDeleteMessages toRequest() => api.BanRequestDeleteMessages.fromJson(rawType); } diff --git a/packages/stream_chat/lib/src/repository/mapper/roles_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/roles_mapper.dart index d6c9609701..8059e95dfb 100644 --- a/packages/stream_chat/lib/src/repository/mapper/roles_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/roles_mapper.dart @@ -4,7 +4,7 @@ import '../../core/models/role.dart'; /// Maps a generated [api.Role] to a [Role]. extension RoleMapper on api.Role { - /// Converts this generated role into a [Role]. + /// Converts this role into a [Role]. Role toModel() => Role( createdAt: createdAt, custom: custom, diff --git a/packages/stream_chat/lib/src/repository/mapper/user_groups_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/user_groups_mapper.dart index 4788c03e18..384d01d086 100644 --- a/packages/stream_chat/lib/src/repository/mapper/user_groups_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/user_groups_mapper.dart @@ -28,7 +28,7 @@ extension UserGroupResponseMapper on api.UserGroupResponse { /// Maps a generated [api.UserGroupMember] to a [UserGroupMember]. extension UserGroupMemberMapper on api.UserGroupMember { - /// Converts this generated member into a [UserGroupMember]. + /// Converts this member into a [UserGroupMember]. UserGroupMember toModel() => UserGroupMember( createdAt: createdAt, groupId: groupId, From bf016c042c46b8651325fb8673a1c84e87cf89b3 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 9 Oct 2026 16:41:40 +0200 Subject: [PATCH 18/32] refactor(llc): leave custom data named like a poll field out of the poll requests Co-Authored-By: Claude Opus 5.5 --- openapi-migration/05-polls.md | 2 + openapi-migration/tool/generate_plan.py | 2 + packages/stream_chat/CHANGELOG.md | 1 + .../src/repository/mapper/polls_mapper.dart | 41 ++++++++++++------- .../src/repository/mapper/sort_mapper.dart | 2 +- .../test/src/client/client_test.dart | 9 ++-- 6 files changed, 37 insertions(+), 20 deletions(-) diff --git a/openapi-migration/05-polls.md b/openapi-migration/05-polls.md index f06156384c..db1210d227 100644 --- a/openapi-migration/05-polls.md +++ b/openapi-migration/05-polls.md @@ -67,6 +67,8 @@ - **An answer's `optionId` stays `""`,** as the server sends it and v10 decoded it. - **`custom` becomes `extraData` without the keys named after the model's own fields** (`Poll.topLevelFields`, `PollOption.topLevelFields`), matching v1's flat JSON, which shadowed them. + The requests leave the same keys out of `custom`, as every request mapper does: v2 stores them as + custom data, or drops them on update, where v10's flattened body set the field. - **Every public poll method returns a `Result`,** on `StreamChatClient` and `Channel`. `deletePoll` and `deletePollOption` answer `DurationResponse` and return `Result`. `closePoll` stays a `partialUpdatePoll` that sets `is_closed`. diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 5f278d93d9..f648d0191b 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -347,6 +347,8 @@ def match(path): - **An answer's `optionId` stays `""`,** as the server sends it and v10 decoded it. - **`custom` becomes `extraData` without the keys named after the model's own fields** (`Poll.topLevelFields`, `PollOption.topLevelFields`), matching v1's flat JSON, which shadowed them. + The requests leave the same keys out of `custom`, as every request mapper does: v2 stores them as + custom data, or drops them on update, where v10's flattened body set the field. - **Every public poll method returns a `Result`,** on `StreamChatClient` and `Channel`. `deletePoll` and `deletePollOption` answer `DurationResponse` and return `Result`. `closePoll` stays a `partialUpdatePoll` that sets `is_closed`. diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 078baad51f..73fc336a15 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -87,6 +87,7 @@ - The poll responses are immutable, built through a const constructor, and their `duration` is a non-nullable `String`; `PollVoteResponse.vote` is nullable. - `Poll`, `PollOption` and `PollVote` no longer extend `Equatable`, so `props` is removed; they still compare by value, now including `extraData`. - `StreamChatApi.polls` is removed; its endpoints are reached through `StreamChatClient`. +- The poll and poll option requests no longer send `extraData` entries named like one of the poll's or option's own fields. - `sendImage`, `sendFile`, `uploadImage` and `uploadFile`, on `StreamChatClient`, `Channel` and `AttachmentFileUploader`, return a `Result` instead of throwing; read the URL from `fileUrl` and a video's thumbnail from `thumbUrl`. - `deleteImage`, `deleteFile`, `removeImage` and `removeFile` return a `Result` instead of throwing, and carry no value on success. - `SendAttachmentResponse`, `SendFileResponse`, `SendImageResponse`, `UploadImageResponse` and `UploadFileResponse` are removed in favor of `UploadedFile`. 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 d9d313b5ea..395ec57dae 100644 --- a/packages/stream_chat/lib/src/repository/mapper/polls_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/polls_mapper.dart @@ -12,10 +12,12 @@ import 'user_mapper.dart'; /// Maps a generated [api.PollResponseData] to a [Poll]. extension PollResponseDataMapper on api.PollResponseData { + // Custom keys named like one of the poll's own fields. + static const _shadowedCustomKeys = {...Poll.topLevelFields}; + /// Converts this poll into a [Poll]. /// - /// [custom] becomes [Poll.extraData], without the keys named after a field of the poll's own, so a custom field - /// never stands in for one of them. + /// Custom data named like one of the poll's own fields is left out of [Poll.extraData]. Poll toModel() => Poll( id: id, name: name, @@ -42,20 +44,23 @@ extension PollResponseDataMapper on api.PollResponseData { createdById: createdById, createdBy: createdBy?.toModel(), ownVotesAndAnswers: [for (final vote in ownVotes) vote.toModel()], - extraData: _withoutKeys(custom, Poll.topLevelFields), + extraData: {...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), ); } /// Maps a generated [api.PollOptionResponseData] to a [PollOption]. extension PollOptionResponseDataMapper on api.PollOptionResponseData { + // Custom keys named like one of the option's own fields. + static const _shadowedCustomKeys = {...PollOption.topLevelFields}; + /// Converts this option into a [PollOption]. /// - /// [custom] becomes [PollOption.extraData], without the keys named after a field of the option's own. + /// Custom data named like one of the option's own fields is left out of [PollOption.extraData]. PollOption toModel() => PollOption( id: id, text: text, textI18n: textI18n, - extraData: _withoutKeys(custom, PollOption.topLevelFields), + extraData: {...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), ); } @@ -117,7 +122,8 @@ extension PollVotesResponseMapper on api.PollVotesResponse { /// Maps a [Poll] to the generated requests that create and update it. /// -/// Only the settings of the poll are sent; the vote summary is not. +/// Only the settings of the poll are sent; the vote summary is not. Custom data named like one of the poll's own fields +/// is left out. extension PollRequestMapper on Poll { /// Converts this poll into an [api.CreatePollRequest]. /// @@ -133,7 +139,7 @@ extension PollRequestMapper on Poll { allowAnswers: allowAnswers, allowUserSuggestedOptions: allowUserSuggestedOptions, isClosed: isClosed, - custom: extraData, + custom: _customData, ); /// Converts this poll into an [api.UpdatePollRequest]. @@ -148,32 +154,37 @@ extension PollRequestMapper on Poll { allowAnswers: allowAnswers, allowUserSuggestedOptions: allowUserSuggestedOptions, isClosed: isClosed, - custom: extraData, + custom: _customData, ); + + Map get _customData => + {...extraData}..removeWhere((key, _) => PollResponseDataMapper._shadowedCustomKeys.contains(key)); } /// Maps a [PollOption] to the generated requests that create and update it. +/// +/// Custom data named like one of the option's own fields is left out. extension PollOptionRequestMapper on PollOption { /// Converts this option into an [api.PollOptionInput], an option of a poll being created. - api.PollOptionInput toPollOptionInput() => api.PollOptionInput(text: text, custom: extraData); + api.PollOptionInput toPollOptionInput() => api.PollOptionInput(text: text, custom: _customData); /// Converts this option into an [api.PollOptionRequest], an option of a poll being updated. /// /// An option without an [id] is sent with an empty one. - api.PollOptionRequest toPollOptionRequest() => api.PollOptionRequest(id: id ?? '', text: text, custom: extraData); + api.PollOptionRequest toPollOptionRequest() => api.PollOptionRequest(id: id ?? '', text: text, custom: _customData); /// Converts this option into an [api.CreatePollOptionRequest]. api.CreatePollOptionRequest toCreatePollOptionRequest() => api.CreatePollOptionRequest( text: text, - custom: extraData, + custom: _customData, ); /// Converts this option into an [api.UpdatePollOptionRequest]. /// /// An option without an [id] is sent with an empty one. api.UpdatePollOptionRequest toUpdatePollOptionRequest() => - api.UpdatePollOptionRequest(id: id ?? '', text: text, custom: extraData); -} + api.UpdatePollOptionRequest(id: id ?? '', text: text, custom: _customData); -Map _withoutKeys(Map custom, List keys) => - {...custom}..removeWhere((key, _) => keys.contains(key)); + Map get _customData => + {...extraData}..removeWhere((key, _) => PollOptionResponseDataMapper._shadowedCustomKeys.contains(key)); +} diff --git a/packages/stream_chat/lib/src/repository/mapper/sort_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/sort_mapper.dart index ff4ac63d50..e821224f31 100644 --- a/packages/stream_chat/lib/src/repository/mapper/sort_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/sort_mapper.dart @@ -3,7 +3,7 @@ import 'package:stream_core/stream_core.dart' show Sort; import '../../../open_api/api.dart' as api; /// Maps a [Sort] to the generated [api.SortParamRequest]. -extension SortMapper on Sort { +extension SortRequestMapper on Sort { /// Converts this sort into an [api.SortParamRequest]. api.SortParamRequest toRequest() => api.SortParamRequest(field: field.remote, direction: direction.value); } diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index fde70d591e..7d50effb80 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -2628,7 +2628,7 @@ void main() { final result = await client.updatePoll( _newPoll().copyWith( options: const [ - PollOption(id: 'pizza', text: 'Pizza', extraData: {'color': 'red'}), + PollOption(id: 'pizza', text: 'Pizza', extraData: {'color': 'red', 'id': 'pasta'}), PollOption(id: 'sushi', text: 'Sushi'), ], isClosed: true, @@ -2821,7 +2821,7 @@ void main() { final result = await client.createPollOption( 'poll-id', - const PollOption(text: 'Pizza', extraData: {'color': 'red'}), + const PollOption(text: 'Pizza', extraData: {'color': 'red', 'text': 'Pasta'}), ); expect(result, const Result.success(_pizzaResponse)); @@ -7180,7 +7180,7 @@ Poll _newPoll() => Poll( name: 'Lunch?', description: 'Pick one', options: const [ - PollOption(text: 'Pizza', extraData: {'color': 'red'}), + PollOption(text: 'Pizza', extraData: {'color': 'red', 'text': 'Pasta'}), PollOption(text: 'Sushi'), ], votingVisibility: VotingVisibility.anonymous, @@ -7189,7 +7189,8 @@ Poll _newPoll() => Poll( allowAnswers: true, allowUserSuggestedOptions: true, voteCount: 7, - extraData: const {'topic': 'food'}, + // Custom data named like one of the poll's own fields is left out of the requests. + extraData: const {'topic': 'food', 'name': 'Dinner?'}, ); const _generatedPizzaResponse = api.PollOptionResponse(duration: '4.21ms', pollOption: generatedPizza); From 7ef7ca6d5055e4dda59c47796aa856c46c8f5a5e Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 13 Oct 2026 10:16:18 +0200 Subject: [PATCH 19/32] refactor(llc): tighten the poll docs, changelogs and tests Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 3 +- packages/stream_chat/CHANGELOG.md | 3 +- .../lib/src/client/channel/channel.dart | 6 +- .../stream_chat/lib/src/client/client.dart | 5 +- .../stream_chat/lib/src/core/models/poll.dart | 2 +- .../lib/src/core/models/poll_vote.dart | 6 +- .../models/response/poll_vote_response.dart | 2 - packages/stream_chat/test/fixtures/poll.json | 44 ------------- .../client/channel/channel_polls_test.dart | 8 ++- .../test/src/client/client_test.dart | 36 ++++++++--- .../test/src/client/poll_fixtures.dart | 3 + .../models/converters/poll_v1_json_test.dart | 24 ++++++-- .../stream_chat_flutter_core/CHANGELOG.md | 4 -- .../src/stream_poll_vote_list_controller.dart | 6 +- ...stream_poll_vote_list_controller_test.dart | 61 +++++++++++-------- packages/stream_chat_persistence/CHANGELOG.md | 2 +- 16 files changed, 105 insertions(+), 110 deletions(-) delete mode 100644 packages/stream_chat/test/fixtures/poll.json diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index dbf22920bf..c4360979fe 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -230,7 +230,6 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `CreatePollResponse()..poll = …` and the other response setters | `PollResponse(duration: …, poll: …)` | `retyped` | The responses are plain classes with a const constructor and final fields | | poll responses' `duration` (`String?`) | `String` | `retyped` | Always present; drop any `!` or `?? ''` | | `Poll` / `PollOption` / `PollVote extends Equatable`, `props` | value `==` | `removed` | `Poll` and `PollOption` now compare `extraData` too | -| `PollVote.isAnswer` (a field set in the constructor) | the same name, as a getter | `retyped` | Computed from `answerText` on each read | | `StreamChatApi.polls` (`PollsApi`) | `StreamChatClient`'s poll methods | `removed` | The endpoints moved to the generated client | | `sendImage` / `sendFile` / `uploadImage` / `uploadFile` → `Future` and siblings | `Future>` | `retyped` | On `StreamChatClient`, `Channel` and `AttachmentFileUploader`. Returns a `Result` instead of throwing | | `deleteImage` / `deleteFile` / `removeImage` / `removeFile` → `Future` | `Future>` | `retyped` | On `StreamChatClient`, `Channel` and `AttachmentFileUploader`. Returns a `Result` instead of throwing | @@ -877,7 +876,7 @@ Work top to bottom; each item is independently verifiable. - [ ] Replace `ChatErrorCode` comparisons with `StreamErrorCode` constants - [ ] Apply every row of the [Symbol Map](#symbol-map) - [ ] Re-check custom data access: fields that used to arrive in `extraData` may now be typed properties -- [ ] If you implement `AttachmentFileUploader`, review its section under [Feature Areas](#feature-areas) +- [ ] If you implement `AttachmentFileUploader`, return a `Result` from it — see [File Upload](#file-upload) - [ ] If you persist models yourself, re-check nullability as endpoints move to the generated types, which are nullable wherever the API allows it - [ ] Replace `sort: null` on any list controller with `XSort.empty` if you relied on server ordering — see [Sorting](#sorting) diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 73fc336a15..58f25556ec 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -80,7 +80,7 @@ - `StreamChatApi.guest` and `ConnectGuestUserResponse` are removed; call `StreamChatClient.connectGuestUser` instead. - The poll calls on `StreamChatClient` and `Channel` return a `Result` instead of throwing. - `CreatePollResponse`, `GetPollResponse` and `UpdatePollResponse` are replaced by `PollResponse`; `CreatePollOptionResponse`, `GetPollOptionResponse` and `UpdatePollOptionResponse` by `PollOptionResponse`; and `CastPollVoteResponse` and `RemovePollVoteResponse` by `PollVoteResponse`. -- `queryPolls` and `queryPollVotes` take `limit` (10 when omitted), `next` and `prev` instead of `PaginationParams`, and their responses carry a `prev` cursor. +- `queryPolls` and `queryPollVotes` take `limit` (10 when omitted) and `next` instead of `PaginationParams`. - `Channel.castPollVote` and `removePollVote` return a failure instead of throwing an `ArgumentError` for an option or vote without an id. - `VotingVisibility` is an extension type over its wire string instead of an enum. - `Poll`, `PollOption`, `PollVote` and the poll responses no longer decode from or encode to JSON; `PollOption` gains `fromData` and `toData`, which read and write only the format `stream_chat_persistence` stores it in. @@ -122,6 +122,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 a `prev` argument to `queryPolls` and `queryPollVotes`, and a `prev` cursor on their responses, for paging backwards. 🔒 Security diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index 21df5aae1e..00aea3c934 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -1431,8 +1431,10 @@ class Channel { /// Creates [poll] and sends it to this channel in a new message. /// - /// The message carries [messageText] as its text, if given. If the poll cannot be created, no message is sent and - /// the failure is returned; if the message cannot be sent, the poll stays created and the failure is returned. + /// The message carries [messageText] as its text, if given. + /// + /// If the poll cannot be created, no message is sent and the failure is returned. If the message cannot be sent, the + /// poll stays created and the failure is returned. Future> sendPoll( Poll poll, { String? messageText, diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 85c6ca1f38..f79c30b055 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -1644,8 +1644,9 @@ class StreamChatClient { /// Creates [poll] with its settings and options. /// - /// The ids of the options are left out: every option gets a new one, which the returned poll carries. A poll is - /// shown to the members of a channel once it is sent in a message; [Channel.sendPoll] does both. + /// Any ids already set on the options are ignored; every option gets a new one, which the returned poll carries. + /// + /// A poll is shown to the members of a channel once it is sent in a message; [Channel.sendPoll] does both. Future> createPoll(Poll poll) => _pollsRepository.createPoll(poll); /// Fetches the poll with the id [pollId]. diff --git a/packages/stream_chat/lib/src/core/models/poll.dart b/packages/stream_chat/lib/src/core/models/poll.dart index 7c874a351a..1a947ddfdf 100644 --- a/packages/stream_chat/lib/src/core/models/poll.dart +++ b/packages/stream_chat/lib/src/core/models/poll.dart @@ -113,7 +113,7 @@ class Poll with _$Poll { @override final bool allowAnswers; - /// Whether this poll no longer accepts votes. + /// Whether this poll no longer accepts votes or answers. @override final bool isClosed; diff --git a/packages/stream_chat/lib/src/core/models/poll_vote.dart b/packages/stream_chat/lib/src/core/models/poll_vote.dart index 8af5705240..a7250a2d7f 100644 --- a/packages/stream_chat/lib/src/core/models/poll_vote.dart +++ b/packages/stream_chat/lib/src/core/models/poll_vote.dart @@ -78,6 +78,9 @@ class PollVote with _$PollVote { @override final User? user; + /// Whether this is a free-form answer rather than a vote for an option. + bool get isAnswer => answerText != null; + /// Creates a copy of [PollVote] with specified attributes overridden. PollVote copyWith({ String? id, @@ -100,9 +103,6 @@ class PollVote with _$PollVote { userId: userId ?? this.userId, user: user ?? this.user, ); - - /// Whether this is a free-form answer rather than a vote for an option. - bool get isAnswer => answerText != null; } /// A filter for a poll vote query. diff --git a/packages/stream_chat/lib/src/core/models/response/poll_vote_response.dart b/packages/stream_chat/lib/src/core/models/response/poll_vote_response.dart index 3ed047a17a..151b4c3d75 100644 --- a/packages/stream_chat/lib/src/core/models/response/poll_vote_response.dart +++ b/packages/stream_chat/lib/src/core/models/response/poll_vote_response.dart @@ -18,8 +18,6 @@ class PollVoteResponse with _$PollVoteResponse { final String duration; /// The vote that was cast or removed. - /// - /// Every vote on a message is returned; this is null only for a response that carries no vote. @override final PollVote? vote; } diff --git a/packages/stream_chat/test/fixtures/poll.json b/packages/stream_chat/test/fixtures/poll.json deleted file mode 100644 index 6dd7f46ded..0000000000 --- a/packages/stream_chat/test/fixtures/poll.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "id": "7fd88eb3-fc05-4e89-89af-36c6d8995dda", - "name": "test", - "description": "", - "voting_visibility": "public", - "enforce_unique_vote": false, - "max_votes_allowed": null, - "allow_user_suggested_options": false, - "allow_answers": false, - "vote_count": 0, - "options": [ - { - "id": "option1", - "text": "option1 text" - } - ], - "answers_count": 0, - "vote_counts_by_option": null, - "latest_votes_by_option": {}, - "own_votes": [ - { - "created_at": "2022-02-03T15:47:10.148169Z", - "id": "luke_skywalker", - "option_id": "option1", - "poll_id": "7fd88eb3-fc05-4e89-89af-36c6d8995dda", - "updated_at": "2024-03-18T16:44:45.749718Z" - } - ], - "created_by_id": "luke_skywalker", - "created_by": { - "id": "luke_skywalker", - "role": "user", - "created_at": "2022-02-03T15:47:10.148169Z", - "updated_at": "2024-03-18T16:44:45.749718Z", - "last_active": "2024-04-17T14:46:22.723731831Z", - "banned": false, - "online": true, - "name": "Luke Skywalker", - "image": "https://vignette.wikia.nocookie.net/starwars/images/2/20/LukeTLJ.jpg", - "birthland": "Tatooine" - }, - "created_at": "2024-04-17T14:46:23.001349Z", - "updated_at": "2024-04-17T14:46:23.001349Z" -} \ No newline at end of file diff --git a/packages/stream_chat/test/src/client/channel/channel_polls_test.dart b/packages/stream_chat/test/src/client/channel/channel_polls_test.dart index 4bb6cc478c..60bba74ee6 100644 --- a/packages/stream_chat/test/src/client/channel/channel_polls_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_polls_test.dart @@ -174,13 +174,15 @@ void main() { expect(result, Result.success(response)); }); - test('Channel.queryPollVotes asks for ten votes when no limit is given', () async { + test('Channel.queryPollVotes forwards the previous-page cursor and ten as the default limit', () async { final client = _client(); final channel = _initializedChannel(client); const response = QueryPollVotesResponse(duration: '4.21ms', votes: []); - when(() => client.queryPollVotes(_poll.id, limit: 10)).thenAnswer((_) async => const Result.success(response)); + when( + () => client.queryPollVotes(_poll.id, limit: 10, prev: 'cursor'), + ).thenAnswer((_) async => const Result.success(response)); - final result = await channel.queryPollVotes(_poll.id); + final result = await channel.queryPollVotes(_poll.id, prev: 'cursor'); expect(result, const Result.success(response)); }); diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 7d50effb80..12940767aa 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -2611,6 +2611,23 @@ void main() { expect(result.getOrNull()?.poll.votingVisibility, const VotingVisibility('members_only')); }); + test("StreamChatClient.getPoll reads someone else's vote in an anonymous poll without a user", () async { + final response = api.PollResponse( + duration: '4.21ms', + poll: generatedPoll.copyWith( + latestVotesByOption: { + 'pizza': [generatedVote.copyWith(userId: null, user: null)], + }, + ), + ); + when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(response)); + + final result = await client.getPoll('poll-id'); + + final pizzaVote = result.getOrNull()?.poll.latestVotesByOption['pizza']?.single; + expect((pizzaVote?.userId, pizzaVote?.user), (null, null)); + }); + test('StreamChatClient.getPoll reads a poll that does not say whether it is closed as open', () async { final response = api.PollResponse(duration: '4.21ms', poll: generatedPoll.copyWith(isClosed: null)); when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(response)); @@ -2631,6 +2648,7 @@ void main() { PollOption(id: 'pizza', text: 'Pizza', extraData: {'color': 'red', 'id': 'pasta'}), PollOption(id: 'sushi', text: 'Sushi'), ], + votingVisibility: const VotingVisibility('members_only'), isClosed: true, ), ); @@ -2640,21 +2658,21 @@ void main() { ).captured.single; expect( request, - const api.UpdatePollRequest( + api.UpdatePollRequest( id: 'poll-id', name: 'Lunch?', description: 'Pick one', - options: [ + options: const [ api.PollOptionRequest(id: 'pizza', text: 'Pizza', custom: {'color': 'red'}), api.PollOptionRequest(id: 'sushi', text: 'Sushi', custom: {}), ], - votingVisibility: api.UpdatePollRequestVotingVisibility.anonymous, + votingVisibility: api.UpdatePollRequestVotingVisibility.fromJson('members_only'), enforceUniqueVote: false, maxVotesAllowed: 2, allowAnswers: true, allowUserSuggestedOptions: true, isClosed: true, - custom: {'topic': 'food'}, + custom: const {'topic': 'food'}, ), ); expect(result, Result.success(pollResponse)); @@ -3048,7 +3066,7 @@ void main() { }, sort: [api.SortParamRequest(field: 'created_at', direction: 1)], limit: 25, - prev: 'prev-cursor', + next: 'next-cursor', ), ), ).thenAnswer( @@ -3062,7 +3080,7 @@ void main() { filter: Filter.equal(PollVoteFilterField.isAnswer, true), sort: [PollVoteSort.asc(PollVoteSortField.createdAt)], limit: 25, - prev: 'prev-cursor', + next: 'next-cursor', ); expect( @@ -3084,15 +3102,15 @@ void main() { expect(result.exceptionOrNull(), pollsApiError); }); - test('StreamChatClient.queryPollVotes sends ten as the default limit', () async { + test('StreamChatClient.queryPollVotes sends the previous-page cursor and ten as the default limit', () async { when( () => defaultApi.queryPollVotes( pollId: 'poll-id', - queryPollVotesRequest: const api.QueryPollVotesRequest(limit: 10), + queryPollVotesRequest: const api.QueryPollVotesRequest(limit: 10, prev: 'prev-cursor'), ), ).thenAnswer((_) async => const Result.success(api.PollVotesResponse(duration: '4.21ms', votes: []))); - final result = await client.queryPollVotes('poll-id'); + final result = await client.queryPollVotes('poll-id', prev: 'prev-cursor'); expect(result, const Result.success(QueryPollVotesResponse(duration: '4.21ms', votes: []))); }); diff --git a/packages/stream_chat/test/src/client/poll_fixtures.dart b/packages/stream_chat/test/src/client/poll_fixtures.dart index 31796622cb..bcb1b9e682 100644 --- a/packages/stream_chat/test/src/client/poll_fixtures.dart +++ b/packages/stream_chat/test/src/client/poll_fixtures.dart @@ -13,12 +13,15 @@ final generatedVoter = api.UserResponse( blockedUserIds: const ['blocked-user'], createdAt: DateTime.utc(2024, 1, 1), custom: const {'favorite_color': 'blue'}, + deactivatedAt: DateTime.utc(2024, 1, 4), + deletedAt: DateTime.utc(2024, 1, 5), id: 'luke', image: 'https://example.com/luke.png', language: 'en', lastActive: DateTime.utc(2024, 1, 3), name: 'Luke', online: true, + revokeTokensIssuedBefore: DateTime.utc(2024, 1, 6), role: 'user', teams: const ['jedi'], teamsRole: const {'jedi': 'admin'}, diff --git a/packages/stream_chat/test/src/core/models/converters/poll_v1_json_test.dart b/packages/stream_chat/test/src/core/models/converters/poll_v1_json_test.dart index 237528049d..0aa6190a2e 100644 --- a/packages/stream_chat/test/src/core/models/converters/poll_v1_json_test.dart +++ b/packages/stream_chat/test/src/core/models/converters/poll_v1_json_test.dart @@ -75,7 +75,7 @@ void main() { expect(poll.extraData, isEmpty); }); - test('Message.fromJson keeps the translations of a poll and its options out of their custom data', () { + test('Message.fromJson keeps the translations of a poll out of its custom data', () { final message = Message.fromJson(const { 'id': 'message-id', 'poll': { @@ -83,6 +83,20 @@ void main() { 'name': 'Lunch?', 'name_i18n': {'it': 'Pranzo?'}, 'description_i18n': {'it': 'Scegline uno'}, + 'options': [], + 'topic': 'food', + }, + }); + + expect(message.poll!.extraData, {'topic': 'food'}); + }); + + test('Message.fromJson keeps the translations of a poll option out of its custom data', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'poll': { + 'id': 'poll-id', + 'name': 'Lunch?', 'options': [ { 'id': 'pizza', @@ -91,11 +105,9 @@ void main() { 'color': 'red', }, ], - 'topic': 'food', }, }); - expect(message.poll!.extraData, {'topic': 'food'}); expect(message.poll!.options.single.extraData, {'color': 'red'}); }); @@ -170,10 +182,10 @@ void main() { }); }); - test('Event.toJson writes only the id, the option and the answer of a vote', () { - final event = Event(type: EventType.pollVoteCasted, pollVote: _vote); + test('Event.toJson writes only the id, the option and the answer text of a vote', () { + final event = Event(type: EventType.pollAnswerCasted, pollVote: _answer); - expect(event.toJson()['poll_vote'], {'id': 'vote-id', 'option_id': 'pizza'}); + expect(event.toJson()['poll_vote'], {'id': 'answer-id', 'option_id': '', 'answer_text': 'Anything'}); }); test('Event.fromJson reads back the poll settings written by toJson', () { diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index c71439694f..f6194a6455 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -13,10 +13,6 @@ - A list controller sorted each loaded page with an unstable sort, so rows it called equal — members sharing a `created_at`, say — were reordered on every page append. They keep the order they arrived in now. -🔄 Changed - -- `StreamPollVoteListController` pages with the poll vote query's `limit` and `next` cursor rather than `PaginationParams`. - ## Upcoming 🐞 Fixed diff --git a/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart index 16169ecced..8442d9b02d 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart @@ -122,7 +122,7 @@ class StreamPollVoteListController extends PagedValueNotifier nextPageKey: response.next, ); - // start listening to events + // Start listening to events. if (disposed) return; _subscribeToPollVoteEvents(); }, @@ -150,8 +150,8 @@ class StreamPollVoteListController extends PagedValueNotifier ); } - // Queries the next page of votes, turning an error the channel throws (for one that is not initialized, say) into a - // failure, so every outcome reaches the controller's value. + // Queries a page of votes, after [next] when given, turning an error the channel throws (when it is not initialized, + // for example) into a failure, so every outcome reaches the controller's value. Future> _queryPollVotes({required int limit, String? next}) async { try { return await channel.queryPollVotes(pollId, sort: _activeSort, filter: _activeFilter, limit: limit, next: next); diff --git a/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart b/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart index bbb392b388..c6804a9c33 100644 --- a/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart @@ -76,6 +76,18 @@ void main() { await controller.loadMore('cursor'); expect(controller.value, PagedValue(items: [_vote('v1'), _vote('v2')])); + }); + + test('StreamPollVoteListController.loadMore asks for a page of ten after the given cursor', () async { + final channel = _channelAnswering([ + Result.success(_page([_vote('v1')], next: 'cursor')), + Result.success(_page([])), + ]); + final controller = _controller(channel); + await controller.doInitialLoad(); + + await controller.loadMore('cursor'); + verify( () => channel.queryPollVotes( 'poll-id', @@ -101,33 +113,28 @@ void main() { expect(controller.value, PagedValue(items: [_vote('v1')], nextPageKey: 'cursor', error: error)); }); - test( - 'StreamPollVoteListController.loadMore keeps the loaded votes and records an error the channel throws', - () async { - final channel = _channelAnswering([ - Result.success(_page([_vote('v1')], next: 'cursor')), - ]); - final controller = _controller(channel); - await controller.doInitialLoad(); - final thrown = StateError('Channel is not initialized'); - when( - () => channel.queryPollVotes( - any(), - filter: any(named: 'filter'), - sort: any(named: 'sort'), - limit: any(named: 'limit'), - next: any(named: 'next'), - prev: any(named: 'prev'), - ), - ).thenThrow(thrown); - - await controller.loadMore('cursor'); - - final success = controller.value.asSuccess; - expect(success.items, [_vote('v1')]); - expect(success.error, isA().having((it) => it.cause, 'cause', thrown)); - }, - ); + test('StreamPollVoteListController.loadMore records an error the channel throws', () async { + final channel = _channelAnswering([ + Result.success(_page([_vote('v1')], next: 'cursor')), + ]); + final controller = _controller(channel); + await controller.doInitialLoad(); + final thrown = StateError('Channel is not initialized'); + when( + () => channel.queryPollVotes( + any(), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + limit: any(named: 'limit'), + next: any(named: 'next'), + prev: any(named: 'prev'), + ), + ).thenThrow(thrown); + + await controller.loadMore('cursor'); + + expect(controller.value.asSuccess.error, isA().having((it) => it.cause, 'cause', thrown)); + }); } PollVote _vote(String id) => PollVote( diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md index 1d516ad77d..99d6c23d3e 100644 --- a/packages/stream_chat_persistence/CHANGELOG.md +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -6,11 +6,11 @@ - The sort arguments on `getChannelStates`, `queryChannelStates` and `saveChannelQueries` are typed `List` rather than `SortOrder`. - The filter arguments on `getChannelStates`, `queryChannelStates`, `updateChannelQueries` and `saveChannelQueries` are typed `ChannelFilter?` rather than an untyped `Filter`. - A predefined-filter query whose resolved filter is absent now stores no filter, rather than an empty one. The schema version is bumped for it, so the local cache is rebuilt once on upgrade. -- Poll options are stored in a new format, and a voting visibility the SDK does not name is read back instead of failing. 🔄 Changed - Every persistence call logged its own name at `info`; that call trace is `verbose` now. +- A cached poll with a voting visibility the SDK does not name is read back instead of failing. 🐞 Fixed From 5bcc38355a097b27027f2629cc3400305c4843cf Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 13 Oct 2026 10:42:23 +0200 Subject: [PATCH 20/32] fix(llc): keep the truncation and auto-translation fields through ChannelModel.copyWith and address the style review Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 4 +- packages/stream_chat/CHANGELOG.md | 2 +- .../lib/src/client/channel/channel.dart | 2 +- .../stream_chat/lib/src/client/client.dart | 15 ++-- .../lib/src/core/models/channel_model.dart | 22 +++++- .../lib/src/core/models/member.dart | 4 +- .../update_channel_partial_response.dart | 3 +- .../update_member_partial_response.dart | 3 +- .../src/repository/channels_repository.dart | 8 +- .../test/src/client/channel/channel_test.dart | 47 ++++-------- .../client_update_channel_partial_test.dart | 58 +++++++------- .../client_update_member_partial_test.dart | 41 +++++++++- .../test/src/core/models/channel_test.dart | 76 ++++++++++++++----- .../test/src/core/models/member_test.dart | 18 ++++- 14 files changed, 195 insertions(+), 108 deletions(-) diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index 47af5489f9..7b0e4f2062 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -430,8 +430,8 @@ 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 `1036` to `1104`, and the upgrade strategy drops and recreates every -table rather than migrating rows. +on v11. The Drift schema version moves from v10's `1000 + n` (`1035` in v10.5.0) to `1104`, 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 and reactions. **Messages that failed or were queued while offline are stored in the same table**, so they go diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index fdf7f37546..a5e4a8c34b 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -120,7 +120,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 `ChannelModel.truncatedBy`, `autoTranslationEnabled` and `autoTranslationLanguage`, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, `futureChannelBanExpires`, `deletedAt` and `topLevelFields`; the constructors accept the new fields. +- Added `ChannelModel.truncatedBy`, `autoTranslationEnabled` and `autoTranslationLanguage`, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, `futureChannelBanExpires`, `deletedAt` and `topLevelFields`; the constructors, and `ChannelModel.copyWith`, accept the new fields. 🔒 Security diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index 927670bdd1..1ca3ce3229 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -1708,7 +1708,7 @@ class Channel { /// /// At least one of [set] and [unset] is required. /// - /// Use [update] to replace every field. + /// To replace every field, consider [update]. Future> updatePartial({ Map? set, List? unset, diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 5f7be13c0b..9a30f3b302 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -1243,8 +1243,8 @@ class StreamChatClient { message: message, ); - /// Partially updates a channel: sets the fields in [set] and removes the fields named in [unset], leaving every - /// other field as it is. + /// Partially updates the channel [channelId] of type [channelType]: sets the fields in [set] and removes the fields + /// named in [unset], leaving every other field as it is. /// /// At least one of [set] and [unset] is required. /// @@ -2135,7 +2135,8 @@ class StreamChatClient { ); } - /// Enables slow mode on a channel, so members wait [cooldown] seconds between messages. + /// Enables slow mode on the channel [channelId] of type [channelType], so members wait [cooldown] seconds between + /// messages. Future> enableSlowMode( String channelId, String channelType, @@ -2146,14 +2147,14 @@ class StreamChatClient { set: {'cooldown': cooldown}, ); - /// Disables slow mode on a channel. + /// Disables slow mode on the channel [channelId] of type [channelType]. Future> disableSlowMode( String channelId, String channelType, ) => _channelsRepository.updateChannelPartial( channelId, channelType, - set: {'cooldown': 0}, + set: const {'cooldown': 0}, ); /// Pins provided message @@ -2297,8 +2298,8 @@ class StreamChatClient { ); } - /// Partially updates the current user's membership of a channel: sets the fields in [set] and removes the fields - /// named in [unset], leaving every other field as it is. + /// Partially updates the current user's membership of the channel [channelId] of type [channelType]: sets the + /// fields in [set] and removes the fields named in [unset], leaving every other field as it is. /// /// At least one of [set] and [unset] is required. Future> updateMemberPartial({ diff --git a/packages/stream_chat/lib/src/core/models/channel_model.dart b/packages/stream_chat/lib/src/core/models/channel_model.dart index d819422c3c..00bc69428b 100644 --- a/packages/stream_chat/lib/src/core/models/channel_model.dart +++ b/packages/stream_chat/lib/src/core/models/channel_model.dart @@ -14,7 +14,7 @@ class ChannelModel { /// Creates a channel model. /// /// The [disabled], [hidden], [muted], [blocked], [truncatedAt], [truncatedBy], [autoTranslationEnabled] and - /// [autoTranslationLanguage] arguments, when given, are stored in [extraData], replacing the entry each one reads. + /// [autoTranslationLanguage] arguments, when given, are stored in [extraData], overriding an entry of the same name. ChannelModel({ String? id, String? type, @@ -170,7 +170,9 @@ class ChannelModel { /// The user that last truncated this channel. @JsonKey(includeToJson: false, includeFromJson: false) - User? get truncatedBy => switch (extraData['truncated_by']) { + User? get truncatedBy => _userFrom(extraData['truncated_by']); + + static User? _userFrom(Object? value) => switch (value) { final Map json when json['id'] is String => User.fromJson(json), _ => null, }; @@ -263,6 +265,9 @@ class ChannelModel { DateTime? truncatedAt, int? messageCount, List? filterTags, + User? truncatedBy, + bool? autoTranslationEnabled, + String? autoTranslationLanguage, }) => ChannelModel( id: id ?? this.id, type: type ?? this.type, @@ -293,6 +298,15 @@ class ChannelModel { this.truncatedAt, messageCount: messageCount ?? this.messageCount, filterTags: filterTags ?? this.filterTags, + truncatedBy: truncatedBy ?? _userFrom(extraData?['truncated_by']) ?? this.truncatedBy, + autoTranslationEnabled: + autoTranslationEnabled ?? + extraData?['auto_translation_enabled'].safeCast() ?? + this.autoTranslationEnabled, + autoTranslationLanguage: + autoTranslationLanguage ?? + extraData?['auto_translation_language'].safeCast() ?? + this.autoTranslationLanguage, ); /// Returns a new [ChannelModel] that is a combination of this channelModel @@ -323,6 +337,10 @@ class ChannelModel { truncatedAt: other.truncatedAt, messageCount: other.messageCount, filterTags: other.filterTags, + truncatedBy: other.truncatedBy, + // A channel only carries its auto-translation flag while auto translation is on, so its absence turns it off. + autoTranslationEnabled: other.autoTranslationEnabled ?? false, + autoTranslationLanguage: other.autoTranslationLanguage, ); } } diff --git a/packages/stream_chat/lib/src/core/models/member.dart b/packages/stream_chat/lib/src/core/models/member.dart index e0dae9ae13..94fa8cb968 100644 --- a/packages/stream_chat/lib/src/core/models/member.dart +++ b/packages/stream_chat/lib/src/core/models/member.dart @@ -16,7 +16,7 @@ class Member extends Equatable { /// Creates a member. /// /// The [notificationsMuted], [status], [banFromFutureChannels], [futureChannelBanExpires] and [deletedAt] arguments, - /// when given, are stored in [extraData], replacing the entry each one reads. + /// when given, are stored in [extraData], overriding an entry of the same name. Member({ this.user, this.inviteAcceptedAt, @@ -147,7 +147,7 @@ class Member extends Equatable { @JsonKey(includeToJson: false, includeFromJson: false) DateTime? get futureChannelBanExpires => _date('future_channel_ban_expires'); - /// The date at which the membership was deleted. + /// The date at which this membership was deleted. /// /// Null while the member belongs to the channel. @JsonKey(includeToJson: false, includeFromJson: false) diff --git a/packages/stream_chat/lib/src/core/models/response/update_channel_partial_response.dart b/packages/stream_chat/lib/src/core/models/response/update_channel_partial_response.dart index 277875dd18..fbca6035b2 100644 --- a/packages/stream_chat/lib/src/core/models/response/update_channel_partial_response.dart +++ b/packages/stream_chat/lib/src/core/models/response/update_channel_partial_response.dart @@ -5,7 +5,8 @@ import '../member.dart'; part 'update_channel_partial_response.freezed.dart'; -/// A channel after some of its fields were set or unset. +/// A channel after some of its fields were set or unset, returned by [StreamChatClient.updateChannelPartial] and +/// [Channel.updatePartial]. @freezed class UpdateChannelPartialResponse with _$UpdateChannelPartialResponse { /// Creates a new [UpdateChannelPartialResponse]. diff --git a/packages/stream_chat/lib/src/core/models/response/update_member_partial_response.dart b/packages/stream_chat/lib/src/core/models/response/update_member_partial_response.dart index cf51cd941d..c44e5f771d 100644 --- a/packages/stream_chat/lib/src/core/models/response/update_member_partial_response.dart +++ b/packages/stream_chat/lib/src/core/models/response/update_member_partial_response.dart @@ -4,7 +4,8 @@ import '../member.dart'; part 'update_member_partial_response.freezed.dart'; -/// The current user's membership of a channel after some of its fields were set or unset. +/// The current user's membership of a channel after some of its fields were set or unset, returned by +/// [StreamChatClient.updateMemberPartial] and [Channel.pin]. @freezed class UpdateMemberPartialResponse with _$UpdateMemberPartialResponse { /// Creates a new [UpdateMemberPartialResponse]. diff --git a/packages/stream_chat/lib/src/repository/channels_repository.dart b/packages/stream_chat/lib/src/repository/channels_repository.dart index 2d0e995089..ebb5c9670e 100644 --- a/packages/stream_chat/lib/src/repository/channels_repository.dart +++ b/packages/stream_chat/lib/src/repository/channels_repository.dart @@ -12,8 +12,8 @@ class ChannelsRepository { final api.DefaultApi _api; - /// Partially updates a channel: sets the fields in [set] and removes the fields named in [unset], leaving every - /// other field as it is. + /// Partially updates the channel [channelId] of type [channelType]: sets the fields in [set] and removes the fields + /// named in [unset], leaving every other field as it is. /// /// At least one of [set] and [unset] is required. Future> updateChannelPartial( @@ -31,8 +31,8 @@ class ChannelsRepository { return result.map((response) => response.toModel()); } - /// Partially updates the current user's membership of a channel: sets the fields in [set] and removes the fields - /// named in [unset], leaving every other field as it is. + /// Partially updates the current user's membership of the channel [channelId] of type [channelType]: sets the + /// fields in [set] and removes the fields named in [unset], leaving every other field as it is. /// /// At least one of [set] and [unset] is required. Future> updateMemberPartial( diff --git a/packages/stream_chat/test/src/client/channel/channel_test.dart b/packages/stream_chat/test/src/client/channel/channel_test.dart index 03f7f11d42..da4683aab3 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -3726,9 +3726,9 @@ void main() { test('Channel.updateImage sets the image through a partial update', () async { const image = 'https://getstream.io/new-image'; - final channelModel = ChannelModel( - cid: channelCid, - extraData: {'image': image}, + final response = UpdateChannelPartialResponse( + duration: '0.01ms', + channel: ChannelModel(cid: channelCid), ); when( @@ -3737,14 +3737,11 @@ void main() { channelType, set: {'image': image}, ), - ).thenAnswer( - (_) async => Result.success(UpdateChannelPartialResponse(duration: '0.01ms', channel: channelModel)), - ); + ).thenAnswer((_) async => Result.success(response)); final res = await channel.updateImage(image); - expect(res.isSuccess, isTrue); - expect(res.getOrNull()!.channel!.extraData['image'], image); + expect(res.getOrNull(), response); verify( () => client.updateChannelPartial( @@ -3758,9 +3755,9 @@ void main() { test('Channel.updateName sets the name through a partial update', () async { const name = 'Name'; - final channelModel = ChannelModel( - cid: channelCid, - extraData: {'name': name}, + final response = UpdateChannelPartialResponse( + duration: '0.01ms', + channel: ChannelModel(cid: channelCid), ); when( @@ -3769,14 +3766,11 @@ void main() { channelType, set: {'name': name}, ), - ).thenAnswer( - (_) async => Result.success(UpdateChannelPartialResponse(duration: '0.01ms', channel: channelModel)), - ); + ).thenAnswer((_) async => Result.success(response)); final res = await channel.updateName(name); - expect(res.isSuccess, isTrue); - expect(res.getOrNull()!.channel!.extraData['name'], name); + expect(res.getOrNull(), response); verify( () => client.updateChannelPartial( @@ -3795,12 +3789,9 @@ void main() { const unset = ['tag', 'last_name']; - final channelModel = ChannelModel( - cid: channelCid, - extraData: { - 'coolness': 999, - ...set, - }, + final response = UpdateChannelPartialResponse( + duration: '0.01ms', + channel: ChannelModel(cid: channelCid), ); when( @@ -3810,18 +3801,11 @@ void main() { set: set, unset: unset, ), - ).thenAnswer( - (_) async => Result.success(UpdateChannelPartialResponse(duration: '0.01ms', channel: channelModel)), - ); + ).thenAnswer((_) async => Result.success(response)); final res = await channel.updatePartial(set: set, unset: unset); - expect(res.isSuccess, isTrue); - expect(res.getOrNull()!.channel!.cid, channelModel.cid); - expect( - res.getOrNull()!.channel!.extraData, - {'coolness': 999, ...set}, - ); + expect(res.getOrNull(), response); verify( () => client.updateChannelPartial( @@ -4866,7 +4850,6 @@ void main() { verify(() => client.showChannel(channelId, channelType)).called(1); }); - // testing archiving test("Channel.archive returns the client's archiveChannel response", () async { final response = UpdateMemberPartialResponse( duration: '0.01ms', diff --git a/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart b/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart index 3fcd82520d..763af069be 100644 --- a/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart +++ b/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart @@ -74,22 +74,6 @@ void main() { expect(channel.filterTags, ['support']); }); - test('StreamChatClient.updateChannelPartial returns the channel name and image from its custom data', () async { - const request = api.UpdateChannelPartialRequest(set: {'name': 'General'}); - - final defaultApi = MockDefaultApi(); - when( - () => defaultApi.updateChannelPartial(type: 'messaging', id: 'general', updateChannelPartialRequest: request), - ).thenAnswer((_) async => Result.success(_updateChannelPartialResponse())); - final client = _client(defaultApi); - - final res = await client.updateChannelPartial('general', 'messaging', set: const {'name': 'General'}); - - final channel = res.getOrNull()!.channel!; - expect(channel.name, 'General'); - expect(channel.extraData['image'], 'https://example.com/general.png'); - }); - test('StreamChatClient.updateChannelPartial keeps the custom data and the server fields in extraData', () async { const request = api.UpdateChannelPartialRequest(set: {'name': 'General'}); @@ -118,7 +102,29 @@ void main() { test('StreamChatClient.updateChannelPartial drops channel custom data named like a channel field', () async { final response = _updateChannelPartialResponse( - channel: _channelResponse(custom: const {'hidden': 'shadowed', 'cid': 'shadowed'}), + channel: api.ChannelResponse( + cid: 'messaging:general', + id: 'general', + type: 'messaging', + createdAt: DateTime.utc(2026), + updatedAt: DateTime.utc(2026, 2), + custom: const { + 'topic': 'anything', + 'cid': 'shadowed', + 'disabled': 'shadowed', + 'hidden': 'shadowed', + 'muted': 'shadowed', + 'blocked': 'shadowed', + 'truncated_at': 'shadowed', + 'truncated_by': 'shadowed', + 'mute_expires_at': 'shadowed', + 'auto_translation_enabled': 'shadowed', + 'auto_translation_language': 'shadowed', + 'hide_messages_before': 'shadowed', + }, + disabled: false, + frozen: false, + ), ); const request = api.UpdateChannelPartialRequest(set: {'name': 'General'}); @@ -130,9 +136,7 @@ void main() { final res = await client.updateChannelPartial('general', 'messaging', set: const {'name': 'General'}); - final channel = res.getOrNull()!.channel!; - expect(channel.hidden, isTrue); - expect(channel.extraData, isNot(contains('cid'))); + expect(res.getOrNull()!.channel!.extraData, {'topic': 'anything', 'disabled': false}); }); test('StreamChatClient.updateChannelPartial falls back to the defaults for channel fields left out', () async { @@ -329,14 +333,12 @@ StreamChatClient _client(api.DefaultApi defaultApi) { } // A response with every field set. -api.UpdateChannelPartialResponse _updateChannelPartialResponse({ - api.ChannelResponse? channel, - List? members, -}) => api.UpdateChannelPartialResponse( - channel: channel ?? _channelResponse(), - duration: '0.01ms', - members: members ?? [fakeChannelMemberResponse()], -); +api.UpdateChannelPartialResponse _updateChannelPartialResponse({api.ChannelResponse? channel}) => + api.UpdateChannelPartialResponse( + channel: channel ?? _channelResponse(), + duration: '0.01ms', + members: [fakeChannelMemberResponse()], + ); // A channel with every field set. api.ChannelResponse _channelResponse({ diff --git a/packages/stream_chat/test/src/client/client_update_member_partial_test.dart b/packages/stream_chat/test/src/client/client_update_member_partial_test.dart index 156f29a20e..f8dc631e2e 100644 --- a/packages/stream_chat/test/src/client/client_update_member_partial_test.dart +++ b/packages/stream_chat/test/src/client/client_update_member_partial_test.dart @@ -67,7 +67,24 @@ void main() { test('StreamChatClient.updateMemberPartial drops custom data named like a member field', () async { final response = api.UpdateMemberPartialResponse( duration: '0.01ms', - channelMember: fakeChannelMemberResponse(custom: const {'status': 'shadowed', 'user_id': 'shadowed'}), + channelMember: api.ChannelMemberResponse( + banned: false, + channelRole: 'channel_member', + createdAt: DateTime.utc(2026), + updatedAt: DateTime.utc(2026, 2), + custom: const { + 'nickname': 'Mo', + 'user_id': 'shadowed', + 'notifications_muted': 'shadowed', + 'status': 'shadowed', + 'role': 'shadowed', + 'ban_from_future_channels': 'shadowed', + 'future_channel_ban_expires': 'shadowed', + 'deleted_at': 'shadowed', + }, + notificationsMuted: false, + shadowBanned: false, + ), ); const request = api.UpdateMemberPartialRequest(set: {'a': 1}); @@ -79,9 +96,25 @@ void main() { final res = await client.updateMemberPartial(channelId: 'general', channelType: 'messaging', set: const {'a': 1}); - final member = res.getOrNull()!.channelMember!; - expect(member.status, 'member'); - expect(member.extraData, isNot(contains('user_id'))); + expect(res.getOrNull()!.channelMember!.extraData, {'nickname': 'Mo', 'notifications_muted': false}); + }); + + test("StreamChatClient.updateMemberPartial keeps the member's role over a custom role", () async { + final response = api.UpdateMemberPartialResponse( + duration: '0.01ms', + channelMember: fakeChannelMemberResponse(custom: const {'role': 'shadowed'}), + ); + const request = api.UpdateMemberPartialRequest(set: {'a': 1}); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateMemberPartial(type: 'messaging', id: 'general', updateMemberPartialRequest: request), + ).thenAnswer((_) async => Result.success(response)); + final client = _client(defaultApi); + + final res = await client.updateMemberPartial(channelId: 'general', channelType: 'messaging', set: const {'a': 1}); + + expect(res.getOrNull()!.channelMember!.extraData['role'], 'user'); }); test('StreamChatClient.updateMemberPartial falls back to the defaults for member fields left out', () async { diff --git a/packages/stream_chat/test/src/core/models/channel_test.dart b/packages/stream_chat/test/src/core/models/channel_test.dart index 6a6b457ada..38245b7b92 100644 --- a/packages/stream_chat/test/src/core/models/channel_test.dart +++ b/packages/stream_chat/test/src/core/models/channel_test.dart @@ -224,25 +224,22 @@ void main() { }); }); - test( - 'ChannelModel.copyWith drops truncatedBy and the auto-translation settings the replacement extraData leaves out', - () { - final channel = ChannelModel( - cid: 'test:cid', - truncatedBy: User(id: 'truncator'), - autoTranslationEnabled: true, - autoTranslationLanguage: 'fr', - ); + test('ChannelModel.copyWith keeps truncatedBy and the auto-translation settings the new extraData leaves out', () { + final channel = ChannelModel( + cid: 'test:cid', + truncatedBy: User(id: 'truncator'), + autoTranslationEnabled: true, + autoTranslationLanguage: 'fr', + ); - final newChannel = channel.copyWith(extraData: const {'color': 'red'}); + final newChannel = channel.copyWith(extraData: const {'color': 'red'}); - expect(newChannel.truncatedBy, isNull); - expect(newChannel.autoTranslationEnabled, isNull); - expect(newChannel.autoTranslationLanguage, isNull); - }, - ); + expect(newChannel.truncatedBy?.id, 'truncator'); + expect(newChannel.autoTranslationEnabled, isTrue); + expect(newChannel.autoTranslationLanguage, 'fr'); + }); - test('ChannelModel.merge drops truncatedBy and the auto-translation settings the other channel leaves out', () { + test('ChannelModel.copyWith replaces truncatedBy and the auto-translation settings', () { final channel = ChannelModel( cid: 'test:cid', truncatedBy: User(id: 'truncator'), @@ -250,22 +247,59 @@ void main() { autoTranslationLanguage: 'fr', ); + final newChannel = channel.copyWith( + truncatedBy: User(id: 'other'), + autoTranslationEnabled: false, + autoTranslationLanguage: 'de', + ); + + expect(newChannel.truncatedBy?.id, 'other'); + expect(newChannel.autoTranslationEnabled, isFalse); + expect(newChannel.autoTranslationLanguage, 'de'); + }); + + test('ChannelModel.merge keeps truncatedBy when the other channel leaves it out', () { + final channel = ChannelModel( + cid: 'test:cid', + truncatedBy: User(id: 'truncator'), + ); + + final merged = channel.merge(ChannelModel(cid: 'test:cid')); + + expect(merged.truncatedBy?.id, 'truncator'); + }); + + test('ChannelModel.merge turns auto translation off when the other channel leaves it out', () { + final channel = ChannelModel(cid: 'test:cid', autoTranslationEnabled: true, autoTranslationLanguage: 'fr'); + final merged = channel.merge(ChannelModel(cid: 'test:cid')); - expect(merged.truncatedBy, isNull); - expect(merged.autoTranslationEnabled, isNull); - expect(merged.autoTranslationLanguage, isNull); + expect(merged.autoTranslationEnabled, isFalse); }); - test('.truncatedBy should return null if the extraData user has no id', () { + test('ChannelModel arguments override the same keys in extraData', () { final channel = ChannelModel( + cid: 'test:cid', + autoTranslationEnabled: true, + autoTranslationLanguage: 'fr', + extraData: const {'auto_translation_enabled': false, 'auto_translation_language': 'de'}, + ); + + expect(channel.autoTranslationEnabled, isTrue); + expect(channel.autoTranslationLanguage, 'fr'); + }); + + test('ChannelModel.truncatedBy returns null when the extraData value is not a user with an id', () { + final noIdChannel = ChannelModel( cid: 'test:channel', extraData: const { 'truncated_by': {'name': 'No id'}, }, ); + final textChannel = ChannelModel(cid: 'test:channel', extraData: const {'truncated_by': 'someone'}); - expect(channel.truncatedBy, isNull); + expect(noIdChannel.truncatedBy, isNull); + expect(textChannel.truncatedBy, isNull); }); test('.name should fetch from extraData if available', () { diff --git a/packages/stream_chat/test/src/core/models/member_test.dart b/packages/stream_chat/test/src/core/models/member_test.dart index 7f40031533..16be78c71b 100644 --- a/packages/stream_chat/test/src/core/models/member_test.dart +++ b/packages/stream_chat/test/src/core/models/member_test.dart @@ -45,10 +45,24 @@ void main() { }, ); + test('Member arguments override the same keys in extraData', () { + final member = Member( + userId: 'user', + status: 'member', + notificationsMuted: true, + extraData: const {'status': 'pending', 'notifications_muted': false}, + ); + + expect(member.status, 'member'); + expect(member.notificationsMuted, isTrue); + }); + test('Member.deletedAt returns null for an extraData value that is not a date', () { - final member = Member(userId: 'user', extraData: const {'deleted_at': 'yesterday'}); + final textMember = Member(userId: 'user', extraData: const {'deleted_at': 'yesterday'}); + final numberMember = Member(userId: 'user', extraData: const {'deleted_at': 42}); - expect(member.deletedAt, isNull); + expect(textMember.deletedAt, isNull); + expect(numberMember.deletedAt, isNull); }); test('Member.futureChannelBanExpires returns null for an empty extraData value', () { From 2954e6395aa9e0a62673a1dbe61003ac6e695bbd Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 13 Oct 2026 10:55:56 +0200 Subject: [PATCH 21/32] docs(llc): drop the channel id and type roll call from the partial update and slow mode docs Co-Authored-By: Claude Opus 5.5 --- packages/stream_chat/lib/src/client/client.dart | 13 ++++++------- .../lib/src/repository/channels_repository.dart | 8 ++++---- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 9a30f3b302..37e75009d0 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -1243,8 +1243,8 @@ class StreamChatClient { message: message, ); - /// Partially updates the channel [channelId] of type [channelType]: sets the fields in [set] and removes the fields - /// named in [unset], leaving every other field as it is. + /// Partially updates a channel: sets the fields in [set] and removes the fields named in [unset], leaving every + /// other field as it is. /// /// At least one of [set] and [unset] is required. /// @@ -2135,8 +2135,7 @@ class StreamChatClient { ); } - /// Enables slow mode on the channel [channelId] of type [channelType], so members wait [cooldown] seconds between - /// messages. + /// Enables slow mode on a channel, so members wait [cooldown] seconds between messages. Future> enableSlowMode( String channelId, String channelType, @@ -2147,7 +2146,7 @@ class StreamChatClient { set: {'cooldown': cooldown}, ); - /// Disables slow mode on the channel [channelId] of type [channelType]. + /// Disables slow mode on a channel. Future> disableSlowMode( String channelId, String channelType, @@ -2298,8 +2297,8 @@ class StreamChatClient { ); } - /// Partially updates the current user's membership of the channel [channelId] of type [channelType]: sets the - /// fields in [set] and removes the fields named in [unset], leaving every other field as it is. + /// Partially updates the current user's membership of a channel: sets the fields in [set] and removes the fields + /// named in [unset], leaving every other field as it is. /// /// At least one of [set] and [unset] is required. Future> updateMemberPartial({ diff --git a/packages/stream_chat/lib/src/repository/channels_repository.dart b/packages/stream_chat/lib/src/repository/channels_repository.dart index ebb5c9670e..2d0e995089 100644 --- a/packages/stream_chat/lib/src/repository/channels_repository.dart +++ b/packages/stream_chat/lib/src/repository/channels_repository.dart @@ -12,8 +12,8 @@ class ChannelsRepository { final api.DefaultApi _api; - /// Partially updates the channel [channelId] of type [channelType]: sets the fields in [set] and removes the fields - /// named in [unset], leaving every other field as it is. + /// Partially updates a channel: sets the fields in [set] and removes the fields named in [unset], leaving every + /// other field as it is. /// /// At least one of [set] and [unset] is required. Future> updateChannelPartial( @@ -31,8 +31,8 @@ class ChannelsRepository { return result.map((response) => response.toModel()); } - /// Partially updates the current user's membership of the channel [channelId] of type [channelType]: sets the - /// fields in [set] and removes the fields named in [unset], leaving every other field as it is. + /// Partially updates the current user's membership of a channel: sets the fields in [set] and removes the fields + /// named in [unset], leaving every other field as it is. /// /// At least one of [set] and [unset] is required. Future> updateMemberPartial( From 086657e0765fd6649e249704c4acb5e2e24b1c66 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 13 Oct 2026 15:29:17 +0200 Subject: [PATCH 22/32] refactor(llc): switch the poll models to freezed's copyWith Co-Authored-By: Claude Fable 5.1 --- .claude/skills/openapi-migration/SKILL.md | 52 +-- .../openapi-migration/copywith-audit.md | 95 +++++ .../scripts/copywith_audit.py | 386 ++++++++++++++++++ migrations/v11-migration.md | 17 + openapi-migration/02-devices.md | 4 +- openapi-migration/03-user-groups.md | 4 +- openapi-migration/04-roles-guest-and-app.md | 4 +- openapi-migration/05-polls.md | 9 +- openapi-migration/06-reminders.md | 4 +- openapi-migration/07-threads-and-drafts.md | 4 +- .../08-moderation-and-blocklists.md | 4 +- openapi-migration/09-users.md | 4 +- openapi-migration/10-messages.md | 4 +- openapi-migration/11-channels-and-members.md | 4 +- openapi-migration/12-uploads-cdn.md | 4 +- openapi-migration/13-push-preferences.md | 4 +- openapi-migration/14-banned-users.md | 4 +- openapi-migration/README.md | 14 +- openapi-migration/tool/generate_plan.py | 9 +- packages/stream_chat/CHANGELOG.md | 1 + .../stream_chat/lib/src/core/models/poll.dart | 59 +-- .../lib/src/core/models/poll.freezed.dart | 212 +++++++++- .../lib/src/core/models/poll_option.dart | 21 +- .../src/core/models/poll_option.freezed.dart | 61 +++ .../lib/src/core/models/poll_vote.dart | 25 +- .../src/core/models/poll_vote.freezed.dart | 93 ++++- .../src/core/models/poll_option_test.dart | 6 +- .../test/src/core/models/poll_test.dart | 16 +- .../test/src/core/models/poll_vote_test.dart | 9 +- .../channel/stream_message_preview_text.dart | 6 +- 30 files changed, 945 insertions(+), 194 deletions(-) create mode 100644 .claude/skills/openapi-migration/copywith-audit.md create mode 100755 .claude/skills/openapi-migration/scripts/copywith_audit.py diff --git a/.claude/skills/openapi-migration/SKILL.md b/.claude/skills/openapi-migration/SKILL.md index dd5f84616a..e3f4e78934 100644 --- a/.claude/skills/openapi-migration/SKILL.md +++ b/.claude/skills/openapi-migration/SKILL.md @@ -205,9 +205,8 @@ a public signature. [Persisted models](#persisted-models-the-dataserializable-codec)). A model stored only as a table row, mapped column by column like `Poll` in `polls`, needs no codec. - **Which request enums need a hand-written public type** (rule 6). -- **Whether v10 has a `copyWith`, and how it treats `null`.** If it has one, the migrated model keeps that exact - method, `_nullConst` sentinels included. freezed's `copyWith` behaves differently, so read - [Keeping a v10 `copyWith`](#keeping-a-v10-copywith) before relying on it. +- **Whether v10 has a `copyWith`.** If it has one, run `scripts/copywith_audit.py ` before deleting it; + [Replacing a v10 `copyWith`](#replacing-a-v10-copywith) says what to do with the report. **Per method, decide what it returns:** @@ -460,32 +459,24 @@ List>? _membersToData(List? members) => `.g.dart` and `.freezed.dart` files of the models you changed; `--build-filter` deletes every generated output outside the filter, so never use it. -### Keeping a v10 `copyWith` - -**freezed's `copyWith` is not v10's, and swapping one for the other changes behaviour without a compile error.** - -- **v10:** `null` means "keep the current value" (`name: name ?? this.name`). A field opts into clearing through - the `_nullConst` sentinel: `Object? maxVotesAllowed = _nullConst`, read back as - `maxVotesAllowed == _nullConst ? this.maxVotesAllowed : maxVotesAllowed as int?`. Every non-nullable field also - takes a nullable parameter. -- **freezed:** `null` passed for a nullable field sets it to `null`, and a non-nullable field's parameter is - non-nullable. A field whose constructor turns `null` into a default (`id ?? Uuid().v4()`, - `createdAt ?? DateTime.now()`) gets a new value instead of keeping the old one. - -The SDK's state handling relies on exactly which fields keep and which clear. `ChannelClientState` clears expired -pins with `message.copyWith(pinExpires: null)` and a deleted draft with `channelState.copyWith(draft: null)`, and -`StreamMessageComposerController.clearQuotedMessage` clears the quote the same way. Each of those works only -because the field has a sentinel. Elsewhere, a value that happens to be `null` passed to a `??` field keeps what -was there. So: - -- **If the v10 class has a `copyWith`, keep it verbatim.** Annotate the class `@Freezed(copyWith: false)` and copy - v10's method from `git show :`. Copy the whole signature, every `??`, every `_nullConst` parameter, - and the file's `_NullConst` class. freezed still generates `==`, `hashCode` and `toString`. Copy - `lib/src/core/models/poll.dart` (sentinel on `maxVotesAllowed`) and `poll_option.dart` (sentinel on `id`). -- **Only a class with no v10 `copyWith` uses freezed's.** A `copyWith` it gains that way is new API, so its - semantics are not a break. -- **Diff the two methods parameter by parameter** against the base before moving on: the defaults, the `??` - fallbacks and the sentinels. +### Replacing a v10 `copyWith` + +A model moving onto `@freezed` gives up its hand-written `copyWith` for the generated one, and most of the +difference compiles cleanly: `null` clears a nullable field where v10 kept it, `null` re-runs a constructor default +(`id ?? Uuid().v4()`, `createdAt ?? DateTime.now()`), and a constructor parameter with no field of the same name +and type is not generated at all. [`copywith-audit.md`](copywith-audit.md) has the full table and the procedure; +`scripts/copywith_audit.py ` runs it, on a clean tree, and lists every call site in lib and tests whose +behaviour or compilation changes. + +- **Handle every site the report lists** (README rule 2): a compile break gets a local to carry flow analysis, or + a `?? model.field`; a site tagged "v10 kept on null; check" is rewritten as an explicit merge unless the value + is only ever null when the field already is (`Poll.withTranslationsOf`) or the clear is intended; a site tagged + "v10 sentinel, same result" needs nothing. +- **A verdict line means freezed cannot replace this method as it stands.** Keep `@Freezed(copyWith: false)` with + the hand-written method, or reshape the constructor so every parameter is a field of the same name and type, + which is a public API change of its own. `Message` is the known case. +- **Record the semantics change** in the CHANGELOG and the guide, listing the model's fields under the rows of the + audit's table that apply. A model with no v10 `copyWith` gains new API, which is not a break. - **Mind what freezed counts as data.** For a class with a hand-written constructor, it puts every instance field in `==`, `hashCode` and `toString`, private fields and `late final` caches included. - A cache derived only from fields already compared (`Poll.ownVotes`, from `ownVotesAndAnswers`) changes no @@ -524,8 +515,7 @@ Budget for this: on a typical feature it is most of the diff, and none of it is - **Stub each generated response with every field our model carries,** each with a distinct value, and compare the whole returned envelope, so a field the mapper drops or swaps fails an assertion. Give every method a failure test too. -- **Pin each kept `copyWith`'s `null` handling:** one test that `null` keeps the `??` fields, and one per - `_nullConst` field that `null` clears it (`poll_test.dart`, `poll_option_test.dart`). +- **Flip the tests that pinned a v10 `copyWith`'s `null` handling** to freezed's behaviour; the audit lists them. - **Test each temporary converter through its parent's `fromJson`/`toJson`.** - **Test each `@DataSerializable` model's stored format:** pin `toData()` to a literal map, so a rename that changes the stored keys fails; round-trip `fromData(toData())`; cover null versus empty for nullable lists. Extend the diff --git a/.claude/skills/openapi-migration/copywith-audit.md b/.claude/skills/openapi-migration/copywith-audit.md new file mode 100644 index 0000000000..a0e6dc4e17 --- /dev/null +++ b/.claude/skills/openapi-migration/copywith-audit.md @@ -0,0 +1,95 @@ +# copyWith audit: moving a model to freezed's copyWith + +A model moving onto `@freezed` gives up its hand-written `copyWith` for the generated one. The two treat `null` +differently, freezed can't generate every parameter a hand-written method takes, and most of the difference +compiles cleanly. Run this audit per model before deleting the old method. It lists every call site, in lib and in +tests, whose behaviour or compilation changes, and tells you when freezed's `copyWith` can't replace the model's +at all. + +## Install + +Two files, meant to live in the openapi-migration skill: + +* `.claude/skills/openapi-migration/copywith-audit.md`: this doc (everything above the Script section) +* `.claude/skills/openapi-migration/scripts/copywith_audit.py`: the script at the bottom + +## What changes + +| Parameter | v10 hand-written | freezed | How it shows up | +| --- | --- | --- | --- | +| Non-nullable field (`String name`) | `String? name`, null keeps | `String name` | Compile error where a nullable value is passed | +| Nullable field, no sentinel (`String? description`) | null keeps | null clears | Silent: same code, field now wiped | +| Nullable field with `_nullConst` sentinel | null clears | null clears | No change | +| Nullable constructor parameter on a non-nullable field (`String? id` → `id ?? Uuid().v4()`) | null keeps | null runs the constructor default | Silent: a new id or `DateTime.now()` | +| Constructor parameter with no field of the same name (a getter, a private field, a renamed field) | accepted | not generated | Compile error at every call passing it | +| Constructor parameter whose type differs from its field's (`String type` → `MessageType type`) | the field's type | the parameter's type | Compile error where the field's type is passed | + +freezed builds `copyWith` from the class's constructor, and keeps a parameter only when a field (not a getter) has +the same name. That is why the last two rows exist. + +## Run it + +One model per run, named by its class, from anywhere in the repo after `melos bootstrap`: + +```bash +python3 .claude/skills/openapi-migration/scripts/copywith_audit.py Poll +``` + +When two packages declare the class, pass the file: `packages/stream_chat/lib/src/core/models/poll.dart:Poll`. By +default it analyzes every bootstrapped package; `--packages packages/stream_chat packages/stream_chat_flutter …` +narrows it. A run over five packages takes about half a minute. + +The model can be on `@Freezed(copyWith: false)` already, or not on freezed at all. For the second, the probe adds +`@freezed`, `with _$Model`, the `part` and the import for the run only. Either way it deletes the hand-written +`copyWith`, regenerates with the `.fvmrc`-pinned SDK, and analyzes twice: + +1. As generated. Every error is a compile break. +2. With the `?` dropped from the generated `copyWith` interface. Every new error is a call site passing a value + that may be null to a nullable parameter, which is exactly where "keeps" became "clears". + +**What it touches.** Only the model's file and the generated `*.freezed.dart` / `*.g.dart` files of the model's +package, all restored at the end, even on failure. It refuses to start while any of those has uncommitted changes. +Edits anywhere else, including while it runs, are left alone. + +## Read the report + +A verdict line appears when freezed's `copyWith` can't replace the model's as it stands, because parameters are +missing or retyped (`Message` is the example: `createdAt` is a getter over server and local timestamps, +`quotedMessageId` a private field with a fallback, `type` a `String` turned into a `MessageType`). Then either keep +`@Freezed(copyWith: false)` with the hand-written method, or reshape the constructor so every parameter is a field +of the same name and type, which is a public API change of its own. The sections below the verdict still show what +each option would cost. + +Nullable argument sites are tagged: + +* **v10 sentinel, same result**: no action. +* **v10 kept on null; check**: decide whether the value can be null at runtime while the field is set. If it is + only ever null when the field already is (`nameI18n: nameI18n ?? fallback`), the result is unchanged. Otherwise + the site needs `value ?? model.field`, or the clear is the intended new behaviour. + +Compile breaks in lib usually need a local to carry flow analysis (`translatedName(language)` called twice is not +promoted). Tests that pin the old "null keeps" behaviour flip to the new behaviour. + +For a model not yet on freezed, the report covers `copyWith` only. freezed also replaces `==`, `hashCode` and +`toString` (for example, every field counts in `==`, where `Equatable` compared only `props`); check those +separately. + +What a changed site means for apps goes in the changelog and the migration guide: list the model's fields under +the rows of the table that apply. + +## Known limits + +* It finds the hand-written `copyWith` by the shape `Type copyWith({`; a positional or oddly formatted one has to + be removed by hand first. +* A call through `dynamic` or a tear-off isn't type-checked, so it isn't reported. +* A generated file that is untracked and rewritten by the build can't be restored; commit generated files first. + +## Results on the poll models (PR #3037) + +* `Poll`: 1 compile break in lib (`stream_message_preview_text.dart:96`), `withTranslationsOf` unchanged, + sentinel sites unchanged, 1 test to rewrite (`poll_test.dart:40`). Constructor-default trap on `id`, + `createdAt`, `updatedAt`. +* `PollOption`: no lib changes; 1 test no longer compiles (`poll_option_test.dart:43`). +* `PollVote`: no lib changes; 1 test to rewrite (`poll_vote_test.dart:23`). +* Applying the `Poll` switch for real: analyze clean in all packages, and the only failing non-golden test is the + one pinning v10 behaviour. diff --git a/.claude/skills/openapi-migration/scripts/copywith_audit.py b/.claude/skills/openapi-migration/scripts/copywith_audit.py new file mode 100755 index 0000000000..00d03d6077 --- /dev/null +++ b/.claude/skills/openapi-migration/scripts/copywith_audit.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +"""Audit what breaks when one model's hand-written copyWith is replaced by freezed's. + +Usage: python3 .claude/skills/openapi-migration/scripts/copywith_audit.py [--packages ...] + + is a class name (`Poll`) or `path/to/file.dart:Poll`. Run it inside the repo, after `melos bootstrap`. + +The probe edits only the model's file and the generated `*.freezed.dart` / `*.g.dart` files of the model's package, +and restores exactly those at the end, even on failure. It refuses to start while any of them has uncommitted +changes, so nothing of yours is ever overwritten. +""" + +import argparse +import json +import os +import re +import subprocess +import sys +from pathlib import Path + + +def sh(cmd, cwd=None, env=None, check=False): + return subprocess.run(cmd, cwd=cwd or ROOT, env=env, text=True, capture_output=True, check=check) + + +ROOT = Path(subprocess.run(['git', 'rev-parse', '--show-toplevel'], text=True, capture_output=True).stdout.strip() + or Path.cwd()) +GENERATED = ('.freezed.dart', '.g.dart') + + +# --- Files the probe may touch ----------------------------------------------------------------------------------- + + +def managed_status(model: Path, pkg: Path): + """git status of the files the probe may touch: the model file and the package's generated files.""" + rel_pkg = os.path.relpath(pkg, ROOT) + out = sh(['git', 'status', '--porcelain', '--untracked-files=all', '--', f'{rel_pkg}/lib']).stdout + status = {} + for line in out.splitlines(): + path = line[3:].split(' -> ')[-1] + if path == str(model) or path.endswith(GENERATED): + status[path] = line[:2] + return status + + +def restore(model: Path, pkg: Path, untracked_before): + """Puts every managed file back as git has it; removes generated files the probe created.""" + for path, code in managed_status(model, pkg).items(): + if code == '??': + if path not in untracked_before: + (ROOT / path).unlink(missing_ok=True) + else: + sh(['git', 'checkout', '--', path]) + + +# --- Toolchain --------------------------------------------------------------------------------------------------- + + +def pinned_env(): + env = dict(os.environ) + fvmrc = ROOT / '.fvmrc' + if fvmrc.exists(): + pin = json.loads(fvmrc.read_text()).get('flutter') + bin_dir = Path.home() / 'fvm' / 'versions' / str(pin) / 'bin' + if bin_dir.exists(): + env['PATH'] = f'{bin_dir}{os.pathsep}{env["PATH"]}' + return env + + +def package_of(path: Path) -> Path: + for parent in (ROOT / path).resolve().parents: + if (parent / 'pubspec.yaml').exists(): + return parent + sys.exit(f'No pubspec.yaml above {path}') + + +def default_packages(): + out = sh(['git', 'ls-files', '*pubspec.yaml']).stdout.split() + return [ROOT / Path(p).parent for p in out + if (ROOT / Path(p).parent / '.dart_tool' / 'package_config.json').exists()] + + +# --- Locating and rewriting the model ---------------------------------------------------------------------------- + + +def find_model(spec): + if ':' in spec: + path, name = spec.rsplit(':', 1) + return Path(os.path.relpath(Path(path).resolve(), ROOT)), name + hits = [Path(p) for p in sh(['git', 'grep', '-lE', rf'^(abstract |final )*class {spec}( |<|$)', + '--', ':(glob)packages/*/lib/**/*.dart']).stdout.split() + if not p.endswith(GENERATED)] + if len(hits) != 1: + sys.exit(f'Found {len(hits)} files declaring `class {spec}`: {hits}. Pass `path/to/file.dart:{spec}`.') + return hits[0], spec + + +def match_close(src, i, open_ch, close_ch): + """Index just past the bracket that closes the one at src[i].""" + depth, quote = 0, None + while i < len(src): + c = src[i] + if quote: + if c == '\\': + i += 2 + continue + if c == quote: + quote = None + elif c in '\'"': + quote = c + elif c == open_ch: + depth += 1 + elif c == close_ch: + depth -= 1 + if depth == 0: + return i + 1 + i += 1 + raise ValueError('unbalanced brackets') + + +def class_span(src, name): + """(header_start, decl_start, body_start, body_end); the header is the doc comment and annotations.""" + m = re.compile(rf'^(?:abstract |final |sealed |base )*class {re.escape(name)}\b[^{{]*\{{', re.M).search(src) + if not m: + sys.exit(f'`class {name}` not found.') + body_start = m.end() - 1 + body_end = match_close(src, body_start, '{', '}') + lines = src[:m.start()].split('\n')[:-1] + while lines and re.match(r'\s*(///|//|@|\)|\w+:)', lines[-1]): + lines.pop() + header_start = len('\n'.join(lines)) + (1 if lines else 0) + return header_start, m.start(), body_start, body_end + + +def parse_params(params_src): + """{name: (type, has_sentinel)} for a `{...}` parameter list's text.""" + out = {} + for p in re.finditer(r'([\w<>?, ]+?)\s+(\w+)\s*(=\s*_nullConst)?\s*,', params_src + ','): + out[p.group(2)] = (p.group(1).replace('required ', '').strip(), bool(p.group(3))) + return out + + +def remove_copy_with(body): + """Strips the hand-written copyWith from a class body; returns (body, params).""" + m = re.search(r'^([ \t]+)[\w<>?, ]+\s+copyWith\(\{', body, re.M) + if not m: + return body, {} + lines = body[:m.start()].split('\n')[:-1] + while lines and re.match(r'\s*(///|@)', lines[-1]): + lines.pop() + start = len('\n'.join(lines)) + (1 if lines else 0) + + paren = body.index('(', m.start()) + params_end = match_close(body, paren, '(', ')') + params = parse_params(body[paren + 2:params_end - 1]) + rest = body[params_end:] + end = params_end + len(rest) - len(rest.lstrip()) + if body.startswith('=>', end): + while body[end] != ';': + c = body[end] + end = match_close(body, end, c, {'(': ')', '[': ']', '{': '}'}[c]) if c in '([{' else end + 1 + end += 1 + else: + end = match_close(body, end, '{', '}') + if body[end:end + 1] == '\n': + end += 1 + return body[:start] + body[end:], params + + +def put_on_freezed(src, name, header_start, decl_start): + """Makes the class generate freezed's copyWith; returns (src, was_already_freezed).""" + header, decl = src[header_start:decl_start], src[decl_start:] + if re.search(r'@Freezed\([^)]*copyWith:\s*false', header): + def fix(m): + args = re.sub(r'copyWith:\s*false\s*,?\s*', '', m.group(1)).strip().rstrip(',') + return '@freezed' if not args else f'@Freezed({args})' + + header = re.sub(r'@Freezed\(([^)]*copyWith:\s*false[^)]*)\)', fix, header) + return src[:header_start] + header + decl, True + if re.search(r'@(freezed|Freezed)\b', header): + return src, True + + # Not on freezed yet: annotate the class, mix in the generated code, and add the part and import. + header += '@freezed\n' + head_end = decl.index('{') + head = decl[:head_end] + if re.search(r'\bwith\b', head): + head = re.sub(r'\bwith\s+', f'with _${name}, ', head, count=1) + elif ' implements ' in head: + head = head.replace(' implements ', f' with _${name} implements ', 1) + else: + head = head.rstrip() + f' with _${name} ' + src = src[:header_start] + header + head + decl[head_end:] + + part = f"part '{MODEL.stem}.freezed.dart';" + if part not in src: + parts = list(re.finditer(r"^part '.*';\n", src, re.M)) + imports = list(re.finditer(r"^import '.*';\n", src, re.M)) + anchor = parts[-1].end() if parts else imports[-1].end() if imports else 0 + src = src[:anchor] + ('' if parts else '\n') + part + '\n' + src[anchor:] + if 'package:freezed_annotation/freezed_annotation.dart' not in src: + first_import = re.search(r"^import ", src, re.M) + at = first_import.start() if first_import else 0 + src = src[:at] + "import 'package:freezed_annotation/freezed_annotation.dart' show freezed;\n" + src[at:] + return src, False + + +# --- Generated code ---------------------------------------------------------------------------------------------- + + +def interface(freezed_src, name): + """The match of the generated `$Res call({...});` interface of the model's copyWith.""" + head = re.search(rf'class [_$]*{re.escape(name)}CopyWith<\$Res>', freezed_src) + if not head: + return None + return re.compile(r'\$Res call\(\{([^{}]*?)\}\);').search(freezed_src, head.end()) + + +def interface_params(body): + return {m.group(2): m.group(1) for m in re.finditer(r'^\s*(.+?)\s+(\w+),\s*$', body, re.M)} + + +def field_types(body): + return {m.group(2): m.group(1) for m in re.finditer(r'^\s+final\s+([\w<>?, ]+?)\s+(\w+);', body, re.M)} + + +def bare(type_): + return type_.strip().rstrip('?') + + +# --- Analysis ---------------------------------------------------------------------------------------------------- + + +def analyze(packages, env): + errors = {} + for pkg in packages: + out = sh(['dart', 'analyze', '--format=machine', '.'], cwd=pkg, env=env) + for line in (out.stdout + out.stderr).splitlines(): + parts = line.split('|') + if len(parts) >= 8 and parts[0] == 'ERROR': + errors[(os.path.relpath(parts[3], ROOT), int(parts[4]), int(parts[5]))] = (parts[2], parts[7]) + return errors + + +def arg_name(path, line, col, code, msg): + if code == 'UNDEFINED_NAMED_PARAMETER': + m = re.search(r"named parameter '(\w+)'", msg) + if m: + return m.group(1) + text = (ROOT / path).read_text().split('\n')[line - 1] + m = re.search(r'(\w+)\s*:\s*$', text[:col - 1]) + return m.group(1) if m else '?' + + +def source_line(path, line): + return (ROOT / path).read_text().split('\n')[line - 1].strip() + + +# --- Main -------------------------------------------------------------------------------------------------------- + + +def main(): + global MODEL + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('model', help='class name, or path/to/file.dart:ClassName') + ap.add_argument('--packages', nargs='*', help='package dirs to analyze (default: every bootstrapped package)') + args = ap.parse_args() + os.chdir(ROOT) + + MODEL, name = find_model(args.model) + pkg = package_of(MODEL) + before = managed_status(MODEL, pkg) + dirty = [p for p, code in before.items() if code != '??'] + if dirty: + sys.exit('These files have uncommitted changes and the probe would overwrite them; commit or stash first:\n' + + '\n'.join(f' {p}' for p in dirty)) + untracked_before = set(before) + + env = pinned_env() + packages = [ROOT / p for p in args.packages] if args.packages else default_packages() + freezed = MODEL.with_name(MODEL.stem + '.freezed.dart') + try: + src = MODEL.read_text() + header_start, decl_start, body_start, body_end = class_span(src, name) + body, v10_params = remove_copy_with(src[body_start:body_end]) + if not v10_params: + sys.exit(f'`{name}` has no hand-written copyWith; freezed\'s is new API, not a behaviour change.') + src = src[:body_start] + body + src[body_end:] + src, was_freezed = put_on_freezed(src, name, header_start, decl_start) + MODEL.write_text(src) + + # build_runner caches by input digest, and an earlier run's outputs were restored behind its back, so a + # rerun would keep them. Deleting the output forces the rebuild. + freezed.unlink(missing_ok=True) + rel = os.path.relpath((ROOT / MODEL).resolve(), pkg) + r = sh(['dart', 'run', 'build_runner', 'build', f'--build-filter={rel[:-5]}.*.dart'], cwd=pkg, env=env) + generated = freezed.read_text() if freezed.exists() else '' + call = interface(generated, name) + if r.returncode or not call: + sys.exit(f'build_runner did not generate a copyWith for `{name}`:\n{r.stdout[-3000:]}{r.stderr[-3000:]}') + # A filtered build deletes the package's other outputs; put them back before analyzing. + restore_deleted(pkg) + + generated_params = interface_params(call.group(1)) + fields = field_types(body) + missing = [p for p in v10_params if p not in generated_params] + retyped = [(p, t, fields[p]) for p, t in generated_params.items() + if p in fields and bare(t) != bare(fields[p])] + ctor_traps = [p for p, t in generated_params.items() + if t.endswith('?') and p in fields and not fields[p].endswith('?')] + + print(f'Analyzing `{name}` (pass 1: compile breaks)…', file=sys.stderr) + compile_errors = analyze(packages, env) + + stripped = re.sub(r'^(\s*)(.+?)\?(\s+\w+,)\s*$', r'\1\2\3', call.group(1), flags=re.M) + freezed.write_text(generated[:call.start(1)] + stripped + generated[call.end(1):]) + print(f'Analyzing `{name}` (pass 2: nullable arguments)…', file=sys.stderr) + nullable_errors = {k: v for k, v in analyze(packages, env).items() if k not in compile_errors} + + report(name, was_freezed, compile_errors, nullable_errors, v10_params, missing, retyped, ctor_traps) + finally: + restore(MODEL, pkg, untracked_before) + restore_deleted(pkg) + print('Restored the model and its package\'s generated files.', file=sys.stderr) + + +def restore_deleted(pkg): + rel_pkg = os.path.relpath(pkg, ROOT) + deleted = [p for p in sh(['git', 'ls-files', '--deleted', '--', f'{rel_pkg}/lib']).stdout.split() + if p.endswith(GENERATED)] + if deleted: + sh(['git', 'checkout', '--', *deleted]) + + +# --- Report ------------------------------------------------------------------------------------------------------ + + +def report(name, was_freezed, compile_errors, nullable_errors, v10_params, missing, retyped, ctor_traps): + def row(key, value, tag=''): + (file, line, col), (code, msg) = key, value + arg = arg_name(file, line, col, code, msg) + kind = 'test' if '/test/' in f'/{file}' else 'lib' + return arg, f'- [{kind}] `{file}:{line}` `{arg}:`{tag}\n `{source_line(file, line)}`\n {msg}' + + missing_rows, compile_rows = [], [] + for key, value in sorted(compile_errors.items()): + arg, text = row(key, value) + (missing_rows if value[0] == 'UNDEFINED_NAMED_PARAMETER' and arg in missing else compile_rows).append(text) + + nullable_rows = [] + for key, value in sorted(nullable_errors.items()): + arg = arg_name(*key, *value) + sentinel = v10_params.get(arg, (None, False))[1] + nullable_rows.append(row(key, value, ' — v10 sentinel, same result' if sentinel + else ' — **v10 kept on null; check**')[1]) + + def section(title, items, note=None): + print(f'\n## {title}\n') + if note and items: + print(note + '\n') + print('\n'.join(items) if items else 'None.') + + print(f'# freezed copyWith audit: `{name}` ({MODEL})') + if not was_freezed: + print(f'\n`{name}` is not on freezed yet; the probe added `@freezed` for this run only. It audits `copyWith`; ' + 'freezed would also replace `==`, `hashCode` and `toString`, which this does not check.') + if missing or retyped: + print('\n**Verdict: freezed\'s `copyWith` cannot replace this one as it stands.** freezed builds `copyWith` ' + 'from the constructor and keeps a parameter only when a field has the same name and type. Keep ' + '`@Freezed(copyWith: false)` with the hand-written method, or reshape the constructor first.') + section('Parameters freezed does not generate', [f'- `{p}`' for p in missing] + missing_rows, + 'A constructor parameter backed by a getter, a private field or a differently named field gets no ' + '`copyWith` parameter. Every call below stops compiling.') + section('Parameters whose type changes', [f'- `{p}`: `{t}` in `copyWith`, `{f}` on the field' for p, t, f in retyped], + 'freezed types the parameter from the constructor, not the field.') + section('Compile breaks (non-nullable parameter given a nullable value, or a retyped parameter)', compile_rows) + section('Nullable argument sites (null now clears instead of keeping)', nullable_rows) + section('Constructor-default trap (passing null regenerates the default)', [f'- `{p}`' for p in ctor_traps]) + sentinels = [f'`{p}`' for p, (_, s) in v10_params.items() if s] + section('v10 parameters with a `_nullConst` sentinel', [', '.join(sentinels)] if sentinels else []) + + +MODEL = Path() + +if __name__ == '__main__': + main() diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index c4360979fe..c5253a0bf5 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -224,6 +224,7 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `CastPollVoteResponse` / `RemovePollVoteResponse` | `PollVoteResponse` | `renamed` | `vote` is nullable | | `queryPolls(pagination: PaginationParams(limit: l, next: n))` / `queryPollVotes(…)` | `queryPolls(limit: l, next: n)` / `queryPollVotes(…)` | `retyped` | `limit` is still 10 when omitted. Pass a response's `prev` as `prev` to page backwards. The other `PaginationParams` fields never had an effect on these queries | | `Channel.castPollVote` / `.removePollVote` throwing `ArgumentError` | a `Failure` carrying a `StreamClientException` | `retyped` | For an option or vote without an id | +| `Poll.copyWith` / `PollOption.copyWith` / `PollVote.copyWith` keeping a field passed as `null` | freezed's `copyWith` | `retyped` | `null` clears a nullable field and re-applies the constructor default of `Poll.id`, `createdAt` and `updatedAt`; `Poll.maxVotesAllowed` and `PollOption.id` already cleared | | `VotingVisibility` (enum) | `VotingVisibility` (extension type over `String`) | `retyped` | `VotingVisibility.public` and `.anonymous` are unchanged; a `switch` needs a default arm, and `.name` / `.values` are gone — read `.rawType` | | `Poll.fromJson` / `.toJson`, `PollOption.fromJson` / `.toJson`, `PollVote.fromJson` / `.toJson` | — | `removed` | The models are plain classes; construct them directly. `Message.poll` and the poll events still decode from the same keys | | — | `PollOption.fromData` / `.toData` | `added` | Read and write only the format the offline database stores; not a codec for API payloads | @@ -820,6 +821,22 @@ offline database stores; they are not a way to decode API responses. equal, so neither are the messages that carry them, and a widget comparing them rebuilds when only custom data changes. +**`copyWith` on `Poll`, `PollOption` and `PollVote` is freezed's.** Passing `null` for a nullable field clears it, +where v10 kept the value: `description`, `nameI18n`, `descriptionI18n`, `createdById` and `createdBy` on `Poll`, +`textI18n` on `PollOption`, and every nullable field of `PollVote`. `Poll.maxVotesAllowed` and `PollOption.id` +already cleared. Passing `null` for `Poll.id`, `createdAt` or `updatedAt`, or a vote's `createdAt` or +`updatedAt`, applies the constructor default — a new id or the current time — instead of keeping the value. Omit +the argument to keep a value. + +```dart +// v10: description stays 'Pick one' +poll.copyWith(description: null); + +// v11: description is null; omit it to keep it +poll.copyWith(description: null); +poll.copyWith(name: 'Dinner?'); +``` + **`StreamChatApi.polls` is removed.** Call the poll methods on `StreamChatClient` or `Channel` instead. ### File Upload diff --git a/openapi-migration/02-devices.md b/openapi-migration/02-devices.md index 174f1cfe52..5bc5624a3b 100644 --- a/openapi-migration/02-devices.md +++ b/openapi-migration/02-devices.md @@ -89,8 +89,8 @@ Revisited after landing: the group first adopted the generated `DeviceResponse`, 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] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels - included ([README rule 2](README.md#domain-models)). +- [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. diff --git a/openapi-migration/03-user-groups.md b/openapi-migration/03-user-groups.md index a8a8405930..e3f0cf9329 100644 --- a/openapi-migration/03-user-groups.md +++ b/openapi-migration/03-user-groups.md @@ -73,8 +73,8 @@ Follows the [domain-model rules](README.md#domain-models). 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] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels - included ([README rule 2](README.md#domain-models)). +- [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. diff --git a/openapi-migration/04-roles-guest-and-app.md b/openapi-migration/04-roles-guest-and-app.md index 8753aa331e..db99df5cf1 100644 --- a/openapi-migration/04-roles-guest-and-app.md +++ b/openapi-migration/04-roles-guest-and-app.md @@ -113,8 +113,8 @@ 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] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels - included ([README rule 2](README.md#domain-models)). +- [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. diff --git a/openapi-migration/05-polls.md b/openapi-migration/05-polls.md index db1210d227..107b243fb8 100644 --- a/openapi-migration/05-polls.md +++ b/openapi-migration/05-polls.md @@ -84,6 +84,11 @@ `Event` writes these fields too, so its converters run both ways. - **`PollOption` gets the `@DataSerializable` codec** for `polls.options`. The stored format nests custom data under `extra_data` where v10 flattened it, so `schemaVersion` is bumped. +- **`Poll`, `PollOption` and `PollVote` use freezed's `copyWith`.** The audit found one lib site + (`stream_message_preview_text.dart`, a `String?` passed for `name`) and one test per model; the + merging of poll events in `channel_state_mutations.dart` and `Poll.withTranslationsOf` passes no + argument that is null while the field is set, so it is unchanged. `null` now clears a nullable field + and re-applies the `id` and timestamp defaults, recorded as a break. - **`extraData` keeps its name.** Renaming it to `custom` is a migration-wide step for every model at once — [group 15](15-custom-data-rename.md). - **Verified live** against the demo app with a before/after harness covering every endpoint, the @@ -101,8 +106,8 @@ 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] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels - included ([README rule 2](README.md#domain-models)). +- [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. diff --git a/openapi-migration/06-reminders.md b/openapi-migration/06-reminders.md index 969d08f45e..91be7b453e 100644 --- a/openapi-migration/06-reminders.md +++ b/openapi-migration/06-reminders.md @@ -41,8 +41,8 @@ hand-written, with the reason. - [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. -- [ ] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels - included ([README rule 2](README.md#domain-models)). +- [ ] `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)). - [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. diff --git a/openapi-migration/07-threads-and-drafts.md b/openapi-migration/07-threads-and-drafts.md index 2c9e6ba997..d608081b61 100644 --- a/openapi-migration/07-threads-and-drafts.md +++ b/openapi-migration/07-threads-and-drafts.md @@ -48,8 +48,8 @@ hand-written, with the reason. - [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. -- [ ] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels - included ([README rule 2](README.md#domain-models)). +- [ ] `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)). - [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. diff --git a/openapi-migration/08-moderation-and-blocklists.md b/openapi-migration/08-moderation-and-blocklists.md index 1b2e34a44b..660da8e4a8 100644 --- a/openapi-migration/08-moderation-and-blocklists.md +++ b/openapi-migration/08-moderation-and-blocklists.md @@ -187,8 +187,8 @@ 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] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels - included ([README rule 2](README.md#domain-models)). +- [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. diff --git a/openapi-migration/09-users.md b/openapi-migration/09-users.md index db346a7ec4..72c8c66f31 100644 --- a/openapi-migration/09-users.md +++ b/openapi-migration/09-users.md @@ -59,8 +59,8 @@ hand-written, with the reason. - [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. -- [ ] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels - included ([README rule 2](README.md#domain-models)). +- [ ] `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)). - [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. diff --git a/openapi-migration/10-messages.md b/openapi-migration/10-messages.md index 09c47be804..14b775439f 100644 --- a/openapi-migration/10-messages.md +++ b/openapi-migration/10-messages.md @@ -65,8 +65,8 @@ hand-written, with the reason. - [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. -- [ ] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels - included ([README rule 2](README.md#domain-models)). +- [ ] `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)). - [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. diff --git a/openapi-migration/11-channels-and-members.md b/openapi-migration/11-channels-and-members.md index b7344cd8bb..f0d61cfe18 100644 --- a/openapi-migration/11-channels-and-members.md +++ b/openapi-migration/11-channels-and-members.md @@ -89,8 +89,8 @@ hand-written, with the reason. - [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. -- [ ] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels - included ([README rule 2](README.md#domain-models)). +- [ ] `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)). - [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. diff --git a/openapi-migration/12-uploads-cdn.md b/openapi-migration/12-uploads-cdn.md index 3c21aa4875..d92b88f161 100644 --- a/openapi-migration/12-uploads-cdn.md +++ b/openapi-migration/12-uploads-cdn.md @@ -74,8 +74,8 @@ 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] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels - included ([README rule 2](README.md#domain-models)). +- [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. diff --git a/openapi-migration/13-push-preferences.md b/openapi-migration/13-push-preferences.md index 2dafa94a72..a3eebd8aa8 100644 --- a/openapi-migration/13-push-preferences.md +++ b/openapi-migration/13-push-preferences.md @@ -38,8 +38,8 @@ hand-written, with the reason. - [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. -- [ ] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels - included ([README rule 2](README.md#domain-models)). +- [ ] `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)). - [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. diff --git a/openapi-migration/14-banned-users.md b/openapi-migration/14-banned-users.md index 2ec0b23dad..a5b5db0516 100644 --- a/openapi-migration/14-banned-users.md +++ b/openapi-migration/14-banned-users.md @@ -45,8 +45,8 @@ hand-written, with the reason. - [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. -- [ ] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels - included ([README rule 2](README.md#domain-models)). +- [ ] `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)). - [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. diff --git a/openapi-migration/README.md b/openapi-migration/README.md index 9a62bc921f..9523e7966c 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -100,9 +100,17 @@ includes a named response that carries only `duration` today, such as `HideChann (see the breaks above). A field the server adds is exposed later, as an additive change. The one exception to "no JSON" is the temporary `@DataSerializable` storage codec in rule 8. - **A model that had a `copyWith` in v10 keeps that exact method,** `_nullConst` sentinels included, under - `@Freezed(copyWith: false)`. freezed's own `copyWith` sets a field passed as `null`, where v10's keeps it, and - the SDK's state handling depends on the difference. Only a model with no v10 `copyWith` uses freezed's. + **A model uses freezed's `copyWith`.** It differs from a v10 hand-written one: `null` clears a nullable field + instead of keeping it, `null` re-runs a constructor default (`id ?? Uuid().v4()`), and a constructor parameter + with no field of the same name and type is not generated. Before deleting the v10 method, run the audit in + [`.claude/skills/openapi-migration/copywith-audit.md`](../.claude/skills/openapi-migration/copywith-audit.md) + and handle every site it reports: a compile break gets a local or a `?? model.field`; a "kept on null; check" + site is rewritten as an explicit merge (`?? this.x` at the caller, or an `@internal` merge method such as + `Poll.withTranslationsOf`) unless the clear is the intended behaviour. When the audit's verdict says freezed + cannot replace the method — a parameter backed by a getter, a private field or a different type, as on + `Message` — keep `@Freezed(copyWith: false)` with the hand-written method, or reshape the constructor as a + public API change of its own. The semantics change goes in the CHANGELOG and the guide, listed per field under + the rows of the audit's table. **Names default to v10's,** even where the generated name differs: `OGAttachmentResponse` rather than `GetOGResponse`, `AppSettings` rather than `AppResponseFields`, `UploadConfig` rather than `FileUploadConfig`. diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index f648d0191b..6e1c1ea7f9 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -76,8 +76,8 @@ def match(path): hand-written, with the reason. - [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. - - [ ] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels - included ([README rule 2](README.md#domain-models)). + - [ ] `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)). - [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. @@ -364,6 +364,11 @@ def match(path): `Event` writes these fields too, so its converters run both ways. - **`PollOption` gets the `@DataSerializable` codec** for `polls.options`. The stored format nests custom data under `extra_data` where v10 flattened it, so `schemaVersion` is bumped. + - **`Poll`, `PollOption` and `PollVote` use freezed's `copyWith`.** The audit found one lib site + (`stream_message_preview_text.dart`, a `String?` passed for `name`) and one test per model; the + merging of poll events in `channel_state_mutations.dart` and `Poll.withTranslationsOf` passes no + argument that is null while the field is set, so it is unchanged. `null` now clears a nullable field + and re-applies the `id` and timestamp defaults, recorded as a break. - **`extraData` keeps its name.** Renaming it to `custom` is a migration-wide step for every model at once — [group 15](15-custom-data-rename.md). - **Verified live** against the demo app with a before/after harness covering every endpoint, the diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 58f25556ec..a8c822901b 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -86,6 +86,7 @@ - `Poll`, `PollOption`, `PollVote` and the poll responses no longer decode from or encode to JSON; `PollOption` gains `fromData` and `toData`, which read and write only the format `stream_chat_persistence` stores it in. - The poll responses are immutable, built through a const constructor, and their `duration` is a non-nullable `String`; `PollVoteResponse.vote` is nullable. - `Poll`, `PollOption` and `PollVote` no longer extend `Equatable`, so `props` is removed; they still compare by value, now including `extraData`. +- `Poll.copyWith`, `PollOption.copyWith` and `PollVote.copyWith` set a nullable field passed as `null` to `null` instead of keeping its value, and give `id`, `createdAt` and `updatedAt` a fresh default when passed `null`. - `StreamChatApi.polls` is removed; its endpoints are reached through `StreamChatClient`. - The poll and poll option requests no longer send `extraData` entries named like one of the poll's or option's own fields. - `sendImage`, `sendFile`, `uploadImage` and `uploadFile`, on `StreamChatClient`, `Channel` and `AttachmentFileUploader`, return a `Result` instead of throwing; read the URL from `fileUrl` and a video's thumbnail from `thumbUrl`. diff --git a/packages/stream_chat/lib/src/core/models/poll.dart b/packages/stream_chat/lib/src/core/models/poll.dart index 1a947ddfdf..a3a42b925a 100644 --- a/packages/stream_chat/lib/src/core/models/poll.dart +++ b/packages/stream_chat/lib/src/core/models/poll.dart @@ -11,18 +11,12 @@ import 'voting_visibility.dart'; part 'poll.freezed.dart'; -class _NullConst { - const _NullConst(); -} - -const _nullConst = _NullConst(); - /// A question with a set of options that the members of a channel vote on. /// /// A poll is sent in a message. Besides its settings it carries a summary of /// the votes so far: the counts per option, the latest votes and answers, and /// the votes of the current user. -@Freezed(copyWith: false) +@freezed class Poll with _$Poll { /// Creates a new [Poll]. /// @@ -181,57 +175,6 @@ class Poll with _$Poll { @override final Map extraData; - /// Creates a copy of [Poll] with specified attributes overridden. - Poll copyWith({ - String? id, - String? name, - Map? nameI18n, - String? description, - Map? descriptionI18n, - List? options, - VotingVisibility? votingVisibility, - bool? enforceUniqueVote, - Object? maxVotesAllowed = _nullConst, - bool? allowUserSuggestedOptions, - bool? allowAnswers, - bool? isClosed, - Map? voteCountsByOption, - List? ownVotesAndAnswers, - int? voteCount, - int? answersCount, - Map>? latestVotesByOption, - List? latestAnswers, - String? createdById, - User? createdBy, - DateTime? createdAt, - DateTime? updatedAt, - Map? extraData, - }) => Poll( - id: id ?? this.id, - name: name ?? this.name, - nameI18n: nameI18n ?? this.nameI18n, - description: description ?? this.description, - descriptionI18n: descriptionI18n ?? this.descriptionI18n, - options: options ?? this.options, - votingVisibility: votingVisibility ?? this.votingVisibility, - enforceUniqueVote: enforceUniqueVote ?? this.enforceUniqueVote, - maxVotesAllowed: maxVotesAllowed == _nullConst ? this.maxVotesAllowed : maxVotesAllowed as int?, - allowUserSuggestedOptions: allowUserSuggestedOptions ?? this.allowUserSuggestedOptions, - allowAnswers: allowAnswers ?? this.allowAnswers, - isClosed: isClosed ?? this.isClosed, - voteCountsByOption: voteCountsByOption ?? this.voteCountsByOption, - ownVotesAndAnswers: ownVotesAndAnswers ?? this.ownVotesAndAnswers, - voteCount: voteCount ?? this.voteCount, - answersCount: answersCount ?? this.answersCount, - latestVotesByOption: latestVotesByOption ?? this.latestVotesByOption, - latestAnswers: latestAnswers ?? this.latestAnswers, - createdById: createdById ?? this.createdById, - createdBy: createdBy ?? this.createdBy, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - extraData: extraData ?? this.extraData, - ); - /// This poll with the translations of [oldPoll] filled in where this one /// has none. /// diff --git a/packages/stream_chat/lib/src/core/models/poll.freezed.dart b/packages/stream_chat/lib/src/core/models/poll.freezed.dart index bd37b1eac1..b6525a5e35 100644 --- a/packages/stream_chat/lib/src/core/models/poll.freezed.dart +++ b/packages/stream_chat/lib/src/core/models/poll.freezed.dart @@ -40,6 +40,12 @@ mixin _$Poll { DateTime get updatedAt; Map get extraData; + /// Create a copy of Poll + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $PollCopyWith get copyWith => _$PollCopyWithImpl(this as Poll, _$identity); + @override bool operator ==(Object other) { return identical(this, other) || @@ -49,24 +55,48 @@ mixin _$Poll { (identical(other.name, name) || other.name == name) && const DeepCollectionEquality().equals(other.nameI18n, nameI18n) && (identical(other.description, description) || other.description == description) && - const DeepCollectionEquality().equals(other.descriptionI18n, descriptionI18n) && + const DeepCollectionEquality().equals( + other.descriptionI18n, + descriptionI18n, + ) && const DeepCollectionEquality().equals(other.options, options) && (identical(other.votingVisibility, votingVisibility) || other.votingVisibility == votingVisibility) && (identical(other.enforceUniqueVote, enforceUniqueVote) || other.enforceUniqueVote == enforceUniqueVote) && (identical(other.maxVotesAllowed, maxVotesAllowed) || other.maxVotesAllowed == maxVotesAllowed) && - (identical(other.allowUserSuggestedOptions, allowUserSuggestedOptions) || + (identical( + other.allowUserSuggestedOptions, + allowUserSuggestedOptions, + ) || other.allowUserSuggestedOptions == allowUserSuggestedOptions) && (identical(other.allowAnswers, allowAnswers) || other.allowAnswers == allowAnswers) && (identical(other.isClosed, isClosed) || other.isClosed == isClosed) && (identical(other.answersCount, answersCount) || other.answersCount == answersCount) && - const DeepCollectionEquality().equals(other.voteCountsByOption, voteCountsByOption) && - const DeepCollectionEquality().equals(other.latestVotesByOption, latestVotesByOption) && - const DeepCollectionEquality().equals(other.latestVotes, latestVotes) && - const DeepCollectionEquality().equals(other.latestAnswers, latestAnswers) && - const DeepCollectionEquality().equals(other.ownVotesAndAnswers, ownVotesAndAnswers) && + const DeepCollectionEquality().equals( + other.voteCountsByOption, + voteCountsByOption, + ) && + const DeepCollectionEquality().equals( + other.latestVotesByOption, + latestVotesByOption, + ) && + const DeepCollectionEquality().equals( + other.latestVotes, + latestVotes, + ) && + const DeepCollectionEquality().equals( + other.latestAnswers, + latestAnswers, + ) && + const DeepCollectionEquality().equals( + other.ownVotesAndAnswers, + ownVotesAndAnswers, + ) && (identical(other.voteCount, voteCount) || other.voteCount == voteCount) && const DeepCollectionEquality().equals(other.ownVotes, ownVotes) && - const DeepCollectionEquality().equals(other.ownAnswers, ownAnswers) && + const DeepCollectionEquality().equals( + other.ownAnswers, + ownAnswers, + ) && (identical(other.createdById, createdById) || other.createdById == createdById) && (identical(other.createdBy, createdBy) || other.createdBy == createdBy) && (identical(other.createdAt, createdAt) || other.createdAt == createdAt) && @@ -110,3 +140,169 @@ mixin _$Poll { return 'Poll(id: $id, name: $name, nameI18n: $nameI18n, description: $description, descriptionI18n: $descriptionI18n, options: $options, votingVisibility: $votingVisibility, enforceUniqueVote: $enforceUniqueVote, maxVotesAllowed: $maxVotesAllowed, allowUserSuggestedOptions: $allowUserSuggestedOptions, allowAnswers: $allowAnswers, isClosed: $isClosed, answersCount: $answersCount, voteCountsByOption: $voteCountsByOption, latestVotesByOption: $latestVotesByOption, latestVotes: $latestVotes, latestAnswers: $latestAnswers, ownVotesAndAnswers: $ownVotesAndAnswers, voteCount: $voteCount, ownVotes: $ownVotes, ownAnswers: $ownAnswers, createdById: $createdById, createdBy: $createdBy, createdAt: $createdAt, updatedAt: $updatedAt, extraData: $extraData)'; } } + +/// @nodoc +abstract mixin class $PollCopyWith<$Res> { + factory $PollCopyWith(Poll value, $Res Function(Poll) _then) = _$PollCopyWithImpl; + @useResult + $Res call({ + String? id, + String name, + Map? nameI18n, + String? description, + Map? descriptionI18n, + List options, + VotingVisibility votingVisibility, + bool enforceUniqueVote, + int? maxVotesAllowed, + bool allowAnswers, + List latestAnswers, + int answersCount, + bool allowUserSuggestedOptions, + bool isClosed, + DateTime? createdAt, + DateTime? updatedAt, + Map voteCountsByOption, + int voteCount, + Map> latestVotesByOption, + String? createdById, + User? createdBy, + List ownVotesAndAnswers, + Map extraData, + }); +} + +/// @nodoc +class _$PollCopyWithImpl<$Res> implements $PollCopyWith<$Res> { + _$PollCopyWithImpl(this._self, this._then); + + final Poll _self; + final $Res Function(Poll) _then; + + /// Create a copy of Poll + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = freezed, + Object? name = null, + Object? nameI18n = freezed, + Object? description = freezed, + Object? descriptionI18n = freezed, + Object? options = null, + Object? votingVisibility = null, + Object? enforceUniqueVote = null, + Object? maxVotesAllowed = freezed, + Object? allowAnswers = null, + Object? latestAnswers = null, + Object? answersCount = null, + Object? allowUserSuggestedOptions = null, + Object? isClosed = null, + Object? createdAt = freezed, + Object? updatedAt = freezed, + Object? voteCountsByOption = null, + Object? voteCount = null, + Object? latestVotesByOption = null, + Object? createdById = freezed, + Object? createdBy = freezed, + Object? ownVotesAndAnswers = null, + Object? extraData = null, + }) { + return _then( + Poll( + id: freezed == id + ? _self.id! + : id // ignore: cast_nullable_to_non_nullable + as String?, + name: null == name + ? _self.name + : name // ignore: cast_nullable_to_non_nullable + as String, + nameI18n: freezed == nameI18n + ? _self.nameI18n + : nameI18n // ignore: cast_nullable_to_non_nullable + as Map?, + description: freezed == description + ? _self.description + : description // ignore: cast_nullable_to_non_nullable + as String?, + descriptionI18n: freezed == descriptionI18n + ? _self.descriptionI18n + : descriptionI18n // ignore: cast_nullable_to_non_nullable + as Map?, + options: null == options + ? _self.options + : options // ignore: cast_nullable_to_non_nullable + as List, + votingVisibility: null == votingVisibility + ? _self.votingVisibility + : votingVisibility // ignore: cast_nullable_to_non_nullable + as VotingVisibility, + enforceUniqueVote: null == enforceUniqueVote + ? _self.enforceUniqueVote + : enforceUniqueVote // ignore: cast_nullable_to_non_nullable + as bool, + maxVotesAllowed: freezed == maxVotesAllowed + ? _self.maxVotesAllowed + : maxVotesAllowed // ignore: cast_nullable_to_non_nullable + as int?, + allowAnswers: null == allowAnswers + ? _self.allowAnswers + : allowAnswers // ignore: cast_nullable_to_non_nullable + as bool, + latestAnswers: null == latestAnswers + ? _self.latestAnswers + : latestAnswers // ignore: cast_nullable_to_non_nullable + as List, + answersCount: null == answersCount + ? _self.answersCount + : answersCount // ignore: cast_nullable_to_non_nullable + as int, + allowUserSuggestedOptions: null == allowUserSuggestedOptions + ? _self.allowUserSuggestedOptions + : allowUserSuggestedOptions // ignore: cast_nullable_to_non_nullable + as bool, + isClosed: null == isClosed + ? _self.isClosed + : isClosed // ignore: cast_nullable_to_non_nullable + as bool, + createdAt: freezed == createdAt + ? _self.createdAt! + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + updatedAt: freezed == updatedAt + ? _self.updatedAt! + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + voteCountsByOption: null == voteCountsByOption + ? _self.voteCountsByOption + : voteCountsByOption // ignore: cast_nullable_to_non_nullable + as Map, + voteCount: null == voteCount + ? _self.voteCount + : voteCount // ignore: cast_nullable_to_non_nullable + as int, + latestVotesByOption: null == latestVotesByOption + ? _self.latestVotesByOption + : latestVotesByOption // ignore: cast_nullable_to_non_nullable + as Map>, + createdById: freezed == createdById + ? _self.createdById + : createdById // ignore: cast_nullable_to_non_nullable + as String?, + createdBy: freezed == createdBy + ? _self.createdBy + : createdBy // ignore: cast_nullable_to_non_nullable + as User?, + ownVotesAndAnswers: null == ownVotesAndAnswers + ? _self.ownVotesAndAnswers + : ownVotesAndAnswers // ignore: cast_nullable_to_non_nullable + as List, + extraData: null == extraData + ? _self.extraData + : extraData // ignore: cast_nullable_to_non_nullable + as Map, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/poll_option.dart b/packages/stream_chat/lib/src/core/models/poll_option.dart index ab3083547e..2d6ccf9c00 100644 --- a/packages/stream_chat/lib/src/core/models/poll_option.dart +++ b/packages/stream_chat/lib/src/core/models/poll_option.dart @@ -5,14 +5,8 @@ import '../../db/data_serializable.dart'; part 'poll_option.freezed.dart'; part 'poll_option.g.dart'; -class _NullConst { - const _NullConst(); -} - -const _nullConst = _NullConst(); - /// One of the choices a poll offers to vote on. -@Freezed(copyWith: false) +@freezed // TODO(openapi-migration): remove in group 10 @DataSerializable(includeIfNull: false) class PollOption with _$PollOption { @@ -52,19 +46,6 @@ class PollOption with _$PollOption { @override final Map extraData; - /// Creates a copy of [PollOption] with specified attributes overridden. - PollOption copyWith({ - Object? id = _nullConst, - String? text, - Map? textI18n, - Map? extraData, - }) => PollOption( - id: id == _nullConst ? this.id : id as String?, - text: text ?? this.text, - textI18n: textI18n ?? this.textI18n, - extraData: extraData ?? this.extraData, - ); - /// The keys an option carries besides its custom data. static const topLevelFields = [ 'id', diff --git a/packages/stream_chat/lib/src/core/models/poll_option.freezed.dart b/packages/stream_chat/lib/src/core/models/poll_option.freezed.dart index 3b544086e1..7bdee3d22b 100644 --- a/packages/stream_chat/lib/src/core/models/poll_option.freezed.dart +++ b/packages/stream_chat/lib/src/core/models/poll_option.freezed.dart @@ -18,6 +18,12 @@ mixin _$PollOption { Map? get textI18n; Map get extraData; + /// Create a copy of PollOption + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $PollOptionCopyWith get copyWith => _$PollOptionCopyWithImpl(this as PollOption, _$identity); + @override bool operator ==(Object other) { return identical(this, other) || @@ -43,3 +49,58 @@ mixin _$PollOption { return 'PollOption(id: $id, text: $text, textI18n: $textI18n, extraData: $extraData)'; } } + +/// @nodoc +abstract mixin class $PollOptionCopyWith<$Res> { + factory $PollOptionCopyWith( + PollOption value, + $Res Function(PollOption) _then, + ) = _$PollOptionCopyWithImpl; + @useResult + $Res call({ + String? id, + String text, + Map? textI18n, + Map extraData, + }); +} + +/// @nodoc +class _$PollOptionCopyWithImpl<$Res> implements $PollOptionCopyWith<$Res> { + _$PollOptionCopyWithImpl(this._self, this._then); + + final PollOption _self; + final $Res Function(PollOption) _then; + + /// Create a copy of PollOption + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = freezed, + Object? text = null, + Object? textI18n = freezed, + Object? extraData = null, + }) { + return _then( + PollOption( + id: freezed == id + ? _self.id + : id // ignore: cast_nullable_to_non_nullable + as String?, + text: null == text + ? _self.text + : text // ignore: cast_nullable_to_non_nullable + as String, + textI18n: freezed == textI18n + ? _self.textI18n + : textI18n // ignore: cast_nullable_to_non_nullable + as Map?, + extraData: null == extraData + ? _self.extraData + : extraData // ignore: cast_nullable_to_non_nullable + as Map, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/poll_vote.dart b/packages/stream_chat/lib/src/core/models/poll_vote.dart index a7250a2d7f..8e85aa1a25 100644 --- a/packages/stream_chat/lib/src/core/models/poll_vote.dart +++ b/packages/stream_chat/lib/src/core/models/poll_vote.dart @@ -9,7 +9,7 @@ part 'poll_vote.freezed.dart'; /// /// A vote carries an [optionId] and an answer carries [answerText]; [isAnswer] /// tells the two apart. -@Freezed(copyWith: false) +@freezed class PollVote with _$PollVote { /// Creates a new [PollVote]. /// @@ -80,29 +80,6 @@ class PollVote with _$PollVote { /// Whether this is a free-form answer rather than a vote for an option. bool get isAnswer => answerText != null; - - /// Creates a copy of [PollVote] with specified attributes overridden. - PollVote copyWith({ - String? id, - String? pollId, - String? optionId, - String? answerText, - Map? answerTextI18n, - DateTime? createdAt, - DateTime? updatedAt, - String? userId, - User? user, - }) => PollVote( - id: id ?? this.id, - pollId: pollId ?? this.pollId, - optionId: optionId ?? this.optionId, - answerText: answerText ?? this.answerText, - answerTextI18n: answerTextI18n ?? this.answerTextI18n, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - userId: userId ?? this.userId, - user: user ?? this.user, - ); } /// A filter for a poll vote query. diff --git a/packages/stream_chat/lib/src/core/models/poll_vote.freezed.dart b/packages/stream_chat/lib/src/core/models/poll_vote.freezed.dart index 90f67fbf02..51ff240cbd 100644 --- a/packages/stream_chat/lib/src/core/models/poll_vote.freezed.dart +++ b/packages/stream_chat/lib/src/core/models/poll_vote.freezed.dart @@ -23,6 +23,12 @@ mixin _$PollVote { String? get userId; User? get user; + /// Create a copy of PollVote + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $PollVoteCopyWith get copyWith => _$PollVoteCopyWithImpl(this as PollVote, _$identity); + @override bool operator ==(Object other) { return identical(this, other) || @@ -32,7 +38,10 @@ mixin _$PollVote { (identical(other.pollId, pollId) || other.pollId == pollId) && (identical(other.optionId, optionId) || other.optionId == optionId) && (identical(other.answerText, answerText) || other.answerText == answerText) && - const DeepCollectionEquality().equals(other.answerTextI18n, answerTextI18n) && + const DeepCollectionEquality().equals( + other.answerTextI18n, + answerTextI18n, + ) && (identical(other.createdAt, createdAt) || other.createdAt == createdAt) && (identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt) && (identical(other.userId, userId) || other.userId == userId) && @@ -58,3 +67,85 @@ mixin _$PollVote { return 'PollVote(id: $id, pollId: $pollId, optionId: $optionId, answerText: $answerText, answerTextI18n: $answerTextI18n, createdAt: $createdAt, updatedAt: $updatedAt, userId: $userId, user: $user)'; } } + +/// @nodoc +abstract mixin class $PollVoteCopyWith<$Res> { + factory $PollVoteCopyWith(PollVote value, $Res Function(PollVote) _then) = _$PollVoteCopyWithImpl; + @useResult + $Res call({ + String? id, + String? pollId, + String? optionId, + String? answerText, + Map? answerTextI18n, + DateTime? createdAt, + DateTime? updatedAt, + String? userId, + User? user, + }); +} + +/// @nodoc +class _$PollVoteCopyWithImpl<$Res> implements $PollVoteCopyWith<$Res> { + _$PollVoteCopyWithImpl(this._self, this._then); + + final PollVote _self; + final $Res Function(PollVote) _then; + + /// Create a copy of PollVote + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = freezed, + Object? pollId = freezed, + Object? optionId = freezed, + Object? answerText = freezed, + Object? answerTextI18n = freezed, + Object? createdAt = freezed, + Object? updatedAt = freezed, + Object? userId = freezed, + Object? user = freezed, + }) { + return _then( + PollVote( + id: freezed == id + ? _self.id + : id // ignore: cast_nullable_to_non_nullable + as String?, + pollId: freezed == pollId + ? _self.pollId + : pollId // ignore: cast_nullable_to_non_nullable + as String?, + optionId: freezed == optionId + ? _self.optionId + : optionId // ignore: cast_nullable_to_non_nullable + as String?, + answerText: freezed == answerText + ? _self.answerText + : answerText // ignore: cast_nullable_to_non_nullable + as String?, + answerTextI18n: freezed == answerTextI18n + ? _self.answerTextI18n + : answerTextI18n // ignore: cast_nullable_to_non_nullable + as Map?, + createdAt: freezed == createdAt + ? _self.createdAt! + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + updatedAt: freezed == updatedAt + ? _self.updatedAt! + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + userId: freezed == userId + ? _self.userId + : userId // ignore: cast_nullable_to_non_nullable + as String?, + user: freezed == user + ? _self.user + : user // ignore: cast_nullable_to_non_nullable + as User?, + ), + ); + } +} diff --git a/packages/stream_chat/test/src/core/models/poll_option_test.dart b/packages/stream_chat/test/src/core/models/poll_option_test.dart index 433cab319a..3a5424a32b 100644 --- a/packages/stream_chat/test/src/core/models/poll_option_test.dart +++ b/packages/stream_chat/test/src/core/models/poll_option_test.dart @@ -37,9 +37,9 @@ void main() { expect(option.copyWith(id: null).id, isNull); }); - test('PollOption.copyWith keeps the text and custom data passed as null', () { - const option = PollOption(id: 'pizza', text: 'Pizza', extraData: {'color': 'red'}); + test('PollOption.copyWith clears the translations passed as null', () { + const option = PollOption(id: 'pizza', text: 'Pizza', textI18n: {'it': 'Pizza'}); - expect(option.copyWith(text: null, extraData: null), option); + expect(option.copyWith(textI18n: null).textI18n, isNull); }); } diff --git a/packages/stream_chat/test/src/core/models/poll_test.dart b/packages/stream_chat/test/src/core/models/poll_test.dart index 6b87abaacc..d88b6b2695 100644 --- a/packages/stream_chat/test/src/core/models/poll_test.dart +++ b/packages/stream_chat/test/src/core/models/poll_test.dart @@ -1,6 +1,7 @@ import 'package:stream_chat/src/core/models/poll.dart'; import 'package:stream_chat/src/core/models/poll_option.dart'; import 'package:stream_chat/src/core/models/poll_vote.dart'; +import 'package:stream_chat/src/core/models/user.dart'; import 'package:stream_chat/src/core/models/voting_visibility.dart'; import 'package:test/test.dart'; @@ -28,18 +29,13 @@ void main() { expect(poll.copyWith(maxVotesAllowed: null).maxVotesAllowed, isNull); }); - test('Poll.copyWith keeps a field passed as null', () { - final poll = createTestPoll( - id: 'poll-id', - name: 'Lunch?', - description: 'Pick one', - createdAt: DateTime.utc(2023), - updatedAt: DateTime.utc(2023), - ); + test('Poll.copyWith clears a nullable field passed as null', () { + final poll = createTestPoll(name: 'Lunch?', description: 'Pick one').copyWith(createdBy: User(id: 'user-id')); - final copy = poll.copyWith(id: null, description: null, createdAt: null, updatedAt: null); + final copy = poll.copyWith(description: null, createdBy: null); - expect(copy, poll); + expect(copy.description, isNull); + expect(copy.createdBy, isNull); }); test('Poll.latestVotes holds the latest votes of every option', () { diff --git a/packages/stream_chat/test/src/core/models/poll_vote_test.dart b/packages/stream_chat/test/src/core/models/poll_vote_test.dart index ce0ada7931..bb21b6803e 100644 --- a/packages/stream_chat/test/src/core/models/poll_vote_test.dart +++ b/packages/stream_chat/test/src/core/models/poll_vote_test.dart @@ -17,12 +17,13 @@ void main() { expect(vote.isAnswer, isFalse); }); - test('PollVote.copyWith keeps a field passed as null', () { - final vote = createTestPollVote(id: 'vote-id', optionId: 'pizza', userId: 'user-id'); + test('PollVote.copyWith clears a nullable field passed as null', () { + final vote = createTestPollVote(id: 'vote-id', optionId: 'pizza', userId: 'user-id', user: User(id: 'user-id')); - final copy = vote.copyWith(id: null, optionId: null, userId: null, createdAt: null); + final copy = vote.copyWith(userId: null, user: null); - expect(copy, vote); + expect(copy.userId, isNull); + expect(copy.user, isNull); }); test('PollVoteSortField.id orders alphabetically', () { diff --git a/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart b/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart index 1925aa251b..0ffbdc188d 100644 --- a/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart +++ b/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart @@ -91,10 +91,8 @@ class StreamMessagePreviewText extends StatelessWidget { @internal Message translateForPreview(Message message, String? language) { final translated = message.translate(language); - return switch (message.poll) { - final poll? when poll.translatedName(language) != null => translated.copyWith( - poll: poll.copyWith(name: poll.translatedName(language)), - ), + return switch ((message.poll, message.poll?.translatedName(language))) { + (final poll?, final name?) => translated.copyWith(poll: poll.copyWith(name: name)), _ => translated, }; } From 27b3ad3724332b4eede82b17354ebad9734fa82e Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 13 Oct 2026 15:58:02 +0200 Subject: [PATCH 23/32] refactor(llc): apply the polls review fixes and bump the cache schema Co-Authored-By: Claude Fable 5.1 --- .claude/skills/openapi-migration/copywith-audit.md | 7 ------- openapi-migration/05-polls.md | 7 ++++--- openapi-migration/07-threads-and-drafts.md | 1 + openapi-migration/README.md | 2 +- openapi-migration/tool/generate_plan.py | 10 +++++++--- packages/stream_chat/CHANGELOG.md | 2 +- .../src/core/models/converters/v1_json_converters.dart | 4 ++-- .../stream_chat/lib/src/core/models/poll_vote.dart | 2 +- .../lib/src/stream_poll_vote_list_controller.dart | 1 - packages/stream_chat_persistence/CHANGELOG.md | 1 + .../lib/src/db/drift_chat_database.dart | 2 +- 11 files changed, 19 insertions(+), 20 deletions(-) diff --git a/.claude/skills/openapi-migration/copywith-audit.md b/.claude/skills/openapi-migration/copywith-audit.md index a0e6dc4e17..bf491219c9 100644 --- a/.claude/skills/openapi-migration/copywith-audit.md +++ b/.claude/skills/openapi-migration/copywith-audit.md @@ -6,13 +6,6 @@ compiles cleanly. Run this audit per model before deleting the old method. It li tests, whose behaviour or compilation changes, and tells you when freezed's `copyWith` can't replace the model's at all. -## Install - -Two files, meant to live in the openapi-migration skill: - -* `.claude/skills/openapi-migration/copywith-audit.md`: this doc (everything above the Script section) -* `.claude/skills/openapi-migration/scripts/copywith_audit.py`: the script at the bottom - ## What changes | Parameter | v10 hand-written | freezed | How it shows up | diff --git a/openapi-migration/05-polls.md b/openapi-migration/05-polls.md index 107b243fb8..063d195ec4 100644 --- a/openapi-migration/05-polls.md +++ b/openapi-migration/05-polls.md @@ -37,8 +37,8 @@ - **`Poll`, `PollOption` and `PollVote` keep their v10 names, fields and defaults,** as `@freezed` classes with no JSON, mapped from `PollResponseData`, `PollOptionResponseData` and `PollVoteResponseData` in `lib/src/repository/mapper/polls_mapper.dart`. The uuid default `id` and the - `DateTime.now()` timestamps stay; `latestVotes`, `ownVotes`, `ownAnswers` and `PollVote.isAnswer` - become getters. + `DateTime.now()` timestamps stay; `PollVote.isAnswer` becomes a getter, and `latestVotes`, `ownVotes` and + `ownAnswers` stay `late final` caches. - **Equality now includes `extraData`.** v10's `Equatable` props left it out of `Poll` and `PollOption`; freezed compares every field, and so does `Message`, whose props include its poll. Accepted as a behavioural break and flagged for review. @@ -57,7 +57,8 @@ for review. - **Option ids are not sent on create.** `PollOptionInput` has none; the server assigns them and rejects a client id as reserved (confirmed live: v10 answered 400). **`updatePoll` and - `updatePollOption` fail without a request when an option has no id,** since the server requires one. + `updatePollOption` send an option without an id with an empty one** and return the server's failure; + the SDK does not pre-check a rule the backend owns. - **`queryPolls` and `queryPollVotes` take `limit`, `next` and `prev`** instead of `PaginationParams`. The generated requests carry only those, and the backend never read the offset or id/date cursors on these queries. `limit` defaults to 10, `PaginationParams`' default, so an omitted limit pages as in diff --git a/openapi-migration/07-threads-and-drafts.md b/openapi-migration/07-threads-and-drafts.md index d608081b61..eb757b9e15 100644 --- a/openapi-migration/07-threads-and-drafts.md +++ b/openapi-migration/07-threads-and-drafts.md @@ -39,6 +39,7 @@ ## Risks +- `DraftMessage.poll` is never decoded from JSON: `poll` is not in `DraftMessage.topLevelFields`, so the key would land in `extraData`, and the draft payload carries `poll_id` only. Its `PollV1JsonConverter` exists so the generated code compiles. Decide here whether the field stays constructor-only or goes. - The four draft methods live in `message_api.dart`, not `threads_api.dart` — this group reaches into that file, and group 10 must leave those four alone. - `Draft` and `DraftMessage` are public, persisted, and read by `StreamDraftListController` in `stream_chat_flutter_core`. diff --git a/openapi-migration/README.md b/openapi-migration/README.md index 9523e7966c..b31875e38d 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -105,7 +105,7 @@ includes a named response that carries only `duration` today, such as `HideChann with no field of the same name and type is not generated. Before deleting the v10 method, run the audit in [`.claude/skills/openapi-migration/copywith-audit.md`](../.claude/skills/openapi-migration/copywith-audit.md) and handle every site it reports: a compile break gets a local or a `?? model.field`; a "kept on null; check" - site is rewritten as an explicit merge (`?? this.x` at the caller, or an `@internal` merge method such as + site is rewritten as an explicit merge (`?? this.x` at the caller, or an `@internal` merge method in the shape of `Poll.withTranslationsOf`) unless the clear is the intended behaviour. When the audit's verdict says freezed cannot replace the method — a parameter backed by a getter, a private field or a different type, as on `Message` — keep `@Freezed(copyWith: false)` with the hand-written method, or reshape the constructor as a diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 6e1c1ea7f9..6b6afcc66f 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -317,8 +317,8 @@ def match(path): - **`Poll`, `PollOption` and `PollVote` keep their v10 names, fields and defaults,** as `@freezed` classes with no JSON, mapped from `PollResponseData`, `PollOptionResponseData` and `PollVoteResponseData` in `lib/src/repository/mapper/polls_mapper.dart`. The uuid default `id` and the - `DateTime.now()` timestamps stay; `latestVotes`, `ownVotes`, `ownAnswers` and `PollVote.isAnswer` - become getters. + `DateTime.now()` timestamps stay; `PollVote.isAnswer` becomes a getter, and `latestVotes`, `ownVotes` and + `ownAnswers` stay `late final` caches. - **Equality now includes `extraData`.** v10's `Equatable` props left it out of `Poll` and `PollOption`; freezed compares every field, and so does `Message`, whose props include its poll. Accepted as a behavioural break and flagged for review. @@ -337,7 +337,8 @@ def match(path): for review. - **Option ids are not sent on create.** `PollOptionInput` has none; the server assigns them and rejects a client id as reserved (confirmed live: v10 answered 400). **`updatePoll` and - `updatePollOption` fail without a request when an option has no id,** since the server requires one. + `updatePollOption` send an option without an id with an empty one** and return the server's failure; + the SDK does not pre-check a rule the backend owns. - **`queryPolls` and `queryPollVotes` take `limit`, `next` and `prev`** instead of `PaginationParams`. The generated requests carry only those, and the backend never read the offset or id/date cursors on these queries. `limit` defaults to 10, `PaginationParams`' default, so an omitted limit pages as in @@ -409,6 +410,9 @@ def match(path): 'because the two groups can otherwise disagree.', ], risks=[ + '`DraftMessage.poll` is never decoded from JSON: `poll` is not in `DraftMessage.topLevelFields`, so the key ' + 'would land in `extraData`, and the draft payload carries `poll_id` only. Its `PollV1JsonConverter` exists so ' + 'the generated code compiles. Decide here whether the field stays constructor-only or goes.', 'The four draft methods live in `message_api.dart`, not `threads_api.dart` — this group reaches into ' 'that file, and group 10 must leave those four alone.', '`Draft` and `DraftMessage` are public, persisted, and read by `StreamDraftListController` in ' diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index a8c822901b..eb6fa740f7 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -86,7 +86,7 @@ - `Poll`, `PollOption`, `PollVote` and the poll responses no longer decode from or encode to JSON; `PollOption` gains `fromData` and `toData`, which read and write only the format `stream_chat_persistence` stores it in. - The poll responses are immutable, built through a const constructor, and their `duration` is a non-nullable `String`; `PollVoteResponse.vote` is nullable. - `Poll`, `PollOption` and `PollVote` no longer extend `Equatable`, so `props` is removed; they still compare by value, now including `extraData`. -- `Poll.copyWith`, `PollOption.copyWith` and `PollVote.copyWith` set a nullable field passed as `null` to `null` instead of keeping its value, and give `id`, `createdAt` and `updatedAt` a fresh default when passed `null`. +- `Poll.copyWith`, `PollOption.copyWith` and `PollVote.copyWith` set a nullable field passed as `null` to `null` instead of keeping its value, and give `Poll.id` and the `createdAt` and `updatedAt` timestamps a fresh default when passed `null`. - `StreamChatApi.polls` is removed; its endpoints are reached through `StreamChatClient`. - The poll and poll option requests no longer send `extraData` entries named like one of the poll's or option's own fields. - `sendImage`, `sendFile`, `uploadImage` and `uploadFile`, on `StreamChatClient`, `Channel` and `AttachmentFileUploader`, return a `Result` instead of throwing; read the URL from `fileUrl` and a video's thumbnail from `thumbUrl`. diff --git a/packages/stream_chat/lib/src/core/models/converters/v1_json_converters.dart b/packages/stream_chat/lib/src/core/models/converters/v1_json_converters.dart index 0d2394c94b..4f00a1d13e 100644 --- a/packages/stream_chat/lib/src/core/models/converters/v1_json_converters.dart +++ b/packages/stream_chat/lib/src/core/models/converters/v1_json_converters.dart @@ -124,8 +124,8 @@ class PollV1JsonConverter implements JsonConverter> { extraData: _customData(json, Poll.topLevelFields), ); - // Writes the keys the poll settings use, with custom data at the root; the vote summary and the server's - // translations are not written. + // Writes the keys the poll settings use, with custom data at the root; the vote summary and the translations + // are not written. @override Map toJson(Poll poll) => { ...poll.extraData, diff --git a/packages/stream_chat/lib/src/core/models/poll_vote.dart b/packages/stream_chat/lib/src/core/models/poll_vote.dart index 8e85aa1a25..9c1f62a886 100644 --- a/packages/stream_chat/lib/src/core/models/poll_vote.dart +++ b/packages/stream_chat/lib/src/core/models/poll_vote.dart @@ -42,7 +42,7 @@ class PollVote with _$PollVote { /// The unique identifier of the option this vote selects. /// - /// An answer selects no option. + /// Empty for an answer, which selects no option. @override final String? optionId; diff --git a/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart index 8442d9b02d..58caabf503 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart @@ -122,7 +122,6 @@ class StreamPollVoteListController extends PagedValueNotifier nextPageKey: response.next, ); - // Start listening to events. if (disposed) return; _subscribeToPollVoteEvents(); }, diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md index 99d6c23d3e..3a5df847e6 100644 --- a/packages/stream_chat_persistence/CHANGELOG.md +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -11,6 +11,7 @@ - Every persistence call logged its own name at `info`; that call trace is `verbose` now. - A cached poll with a voting visibility the SDK does not name is read back instead of failing. +- A poll option's custom data is stored under its own key rather than beside the option's fields, and the schema version is bumped for it, so the local cache is rebuilt once on upgrade. 🐞 Fixed diff --git a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart index b8241b86af..43ef0b607c 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 + 3; + int get schemaVersion => 1100 + 4; // Store DateTime as ISO-8601 text to preserve sub-second precision. @override From 867d5a45774646dfc342d872fcd0eb0e42da1f69 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 13 Oct 2026 16:00:29 +0200 Subject: [PATCH 24/32] style(llc): format the poll vote test Co-Authored-By: Claude Fable 5.1 --- .../stream_chat/test/src/core/models/poll_vote_test.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/stream_chat/test/src/core/models/poll_vote_test.dart b/packages/stream_chat/test/src/core/models/poll_vote_test.dart index bb21b6803e..fd8cdeb27b 100644 --- a/packages/stream_chat/test/src/core/models/poll_vote_test.dart +++ b/packages/stream_chat/test/src/core/models/poll_vote_test.dart @@ -18,7 +18,12 @@ void main() { }); test('PollVote.copyWith clears a nullable field passed as null', () { - final vote = createTestPollVote(id: 'vote-id', optionId: 'pizza', userId: 'user-id', user: User(id: 'user-id')); + final vote = createTestPollVote( + id: 'vote-id', + optionId: 'pizza', + userId: 'user-id', + user: User(id: 'user-id'), + ); final copy = vote.copyWith(userId: null, user: null); From d4453451f1a862df4c4c2ebf47c2eb3a590b4e1e Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 13 Oct 2026 16:39:52 +0200 Subject: [PATCH 25/32] refactor(llc)!: make poll custom data the wire's custom and rename it Co-Authored-By: Claude Fable 5.1 --- .claude/skills/openapi-migration/SKILL.md | 10 +- migrations/v11-migration.md | 33 +++++-- openapi-migration/05-polls.md | 16 +-- openapi-migration/09-users.md | 4 +- openapi-migration/11-channels-and-members.md | 6 +- openapi-migration/15-custom-data-rename.md | 38 ------- openapi-migration/15-custom-data.md | 43 ++++++++ openapi-migration/README.md | 21 +++- openapi-migration/tool/generate_plan.py | 75 +++++++++----- packages/stream_chat/CHANGELOG.md | 6 +- .../models/converters/v1_json_converters.dart | 55 +++++++++-- .../stream_chat/lib/src/core/models/poll.dart | 35 +------ .../lib/src/core/models/poll.freezed.dart | 18 ++-- .../lib/src/core/models/poll_option.dart | 11 +-- .../src/core/models/poll_option.freezed.dart | 18 ++-- .../lib/src/core/models/poll_option.g.dart | 8 +- .../src/repository/mapper/polls_mapper.dart | 37 ++----- .../test/src/client/client_test.dart | 35 ++++--- .../test/src/client/poll_fixtures.dart | 4 +- .../models/converters/poll_v1_json_test.dart | 21 ++-- .../src/core/models/poll_option_test.dart | 12 +-- .../test/src/core/models/poll_test.dart | 10 +- .../stream_chat_flutter_core/CHANGELOG.md | 1 + .../lib/src/stream_poll_controller.dart | 12 +-- .../test/stream_poll_controller_test.dart | 4 +- packages/stream_chat_persistence/CHANGELOG.md | 2 +- .../lib/src/db/drift_chat_database.g.dart | 99 +++++++++---------- .../lib/src/entity/polls.dart | 4 +- .../lib/src/mapper/poll_mapper.dart | 4 +- .../test/src/dao/poll_dao_test.dart | 2 +- .../test/src/dao/poll_vote_dao_test.dart | 2 +- .../test/src/mapper/poll_mapper_test.dart | 24 ++--- 32 files changed, 370 insertions(+), 300 deletions(-) delete mode 100644 openapi-migration/15-custom-data-rename.md create mode 100644 openapi-migration/15-custom-data.md diff --git a/.claude/skills/openapi-migration/SKILL.md b/.claude/skills/openapi-migration/SKILL.md index e3f4e78934..4447b9e876 100644 --- a/.claude/skills/openapi-migration/SKILL.md +++ b/.claude/skills/openapi-migration/SKILL.md @@ -341,9 +341,13 @@ incrementally, not for us. Three data-shape traps while mapping: -- **`custom` vs `extraData`** (channel / message / user features). The wire puts extra fields in `custom`; - hand-written models flatten them into `extraData`. Write that promotion explicitly, including which keys are - excluded. +- **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 `ChannelModel.hidden` and `User.name` + still are until groups 11 and 09 promote them. 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/migrations/v11-migration.md b/migrations/v11-migration.md index c5253a0bf5..bc70cd623d 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -78,7 +78,7 @@ from the spec, so don't subclass them or depend on their private constructors. | [**Moderation**](#moderation) | Muting, banning and flagging return a `Result` and call the moderation v2 API; `banUser`'s options map becomes named parameters; `unflagMessage`, `unflagUser` and `removeShadowBan` are removed | | [**App Settings**](#app-settings) | `getAppSettings` returns a `Result` instead of throwing; `GetAppSettingsResponse` is renamed `AppSettingsResponse` | | [**Guest Users**](#guest-users) | `connectGuestUser` is unchanged; `StreamChatApi.guest` and `ConnectGuestUserResponse` are removed | -| [**Polls**](#polls) | Poll calls return a `Result`; eight responses become `PollResponse`, `PollOptionResponse` and `PollVoteResponse`; queries take `limit`/`next`/`prev`; `VotingVisibility` is an extension type | +| [**Polls**](#polls) | Poll calls return a `Result`; eight responses become `PollResponse`, `PollOptionResponse` and `PollVoteResponse`; queries take `limit`/`next`/`prev`; `VotingVisibility` is an extension type; `extraData` is `custom` | | [**File Upload**](#file-upload) | Uploads return a `Result` and deletes a `Result` instead of throwing; `AttachmentFileUploaderProvider` receives a `Dio` | | _(filled in per feature as PRs land)_ | | @@ -225,12 +225,16 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `queryPolls(pagination: PaginationParams(limit: l, next: n))` / `queryPollVotes(…)` | `queryPolls(limit: l, next: n)` / `queryPollVotes(…)` | `retyped` | `limit` is still 10 when omitted. Pass a response's `prev` as `prev` to page backwards. The other `PaginationParams` fields never had an effect on these queries | | `Channel.castPollVote` / `.removePollVote` throwing `ArgumentError` | a `Failure` carrying a `StreamClientException` | `retyped` | For an option or vote without an id | | `Poll.copyWith` / `PollOption.copyWith` / `PollVote.copyWith` keeping a field passed as `null` | freezed's `copyWith` | `retyped` | `null` clears a nullable field and re-applies the constructor default of `Poll.id`, `createdAt` and `updatedAt`; `Poll.maxVotesAllowed` and `PollOption.id` already cleared | +| `Poll.extraData` / `PollOption.extraData` | `Poll.custom` / `PollOption.custom` | `renamed` | The wire's name for it; the first models to take it | +| `Poll.custom` / `PollOption.custom` entry named like one of the model's own fields | the same entry, as custom data | `retyped` | Sent and returned as custom data; v10's request body let it overwrite the field | +| `StreamPollController.addOption(extraData:)` / `.updateOption(extraData:)` (`stream_chat_flutter_core`) | `custom:` | `renamed` | Follows `PollOption.custom` | +| `Poll.topLevelFields` / `PollOption.topLevelFields` | — | `removed` | They listed the JSON keys of models that no longer have JSON | | `VotingVisibility` (enum) | `VotingVisibility` (extension type over `String`) | `retyped` | `VotingVisibility.public` and `.anonymous` are unchanged; a `switch` needs a default arm, and `.name` / `.values` are gone — read `.rawType` | | `Poll.fromJson` / `.toJson`, `PollOption.fromJson` / `.toJson`, `PollVote.fromJson` / `.toJson` | — | `removed` | The models are plain classes; construct them directly. `Message.poll` and the poll events still decode from the same keys | | — | `PollOption.fromData` / `.toData` | `added` | Read and write only the format the offline database stores; not a codec for API payloads | | `CreatePollResponse()..poll = …` and the other response setters | `PollResponse(duration: …, poll: …)` | `retyped` | The responses are plain classes with a const constructor and final fields | | poll responses' `duration` (`String?`) | `String` | `retyped` | Always present; drop any `!` or `?? ''` | -| `Poll` / `PollOption` / `PollVote extends Equatable`, `props` | value `==` | `removed` | `Poll` and `PollOption` now compare `extraData` too | +| `Poll` / `PollOption` / `PollVote extends Equatable`, `props` | value `==` | `removed` | `Poll` and `PollOption` now compare `custom` too | | `StreamChatApi.polls` (`PollsApi`) | `StreamChatClient`'s poll methods | `removed` | The endpoints moved to the generated client | | `sendImage` / `sendFile` / `uploadImage` / `uploadFile` → `Future` and siblings | `Future>` | `retyped` | On `StreamChatClient`, `Channel` and `AttachmentFileUploader`. Returns a `Result` instead of throwing | | `deleteImage` / `deleteFile` / `removeImage` / `removeFile` → `Future` | `Future>` | `retyped` | On `StreamChatClient`, `Channel` and `AttachmentFileUploader`. Returns a `Result` instead of throwing | @@ -814,12 +818,29 @@ A `switch` over it needs a default arm, and `.name` / `.values` are gone — rea **`Poll`, `PollOption`, `PollVote` and the responses no longer decode JSON.** Build them with their constructors — `PollResponse(duration: '0ms', poll: poll)` where v10 wrote `CreatePollResponse()..poll = poll`. `Message.poll` and the poll events still decode from the same keys. `PollOption.fromData` and `toData` read and write the format the -offline database stores; they are not a way to decode API responses. +offline database stores; they are not a way to decode API responses. `Poll.topLevelFields` and +`PollOption.topLevelFields`, which listed their JSON keys, are gone with the JSON. **`Poll`, `PollOption` and `PollVote` no longer extend `Equatable`.** They still compare by value and `props` is gone. -`Poll` and `PollOption` now include `extraData` in `==`: two polls that differ only in custom data are no longer -equal, so neither are the messages that carry them, and a widget comparing them rebuilds when only custom data -changes. +`Poll` and `PollOption` now include their custom data in `==`: two polls that differ only in custom data are no +longer equal, so neither are the messages that carry them, and a widget comparing them rebuilds when only custom +data changes. + +**`extraData` is renamed `custom`, and holds the poll's custom data verbatim.** `Poll.custom` and +`PollOption.custom` hold what the API returns as custom data, and the requests send it back as custom data; the +same rename applies to `StreamPollController.addOption` and `updateOption`. In v10 an entry named like one of the +poll's own fields — `name`, `is_closed`, an option's `text` — overwrote that field in the request body; it is now +stored as custom data under that name. + +```dart +// v10 +final topic = poll.extraData['topic']; +Poll(name: 'Lunch?', options: options, extraData: {'topic': 'food'}); + +// v11 +final topic = poll.custom['topic']; +Poll(name: 'Lunch?', options: options, custom: {'topic': 'food'}); +``` **`copyWith` on `Poll`, `PollOption` and `PollVote` is freezed's.** Passing `null` for a nullable field clears it, where v10 kept the value: `description`, `nameI18n`, `descriptionI18n`, `createdById` and `createdBy` on `Poll`, diff --git a/openapi-migration/05-polls.md b/openapi-migration/05-polls.md index 063d195ec4..80fb91249f 100644 --- a/openapi-migration/05-polls.md +++ b/openapi-migration/05-polls.md @@ -66,10 +66,12 @@ - **`VotingVisibility` becomes an extension type over its wire string,** following `PushProvider`. An unknown value is kept rather than rejected, in the mapper, the v1 decoder and persistence. - **An answer's `optionId` stays `""`,** as the server sends it and v10 decoded it. -- **`custom` becomes `extraData` without the keys named after the model's own fields** - (`Poll.topLevelFields`, `PollOption.topLevelFields`), matching v1's flat JSON, which shadowed them. - The requests leave the same keys out of `custom`, as every request mapper does: v2 stores them as - custom data, or drops them on update, where v10's flattened body set the field. +- **`custom` is `extraData`, verbatim, in both directions** (README rule 9). The response mappers copy the + generated `custom` map as is and the request mappers send `extraData` as `custom`; an entry named like + one of the model's own fields is custom data on v2, where v10's flattened body let it set the field. + The v1 converters own the key lists that split the flat JSON on read (`Poll.topLevelFields` and + `PollOption.topLevelFields` are removed), and leave such an entry out on write so it cannot overwrite + a key. - **Every public poll method returns a `Result`,** on `StreamChatClient` and `Channel`. `deletePoll` and `deletePollOption` answer `DurationResponse` and return `Result`. `closePoll` stays a `partialUpdatePoll` that sets `is_closed`. @@ -90,8 +92,10 @@ merging of poll events in `channel_state_mutations.dart` and `Poll.withTranslationsOf` passes no argument that is null while the field is set, so it is unchanged. `null` now clears a nullable field and re-applies the `id` and timestamp defaults, recorded as a break. -- **`extraData` keeps its name.** Renaming it to `custom` is a migration-wide step for every model at - once — [group 15](15-custom-data-rename.md). +- **`extraData` is renamed `custom` on `Poll` and `PollOption`,** the wire's name, with no deprecated + alias; `StreamPollController.addOption` and `updateOption` and the `polls.custom` column follow. + Every migrated model takes the name from here on (README rule 9); [group 15](15-custom-data.md) + renames whatever no group migrates. - **Verified live** against the demo app with a before/after harness covering every endpoint, the error cases and the WebSocket events, and in the sample app on web. diff --git a/openapi-migration/09-users.md b/openapi-migration/09-users.md index 72c8c66f31..1c6d0bf219 100644 --- a/openapi-migration/09-users.md +++ b/openapi-migration/09-users.md @@ -41,7 +41,7 @@ - `User` and `OwnUser` are public, persisted, and embedded in nearly every other response. This group restructures them, last: the mappers in `user_mapper.dart` already map the generated types onto the current class for every group before it, and stay. See [01-foundation](01-foundation.md). - `PrivacySettings` and the push-preference sub-shapes — decide per type. -- `UserResponse.toModel()` leaves `OwnUser.topLevelFields`, `deleted_at`, `deactivated_at` and `revoke_tokens_issued_before` out of every user's `extraData` (`_shadowedCustomKeys`), where v1 kept the `OwnUser`-only keys in a plain user's `extraData`. Revisit once the mapper serves plain users. +- **`User.name` and `image` become fields** (README rule 9): `UserResponse` types them, and the constructor stops writing them into `extraData`; decide whether `name` keeps its fallback to `id`. `deleted_at`, `deactivated_at`, `revoke_tokens_issued_before` and `blocked_user_ids`, which `UserResponse` types and v1 kept in a plain user's `extraData`, become fields or are dropped, never `extraData`. `UserResponse.toModel()` then copies `custom` verbatim into the field, renamed `custom`, and `UserRequestMapper` stops reading `extraData['name']` and `['image']`. - `UserFilterField.shadowBanned` and `.bypassModeration` read `extraData`, which the generated `UserResponse` has no field to fill. - `updateUsers` reuses `User.toRequest()`. It is a full upsert, and v10's flattened body filed the user's client state (`online`, `banned`, `created_at` and similar) into the stored custom data on every call, as it did for guests in [04](04-roles-guest-and-app.md). Decide the same way here, for real users rather than fresh guests, and record it in the CHANGELOG. - The generated `UserRequest` sends explicit `null` for an unset `language` or `invisible`, where v10 left the key out. For a guest create that made no difference; for an upsert of an existing user, confirm live that a `null` does not reset a stored value differently from an omitted key. @@ -76,3 +76,5 @@ - [ ] `user_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 + 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 f0d61cfe18..b3a3766bad 100644 --- a/openapi-migration/11-channels-and-members.md +++ b/openapi-migration/11-channels-and-members.md @@ -73,14 +73,14 @@ ## Decisions to make - `ChannelState`, `ChannelModel` and `Member` are public, persisted, and rebuilt from WebSocket events. Keep ours and map. -- **Whether `ChannelModel` promotes its `extraData`-backed flags to real fields.** `disabled`, `hidden`, `muted`, `blocked` and `truncatedAt` all arrive as root fields on `ChannelResponse` but are pushed into `extraData` and read back through getters, which the constructor comment calls "for backwards compatibility". Promoting them is a break worth making in v11 if it is made at all, and it belongs with this group's model shape rather than with whichever phase happens to add the next flag. Raised on [#2958](https://github.com/GetStream/stream-chat-flutter/pull/2958). +- **`ChannelModel` promotes its `extraData`-backed flags to fields** (README rule 9). `disabled`, `hidden`, `muted`, `blocked` and `truncatedAt`, plus the keys the channel mapper parks today — `truncatedBy`, `muteExpiresAt`, `hideMessagesBefore`, `autoTranslationEnabled` and `autoTranslationLanguage` — become fields with persistence columns, and the constructor stops writing them into the custom data, which is renamed `custom`. `name` and `image` stay in it: `ChannelResponse` has no such fields. Raised on [#2958](https://github.com/GetStream/stream-chat-flutter/pull/2958). - `sync` returns `SyncResponse` — one of the two models that needed the WSEvent generator patch. Verify it decodes before relying on it. ## Risks - `sync` and `queryMembers` live in `general_api.dart`, not `channel_api.dart` — this group reaches into that file. - `queryChannels` drives the channel list controllers and the offline cache; a shape change here is felt everywhere. -- Channel `custom`/`extraData` promotion, same class of problem as messages. +- A consumer reading `channel.extraData['hidden']` and friends loses the key once the flags are fields; none in this repo. - Landing `ChannelResponse` -> `ChannelModel` is the last thing blocking [08](08-moderation-and-blocklists.md)'s `muteChannel`, which drops `channelMute`, `channelMutes` and `ownUser` because the generated `ChannelMute` carries a `ChannelResponse?` and a `UserResponse?` where ours needs a non-nullable `ChannelModel` and `User`. It needs 09 as well. Decide the null case there too — ours are non-nullable, the generated ones are not, the same question [14](14-banned-users.md) records for `BanResponse.user`. ## Definition of done @@ -101,3 +101,5 @@ retyped but whose docs it left alone. - [ ] Tests follow [`TESTING.md`](../TESTING.md): no `group` organizing a file by method, each name states its subject and behaviour. +- [ ] `channel_mapper.dart` has no `_shadowedCustomKeys`: `ChannelModel.custom` is the wire's `custom`, + verbatim, and no flag is written into it. diff --git a/openapi-migration/15-custom-data-rename.md b/openapi-migration/15-custom-data-rename.md deleted file mode 100644 index ac4816877d..0000000000 --- a/openapi-migration/15-custom-data-rename.md +++ /dev/null @@ -1,38 +0,0 @@ -# 15 — `extraData` → `custom` - -**Goal:** Rename `extraData` to `custom` on every public model at once, matching the generated client. - -**Size:** 0 hand-written method(s) across 0 file(s) → 0 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 | -| --- | --- | --- | --- | - -## Decisions to make - -- **Whether to rename at all.** Every model names its custom data `extraData` today; the generated client calls it `custom`. Renaming one model at a time would leave the SDK inconsistent, so the groups keep `extraData` and this step decides for every model together. -- **How to stage it.** Add `custom` beside a deprecated `extraData`, or rename in one break. - -## Risks - -- `Serializer`, the models' `topLevelFields` and the persistence `extra_data` columns name the concept too. -- Runs after group 09 at the earliest, once `User` is restructured. - -## Definition of done - -- [ ] Every public model names its custom data the same way. -- [ ] The mappers and the v1 decoders read and write the renamed field. -- [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section. -- [ ] CHANGELOG entry under `🛑️ Breaking` for each break; PR title `refactor(llc)!:`. -- [ ] Decisions recorded in this file, and the status box ticked in `README.md`. diff --git a/openapi-migration/15-custom-data.md b/openapi-migration/15-custom-data.md new file mode 100644 index 0000000000..b04ed5afd8 --- /dev/null +++ b/openapi-migration/15-custom-data.md @@ -0,0 +1,43 @@ +# 15 — Custom Data + +**Goal:** Every public model's custom data is named `custom` and is exactly the wire's `custom`. + +**Size:** 0 hand-written method(s) across 0 file(s) → 0 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 | +| --- | --- | --- | --- | + +## Decisions to make + +- **Which models are left.** Each group renames the models it migrates, polls first; this pass renames whatever remains, and the persistence `extra_data` columns with them. +- **How to stage the remaining renames.** Polls did a hard rename; decide whether the widely used models (`Message`, `ChannelModel`, `User`) get a deprecated `extraData` alias for a release. +- **What replaces `topLevelFields` and `Serializer`.** The v1 decoders still split flat JSON by a key list until the WebSocket moves to v2. A model that loses its JSON drops `topLevelFields` and its converter owns the list, as polls did; decide where `Serializer` goes once the last decoder does. + +## Risks + +- `Serializer`, the models' `topLevelFields` and the persistence `extra_data` columns name the concept too. +- A consumer reading `channel.extraData['hidden']` or `user.extraData['name']` loses the key; none in this repo. Filter fields over custom keys (`UserFilterField.email`) are unaffected. +- Runs after groups 09 and 11 at the earliest, once `User` and `ChannelModel` promote their parked fields. + +## Definition of done + +- [ ] No mapper under `lib/src/repository/mapper/` filters `custom`: `grep -rn _shadowedCustomKeys` is empty. +- [ ] No model writes a typed field into `extraData`, and no getter reads a typed field out of it. +- [ ] No public model exposes `topLevelFields`; each v1 decoder owns its key list. +- [ ] Every public model names its custom data the same way, in the models, the mappers, the v1 + decoders and the persistence columns. +- [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section. +- [ ] CHANGELOG entry under `🛑️ Breaking` for each break; PR title `refactor(llc)!:`. +- [ ] Decisions recorded in this file, and the status box ticked in `README.md`. diff --git a/openapi-migration/README.md b/openapi-migration/README.md index b31875e38d..3b6d92818b 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -26,7 +26,7 @@ generated operations in scope, the decisions that group has to make, its risks, | [12](12-uploads-cdn.md) | Uploads (CDN) | 8 | 8 | ☑ | | [13](13-push-preferences.md) | Push Preferences | 1 | 1 | ☐ | | [14](14-banned-users.md) | Banned Users — split out of 08 | 1 | 1 | ☐ | -| [15](15-custom-data-rename.md) | `extraData` → `custom`, every model at once | — | — | ☐ | +| [15](15-custom-data.md) | Custom Data — `extraData` is the wire's `custom`, then renamed | — | — | ☐ | **Coverage:** 71 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). @@ -82,7 +82,8 @@ breaks against v10: - envelopes are immutable, built through a const constructor rather than `late` setters; - `duration` is a non-nullable `String` on every envelope, where v10 typed it `String?`; - public models and envelopes are `@freezed`, so they compare by value; one that extended `Equatable` in v10 no - longer does, and loses `props`. + longer does, and loses `props`; +- a model's `extraData` is renamed `custom`, the wire's name for it (rule 9). Each ships the four artifacts listed under [Principles](#principles), like any other break. @@ -148,6 +149,14 @@ includes a named response that carries only `duration` today, such as `HideChann parent's rule-7 converter writes it. The codec is temporary: every use is marked `// TODO(openapi-migration): remove in group 10` and listed below, and group 10 replaces every use and deletes the typedef, so no later group adds one. The cache is disposable, so the stored format is ours to choose. +9. **A migrated model names its custom data `custom`, and it is the wire's `custom`, verbatim, in both + 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. `ChannelModel.hidden` and `User.name` are parked today, and groups 11 + and 09 promote them. `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. ### Temporary adapters @@ -177,13 +186,15 @@ surfaces before it reaches `Message` and `ChannelState`: - **08** is where we decide what *not* to expose: 34 generated operations against 10 hand-written methods. - **14** is `queryBannedUsers`, split out of 08 because it is the only moderation call that answers with a model. The `User` mappers it needs landed with group 04. -- **10–11** are the core of the SDK, and carry the `custom` / `extraData` promotion problem. +- **10–11** are the core of the SDK; 11 promotes the channel flags parked in `extraData` (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 user endpoints and restructures `User` and `OwnUser` themselves, when every parent that embeds them has moved. -- **15** renames `extraData` to `custom` on every model at once. The groups keep `extraData`, so the SDK stays - consistent until then; it runs after 09. +- **15** is the custom-data pass: every 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 renames the models it migrates (polls went + first); 15 renames whatever is left and the persistence columns, and runs after 09 and 11, which promote their + parked fields. ## Prerequisites diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 6b6afcc66f..fdc87e02de 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -346,10 +346,12 @@ def match(path): - **`VotingVisibility` becomes an extension type over its wire string,** following `PushProvider`. An unknown value is kept rather than rejected, in the mapper, the v1 decoder and persistence. - **An answer's `optionId` stays `""`,** as the server sends it and v10 decoded it. - - **`custom` becomes `extraData` without the keys named after the model's own fields** - (`Poll.topLevelFields`, `PollOption.topLevelFields`), matching v1's flat JSON, which shadowed them. - The requests leave the same keys out of `custom`, as every request mapper does: v2 stores them as - custom data, or drops them on update, where v10's flattened body set the field. + - **`custom` is `extraData`, verbatim, in both directions** (README rule 9). The response mappers copy the + generated `custom` map as is and the request mappers send `extraData` as `custom`; an entry named like + one of the model's own fields is custom data on v2, where v10's flattened body let it set the field. + The v1 converters own the key lists that split the flat JSON on read (`Poll.topLevelFields` and + `PollOption.topLevelFields` are removed), and leave such an entry out on write so it cannot overwrite + a key. - **Every public poll method returns a `Result`,** on `StreamChatClient` and `Channel`. `deletePoll` and `deletePollOption` answer `DurationResponse` and return `Result`. `closePoll` stays a `partialUpdatePoll` that sets `is_closed`. @@ -370,8 +372,10 @@ def match(path): merging of poll events in `channel_state_mutations.dart` and `Poll.withTranslationsOf` passes no argument that is null while the field is set, so it is unchanged. `null` now clears a nullable field and re-applies the `id` and timestamp defaults, recorded as a break. - - **`extraData` keeps its name.** Renaming it to `custom` is a migration-wide step for every model at - once — [group 15](15-custom-data-rename.md). + - **`extraData` is renamed `custom` on `Poll` and `PollOption`,** the wire's name, with no deprecated + alias; `StreamPollController.addOption` and `updateOption` and the `polls.custom` column follow. + Every migrated model takes the name from here on (README rule 9); [group 15](15-custom-data.md) + renames whatever no group migrates. - **Verified live** against the demo app with a before/after harness covering every endpoint, the error cases and the WebSocket events, and in the sample app on web. """), @@ -568,9 +572,12 @@ def match(path): 'restructures them, last: the mappers in `user_mapper.dart` already map the generated types onto the ' 'current class for every group before it, and stay. See [01-foundation](01-foundation.md).', '`PrivacySettings` and the push-preference sub-shapes — decide per type.', - '`UserResponse.toModel()` leaves `OwnUser.topLevelFields`, `deleted_at`, `deactivated_at` and ' - '`revoke_tokens_issued_before` out of every user\'s `extraData` (`_shadowedCustomKeys`), where v1 kept the ' - '`OwnUser`-only keys in a plain user\'s `extraData`. Revisit once the mapper serves plain users.', + '**`User.name` and `image` become fields** (README rule 9): `UserResponse` types them, and the constructor ' + 'stops writing them into `extraData`; decide whether `name` keeps its fallback to `id`. `deleted_at`, ' + '`deactivated_at`, `revoke_tokens_issued_before` and `blocked_user_ids`, which `UserResponse` types and v1 ' + 'kept in a plain user\'s `extraData`, become fields or are dropped, never `extraData`. ' + '`UserResponse.toModel()` then copies `custom` verbatim into the field, renamed `custom`, and ' + '`UserRequestMapper` stops reading `extraData[\'name\']` and `[\'image\']`.', '`UserFilterField.shadowBanned` and `.bypassModeration` read `extraData`, which the generated ' '`UserResponse` has no field to fill.', '`updateUsers` reuses `User.toRequest()`. It is a full upsert, and v10\'s flattened body filed the ' @@ -599,6 +606,8 @@ def match(path): '- [ ] `user_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' + ' no typed field is written into it.\n' ), ), dict( @@ -639,13 +648,13 @@ def match(path): decisions=[ '`ChannelState`, `ChannelModel` and `Member` are public, persisted, and rebuilt from WebSocket ' 'events. Keep ours and map.', - '**Whether `ChannelModel` promotes its `extraData`-backed flags to real fields.** `disabled`, ' - '`hidden`, `muted`, `blocked` and `truncatedAt` all arrive as root fields on `ChannelResponse` but ' - 'are pushed into `extraData` and read back through getters, which the constructor comment calls ' - '"for backwards compatibility". Promoting them is a break worth making in v11 if it is made at ' - 'all, and it belongs with this group\'s model shape rather than with whichever phase happens to ' - 'add the next flag. Raised on ' - '[#2958](https://github.com/GetStream/stream-chat-flutter/pull/2958).', + '**`ChannelModel` promotes its `extraData`-backed flags to fields** (README rule 9). `disabled`, ' + '`hidden`, `muted`, `blocked` and `truncatedAt`, plus the keys the channel mapper parks today — ' + '`truncatedBy`, `muteExpiresAt`, `hideMessagesBefore`, `autoTranslationEnabled` and ' + '`autoTranslationLanguage` — become fields with persistence columns, and the constructor stops writing ' + 'them into the custom data, which is renamed `custom`. `name` and `image` stay in it: `ChannelResponse` ' + 'has no such fields. ' + 'Raised on [#2958](https://github.com/GetStream/stream-chat-flutter/pull/2958).', '`sync` returns `SyncResponse` — one of the two models that needed the WSEvent generator patch. ' 'Verify it decodes before relying on it.', ], @@ -654,7 +663,8 @@ def match(path): 'into that file.', '`queryChannels` drives the channel list controllers and the offline cache; a shape change here is ' 'felt everywhere.', - 'Channel `custom`/`extraData` promotion, same class of problem as messages.', + 'A consumer reading `channel.extraData[\'hidden\']` and friends loses the key once the flags are fields; ' + 'none in this repo.', 'Landing `ChannelResponse` -> `ChannelModel` is the last thing blocking ' '[08](08-moderation-and-blocklists.md)\'s `muteChannel`, which drops `channelMute`, `channelMutes` ' 'and `ownUser` because the generated `ChannelMute` carries a `ChannelResponse?` and a ' @@ -662,6 +672,10 @@ def match(path): 'Decide the null case there too — ours are non-nullable, the generated ones are not, the same ' 'question [14](14-banned-users.md) records for `BanResponse.user`.', ], + done=DONE + ( + '- [ ] `channel_mapper.dart` has no `_shadowedCustomKeys`: `ChannelModel.custom` is the wire\'s `custom`,\n' + ' verbatim, and no flag is written into it.\n' + ), ), dict( num='12', slug='uploads-cdn', title='Uploads (CDN)', @@ -764,19 +778,25 @@ def match(path): ], ), dict( - num='15', slug='custom-data-rename', title='`extraData` → `custom`', + num='15', slug='custom-data', title='Custom Data', hand=[], match=owns(), - goal='Rename `extraData` to `custom` on every public model at once, matching the generated client.', + goal='Every public model\'s custom data is named `custom` and is exactly the wire\'s `custom`.', decisions=[ - '**Whether to rename at all.** Every model names its custom data `extraData` today; the generated ' - 'client calls it `custom`. Renaming one model at a time would leave the SDK inconsistent, so the ' - 'groups keep `extraData` and this step decides for every model together.', - '**How to stage it.** Add `custom` beside a deprecated `extraData`, or rename in one break.', + '**Which models are left.** Each group renames the models it migrates, polls first; this pass renames ' + 'whatever remains, and the persistence `extra_data` columns with them.', + '**How to stage the remaining renames.** Polls did a hard rename; decide whether the widely used models ' + '(`Message`, `ChannelModel`, `User`) get a deprecated `extraData` alias for a release.', + '**What replaces `topLevelFields` and `Serializer`.** The v1 decoders still split flat JSON by a key ' + 'list until the WebSocket moves to v2. A model that loses its JSON drops `topLevelFields` and its ' + 'converter owns the list, as polls did; decide where `Serializer` goes once the last decoder does.', ], done=textwrap.dedent("""\ - - [ ] Every public model names its custom data the same way. - - [ ] The mappers and the v1 decoders read and write the renamed field. + - [ ] No mapper under `lib/src/repository/mapper/` filters `custom`: `grep -rn _shadowedCustomKeys` is empty. + - [ ] No model writes a typed field into `extraData`, and no getter reads a typed field out of it. + - [ ] No public model exposes `topLevelFields`; each v1 decoder owns its key list. + - [ ] Every public model names its custom data the same way, in the models, the mappers, the v1 + decoders and the persistence columns. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section. - [ ] CHANGELOG entry under `🛑️ Breaking` for each break; PR title `refactor(llc)!:`. - [ ] Decisions recorded in this file, and the status box ticked in `README.md`. @@ -784,7 +804,10 @@ def match(path): risks=[ '`Serializer`, the models\' `topLevelFields` and the persistence `extra_data` columns name the ' 'concept too.', - 'Runs after group 09 at the earliest, once `User` is restructured.', + 'A consumer reading `channel.extraData[\'hidden\']` or `user.extraData[\'name\']` loses the key; none in ' + 'this repo. Filter fields over custom keys (`UserFilterField.email`) are unaffected.', + 'Runs after groups 09 and 11 at the earliest, once `User` and `ChannelModel` promote their parked ' + 'fields.', ], ), ] diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index eb6fa740f7..13d276ac60 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -85,10 +85,12 @@ - `VotingVisibility` is an extension type over its wire string instead of an enum. - `Poll`, `PollOption`, `PollVote` and the poll responses no longer decode from or encode to JSON; `PollOption` gains `fromData` and `toData`, which read and write only the format `stream_chat_persistence` stores it in. - The poll responses are immutable, built through a const constructor, and their `duration` is a non-nullable `String`; `PollVoteResponse.vote` is nullable. -- `Poll`, `PollOption` and `PollVote` no longer extend `Equatable`, so `props` is removed; they still compare by value, now including `extraData`. +- `Poll`, `PollOption` and `PollVote` no longer extend `Equatable`, so `props` is removed; they still compare by value, now including `custom`. - `Poll.copyWith`, `PollOption.copyWith` and `PollVote.copyWith` set a nullable field passed as `null` to `null` instead of keeping its value, and give `Poll.id` and the `createdAt` and `updatedAt` timestamps a fresh default when passed `null`. - `StreamChatApi.polls` is removed; its endpoints are reached through `StreamChatClient`. -- The poll and poll option requests no longer send `extraData` entries named like one of the poll's or option's own fields. +- `Poll.topLevelFields` and `PollOption.topLevelFields` are removed. +- `Poll.extraData` and `PollOption.extraData` are renamed `custom`. +- `Poll.custom` and `PollOption.custom` hold the custom data exactly as the API returns it, and the poll and poll option requests send it back as custom data, so an entry named like one of the poll's or option's own fields no longer sets that field. - `sendImage`, `sendFile`, `uploadImage` and `uploadFile`, on `StreamChatClient`, `Channel` and `AttachmentFileUploader`, return a `Result` instead of throwing; read the URL from `fileUrl` and a video's thumbnail from `thumbUrl`. - `deleteImage`, `deleteFile`, `removeImage` and `removeFile` return a `Result` instead of throwing, and carry no value on success. - `SendAttachmentResponse`, `SendFileResponse`, `SendImageResponse`, `UploadImageResponse` and `UploadFileResponse` are removed in favor of `UploadedFile`. diff --git a/packages/stream_chat/lib/src/core/models/converters/v1_json_converters.dart b/packages/stream_chat/lib/src/core/models/converters/v1_json_converters.dart index 4f00a1d13e..d0bacf32c3 100644 --- a/packages/stream_chat/lib/src/core/models/converters/v1_json_converters.dart +++ b/packages/stream_chat/lib/src/core/models/converters/v1_json_converters.dart @@ -121,14 +121,14 @@ class PollV1JsonConverter implements JsonConverter> { _ => null, }, ownVotesAndAnswers: _pollVotesFromV1Json(json['own_votes']) ?? const [], - extraData: _customData(json, Poll.topLevelFields), + custom: _customData(json, _pollV1Keys), ); - // Writes the keys the poll settings use, with custom data at the root; the vote summary and the translations - // are not written. + // Writes the keys the poll settings use, with custom data at the root; the vote summary, the translations and a + // custom entry named like one of the keys are not written. @override Map toJson(Poll poll) => { - ...poll.extraData, + ..._customDataAtRoot(poll.custom, _pollV1Keys), 'id': poll.id, 'name': poll.name, 'description': poll.description, @@ -165,11 +165,11 @@ PollOption _pollOptionFromV1Json(Map json) => PollOption( id: json['id'] as String?, text: json['text'] as String, textI18n: _translations(json['text_i18n']), - extraData: _customData(json, PollOption.topLevelFields), + custom: _customData(json, _pollOptionV1Keys), ); Map _pollOptionToV1Json(PollOption option) => { - ...option.extraData, + ..._customDataAtRoot(option.custom, _pollOptionV1Keys), 'id': ?option.id, 'text': option.text, }; @@ -199,5 +199,44 @@ Map? _translations(Object? json) => switch (json) { DateTime? _optionalDateTime(Object? json) => json == null ? null : _dateTime.fromJson(json); -Map _customData(Map json, List topLevelFields) => - Serializer.moveToExtraDataFromRoot(json, topLevelFields)['extra_data'] as Map; +// The keys a v1 poll carries besides its custom data. +const _pollV1Keys = [ + 'id', + 'name', + 'name_i18n', + 'description', + 'description_i18n', + 'options', + 'voting_visibility', + 'enforce_unique_vote', + 'max_votes_allowed', + 'allow_user_suggested_options', + 'allow_answers', + 'is_closed', + 'created_at', + 'updated_at', + 'vote_counts_by_option', + 'votes', + 'own_votes', + 'vote_count', + 'answers_count', + 'latest_votes_by_option', + 'latest_answers', + 'created_by_id', + 'created_by', +]; + +// The keys a v1 poll option carries besides its custom data. +const _pollOptionV1Keys = [ + 'id', + 'text', + 'text_i18n', +]; + +Map _customData(Map json, List knownKeys) => + Serializer.moveToExtraDataFromRoot(json, knownKeys)['extra_data'] as Map; + +// The custom data a v1 map carries at its root: an entry named like one of the known keys would overwrite the key, +// so it is left out. +Map _customDataAtRoot(Map custom, List knownKeys) => + {...custom}..removeWhere((key, _) => knownKeys.contains(key)); diff --git a/packages/stream_chat/lib/src/core/models/poll.dart b/packages/stream_chat/lib/src/core/models/poll.dart index a3a42b925a..5d15534f49 100644 --- a/packages/stream_chat/lib/src/core/models/poll.dart +++ b/packages/stream_chat/lib/src/core/models/poll.dart @@ -45,7 +45,7 @@ class Poll with _$Poll { this.createdById, this.createdBy, this.ownVotesAndAnswers = const [], - this.extraData = const {}, + this.custom = const {}, }) : id = id ?? const Uuid().v4(), createdAt = createdAt ?? DateTime.now(), updatedAt = updatedAt ?? DateTime.now(); @@ -173,7 +173,7 @@ class Poll with _$Poll { /// Custom data attached to this poll. @override - final Map extraData; + final Map custom; /// This poll with the translations of [oldPoll] filled in where this one /// has none. @@ -201,33 +201,6 @@ class Poll with _$Poll { ], ); } - - /// The keys a poll carries besides its custom data. - static const topLevelFields = [ - 'id', - 'name', - 'name_i18n', - 'description', - 'description_i18n', - 'options', - 'voting_visibility', - 'enforce_unique_vote', - 'max_votes_allowed', - 'allow_user_suggested_options', - 'allow_answers', - 'is_closed', - 'created_at', - 'updated_at', - 'vote_counts_by_option', - 'votes', - 'own_votes', - 'vote_count', - 'answers_count', - 'latest_votes_by_option', - 'latest_answers', - 'created_by_id', - 'created_by', - ]; } /// A filter for a poll query. @@ -245,12 +218,12 @@ class PollFilterField extends FilterField { /// off an instance with [value]. PollFilterField(super.remote, super.value); - /// Creates a field the SDK does not model, read from [Poll.extraData]. + /// Creates a field the SDK does not model, read from [Poll.custom]. /// /// **Supported operators:** `$eq`, `$in`, `$gt`, `$gte`, `$lt`, `$lte`, /// `$exists`, `$contains`, `$q`, `$autocomplete` factory PollFilterField.custom(String remote) { - return PollFilterField(remote, (it) => it.extraData[remote]); + return PollFilterField(remote, (it) => it.custom[remote]); } /// Filters polls by their id. diff --git a/packages/stream_chat/lib/src/core/models/poll.freezed.dart b/packages/stream_chat/lib/src/core/models/poll.freezed.dart index b6525a5e35..d805de3284 100644 --- a/packages/stream_chat/lib/src/core/models/poll.freezed.dart +++ b/packages/stream_chat/lib/src/core/models/poll.freezed.dart @@ -38,7 +38,7 @@ mixin _$Poll { User? get createdBy; DateTime get createdAt; DateTime get updatedAt; - Map get extraData; + Map get custom; /// Create a copy of Poll /// with the given fields replaced by the non-null parameter values. @@ -101,7 +101,7 @@ mixin _$Poll { (identical(other.createdBy, createdBy) || other.createdBy == createdBy) && (identical(other.createdAt, createdAt) || other.createdAt == createdAt) && (identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt) && - const DeepCollectionEquality().equals(other.extraData, extraData)); + const DeepCollectionEquality().equals(other.custom, custom)); } @override @@ -132,12 +132,12 @@ mixin _$Poll { createdBy, createdAt, updatedAt, - const DeepCollectionEquality().hash(extraData), + const DeepCollectionEquality().hash(custom), ]); @override String toString() { - return 'Poll(id: $id, name: $name, nameI18n: $nameI18n, description: $description, descriptionI18n: $descriptionI18n, options: $options, votingVisibility: $votingVisibility, enforceUniqueVote: $enforceUniqueVote, maxVotesAllowed: $maxVotesAllowed, allowUserSuggestedOptions: $allowUserSuggestedOptions, allowAnswers: $allowAnswers, isClosed: $isClosed, answersCount: $answersCount, voteCountsByOption: $voteCountsByOption, latestVotesByOption: $latestVotesByOption, latestVotes: $latestVotes, latestAnswers: $latestAnswers, ownVotesAndAnswers: $ownVotesAndAnswers, voteCount: $voteCount, ownVotes: $ownVotes, ownAnswers: $ownAnswers, createdById: $createdById, createdBy: $createdBy, createdAt: $createdAt, updatedAt: $updatedAt, extraData: $extraData)'; + return 'Poll(id: $id, name: $name, nameI18n: $nameI18n, description: $description, descriptionI18n: $descriptionI18n, options: $options, votingVisibility: $votingVisibility, enforceUniqueVote: $enforceUniqueVote, maxVotesAllowed: $maxVotesAllowed, allowUserSuggestedOptions: $allowUserSuggestedOptions, allowAnswers: $allowAnswers, isClosed: $isClosed, answersCount: $answersCount, voteCountsByOption: $voteCountsByOption, latestVotesByOption: $latestVotesByOption, latestVotes: $latestVotes, latestAnswers: $latestAnswers, ownVotesAndAnswers: $ownVotesAndAnswers, voteCount: $voteCount, ownVotes: $ownVotes, ownAnswers: $ownAnswers, createdById: $createdById, createdBy: $createdBy, createdAt: $createdAt, updatedAt: $updatedAt, custom: $custom)'; } } @@ -168,7 +168,7 @@ abstract mixin class $PollCopyWith<$Res> { String? createdById, User? createdBy, List ownVotesAndAnswers, - Map extraData, + Map custom, }); } @@ -206,7 +206,7 @@ class _$PollCopyWithImpl<$Res> implements $PollCopyWith<$Res> { Object? createdById = freezed, Object? createdBy = freezed, Object? ownVotesAndAnswers = null, - Object? extraData = null, + Object? custom = null, }) { return _then( Poll( @@ -298,9 +298,9 @@ class _$PollCopyWithImpl<$Res> implements $PollCopyWith<$Res> { ? _self.ownVotesAndAnswers : ownVotesAndAnswers // ignore: cast_nullable_to_non_nullable as List, - extraData: null == extraData - ? _self.extraData - : extraData // ignore: cast_nullable_to_non_nullable + custom: null == custom + ? _self.custom + : custom // ignore: cast_nullable_to_non_nullable as Map, ), ); diff --git a/packages/stream_chat/lib/src/core/models/poll_option.dart b/packages/stream_chat/lib/src/core/models/poll_option.dart index 2d6ccf9c00..2d56d047b4 100644 --- a/packages/stream_chat/lib/src/core/models/poll_option.dart +++ b/packages/stream_chat/lib/src/core/models/poll_option.dart @@ -18,7 +18,7 @@ class PollOption with _$PollOption { this.id, required this.text, this.textI18n, - this.extraData = const {}, + this.custom = const {}, }); /// Creates a [PollOption] from the offline-database format written by [toData]. @@ -44,14 +44,7 @@ class PollOption with _$PollOption { /// Custom data attached to this option. @override - final Map extraData; - - /// The keys an option carries besides its custom data. - static const topLevelFields = [ - 'id', - 'text', - 'text_i18n', - ]; + final Map custom; /// Serializes this option to the format `stream_chat_persistence` stores. /// diff --git a/packages/stream_chat/lib/src/core/models/poll_option.freezed.dart b/packages/stream_chat/lib/src/core/models/poll_option.freezed.dart index 7bdee3d22b..3a66a6d918 100644 --- a/packages/stream_chat/lib/src/core/models/poll_option.freezed.dart +++ b/packages/stream_chat/lib/src/core/models/poll_option.freezed.dart @@ -16,7 +16,7 @@ mixin _$PollOption { String? get id; String get text; Map? get textI18n; - Map get extraData; + Map get custom; /// Create a copy of PollOption /// with the given fields replaced by the non-null parameter values. @@ -32,7 +32,7 @@ mixin _$PollOption { (identical(other.id, id) || other.id == id) && (identical(other.text, text) || other.text == text) && const DeepCollectionEquality().equals(other.textI18n, textI18n) && - const DeepCollectionEquality().equals(other.extraData, extraData)); + const DeepCollectionEquality().equals(other.custom, custom)); } @override @@ -41,12 +41,12 @@ mixin _$PollOption { id, text, const DeepCollectionEquality().hash(textI18n), - const DeepCollectionEquality().hash(extraData), + const DeepCollectionEquality().hash(custom), ); @override String toString() { - return 'PollOption(id: $id, text: $text, textI18n: $textI18n, extraData: $extraData)'; + return 'PollOption(id: $id, text: $text, textI18n: $textI18n, custom: $custom)'; } } @@ -61,7 +61,7 @@ abstract mixin class $PollOptionCopyWith<$Res> { String? id, String text, Map? textI18n, - Map extraData, + Map custom, }); } @@ -80,7 +80,7 @@ class _$PollOptionCopyWithImpl<$Res> implements $PollOptionCopyWith<$Res> { Object? id = freezed, Object? text = null, Object? textI18n = freezed, - Object? extraData = null, + Object? custom = null, }) { return _then( PollOption( @@ -96,9 +96,9 @@ class _$PollOptionCopyWithImpl<$Res> implements $PollOptionCopyWith<$Res> { ? _self.textI18n : textI18n // ignore: cast_nullable_to_non_nullable as Map?, - extraData: null == extraData - ? _self.extraData - : extraData // ignore: cast_nullable_to_non_nullable + custom: null == custom + ? _self.custom + : custom // ignore: cast_nullable_to_non_nullable as Map, ), ); diff --git a/packages/stream_chat/lib/src/core/models/poll_option.g.dart b/packages/stream_chat/lib/src/core/models/poll_option.g.dart index 43ce8d5ea2..7409fb2928 100644 --- a/packages/stream_chat/lib/src/core/models/poll_option.g.dart +++ b/packages/stream_chat/lib/src/core/models/poll_option.g.dart @@ -9,13 +9,15 @@ part of 'poll_option.dart'; PollOption _$PollOptionFromJson(Map json) => PollOption( id: json['id'] as String?, text: json['text'] as String, - textI18n: (json['text_i18n'] as Map?)?.map((k, e) => MapEntry(k, e as String)), - extraData: json['extra_data'] as Map? ?? const {}, + textI18n: (json['text_i18n'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ), + custom: json['custom'] as Map? ?? const {}, ); Map _$PollOptionToJson(PollOption instance) => { 'id': ?instance.id, 'text': instance.text, 'text_i18n': ?instance.textI18n, - 'extra_data': instance.extraData, + 'custom': instance.custom, }; 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 395ec57dae..b5f6c06059 100644 --- a/packages/stream_chat/lib/src/repository/mapper/polls_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/polls_mapper.dart @@ -12,12 +12,7 @@ import 'user_mapper.dart'; /// Maps a generated [api.PollResponseData] to a [Poll]. extension PollResponseDataMapper on api.PollResponseData { - // Custom keys named like one of the poll's own fields. - static const _shadowedCustomKeys = {...Poll.topLevelFields}; - /// Converts this poll into a [Poll]. - /// - /// Custom data named like one of the poll's own fields is left out of [Poll.extraData]. Poll toModel() => Poll( id: id, name: name, @@ -44,23 +39,18 @@ extension PollResponseDataMapper on api.PollResponseData { createdById: createdById, createdBy: createdBy?.toModel(), ownVotesAndAnswers: [for (final vote in ownVotes) vote.toModel()], - extraData: {...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), + custom: custom, ); } /// Maps a generated [api.PollOptionResponseData] to a [PollOption]. extension PollOptionResponseDataMapper on api.PollOptionResponseData { - // Custom keys named like one of the option's own fields. - static const _shadowedCustomKeys = {...PollOption.topLevelFields}; - /// Converts this option into a [PollOption]. - /// - /// Custom data named like one of the option's own fields is left out of [PollOption.extraData]. PollOption toModel() => PollOption( id: id, text: text, textI18n: textI18n, - extraData: {...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), + custom: custom, ); } @@ -122,8 +112,7 @@ extension PollVotesResponseMapper on api.PollVotesResponse { /// Maps a [Poll] to the generated requests that create and update it. /// -/// Only the settings of the poll are sent; the vote summary is not. Custom data named like one of the poll's own fields -/// is left out. +/// Only the settings of the poll are sent; the vote summary is not. extension PollRequestMapper on Poll { /// Converts this poll into an [api.CreatePollRequest]. /// @@ -139,7 +128,7 @@ extension PollRequestMapper on Poll { allowAnswers: allowAnswers, allowUserSuggestedOptions: allowUserSuggestedOptions, isClosed: isClosed, - custom: _customData, + custom: custom, ); /// Converts this poll into an [api.UpdatePollRequest]. @@ -154,37 +143,29 @@ extension PollRequestMapper on Poll { allowAnswers: allowAnswers, allowUserSuggestedOptions: allowUserSuggestedOptions, isClosed: isClosed, - custom: _customData, + custom: custom, ); - - Map get _customData => - {...extraData}..removeWhere((key, _) => PollResponseDataMapper._shadowedCustomKeys.contains(key)); } /// Maps a [PollOption] to the generated requests that create and update it. -/// -/// Custom data named like one of the option's own fields is left out. extension PollOptionRequestMapper on PollOption { /// Converts this option into an [api.PollOptionInput], an option of a poll being created. - api.PollOptionInput toPollOptionInput() => api.PollOptionInput(text: text, custom: _customData); + api.PollOptionInput toPollOptionInput() => api.PollOptionInput(text: text, custom: custom); /// Converts this option into an [api.PollOptionRequest], an option of a poll being updated. /// /// An option without an [id] is sent with an empty one. - api.PollOptionRequest toPollOptionRequest() => api.PollOptionRequest(id: id ?? '', text: text, custom: _customData); + api.PollOptionRequest toPollOptionRequest() => api.PollOptionRequest(id: id ?? '', text: text, custom: custom); /// Converts this option into an [api.CreatePollOptionRequest]. api.CreatePollOptionRequest toCreatePollOptionRequest() => api.CreatePollOptionRequest( text: text, - custom: _customData, + custom: custom, ); /// Converts this option into an [api.UpdatePollOptionRequest]. /// /// An option without an [id] is sent with an empty one. api.UpdatePollOptionRequest toUpdatePollOptionRequest() => - api.UpdatePollOptionRequest(id: id ?? '', text: text, custom: _customData); - - Map get _customData => - {...extraData}..removeWhere((key, _) => PollOptionResponseDataMapper._shadowedCustomKeys.contains(key)); + api.UpdatePollOptionRequest(id: id ?? '', text: text, custom: custom); } diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 12940767aa..bcb44d9a1a 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -2543,7 +2543,7 @@ void main() { name: 'Lunch?', description: 'Pick one', options: [ - api.PollOptionInput(text: 'Pizza', custom: {'color': 'red'}), + api.PollOptionInput(text: 'Pizza', custom: {'color': 'red', 'text': 'Pasta'}), api.PollOptionInput(text: 'Sushi', custom: {}), ], votingVisibility: api.CreatePollRequestVotingVisibility.anonymous, @@ -2552,7 +2552,7 @@ void main() { allowAnswers: true, allowUserSuggestedOptions: true, isClosed: false, - custom: {'topic': 'food'}, + custom: {'topic': 'food', 'name': 'Dinner?'}, ), ); expect(result, Result.success(pollResponse)); @@ -2585,7 +2585,7 @@ void main() { }); test( - "StreamChatClient.getPoll keeps custom fields named like the poll's own fields out of its custom data", + "StreamChatClient.getPoll keeps a custom field named like one of the poll's own fields", () async { final response = api.PollResponse( duration: '4.21ms', @@ -2595,7 +2595,11 @@ void main() { final result = await client.getPoll('poll-id'); - expect(result.getOrNull()?.poll.extraData, const {'topic': 'food'}); + expect(result.getOrNull()?.poll.custom, const { + 'topic': 'food', + 'name': 'custom-name', + 'own_votes': 'custom', + }); }, ); @@ -2645,7 +2649,7 @@ void main() { final result = await client.updatePoll( _newPoll().copyWith( options: const [ - PollOption(id: 'pizza', text: 'Pizza', extraData: {'color': 'red', 'id': 'pasta'}), + PollOption(id: 'pizza', text: 'Pizza', custom: {'color': 'red', 'id': 'pasta'}), PollOption(id: 'sushi', text: 'Sushi'), ], votingVisibility: const VotingVisibility('members_only'), @@ -2663,7 +2667,7 @@ void main() { name: 'Lunch?', description: 'Pick one', options: const [ - api.PollOptionRequest(id: 'pizza', text: 'Pizza', custom: {'color': 'red'}), + api.PollOptionRequest(id: 'pizza', text: 'Pizza', custom: {'color': 'red', 'id': 'pasta'}), api.PollOptionRequest(id: 'sushi', text: 'Sushi', custom: {}), ], votingVisibility: api.UpdatePollRequestVotingVisibility.fromJson('members_only'), @@ -2672,7 +2676,7 @@ void main() { allowAnswers: true, allowUserSuggestedOptions: true, isClosed: true, - custom: const {'topic': 'food'}, + custom: const {'topic': 'food', 'name': 'Dinner?'}, ), ); expect(result, Result.success(pollResponse)); @@ -2833,13 +2837,16 @@ void main() { when( () => defaultApi.createPollOption( pollId: 'poll-id', - createPollOptionRequest: const api.CreatePollOptionRequest(text: 'Pizza', custom: {'color': 'red'}), + createPollOptionRequest: const api.CreatePollOptionRequest( + text: 'Pizza', + custom: {'color': 'red', 'text': 'Pasta'}, + ), ), ).thenAnswer((_) async => const Result.success(_generatedPizzaResponse)); final result = await client.createPollOption( 'poll-id', - const PollOption(text: 'Pizza', extraData: {'color': 'red', 'text': 'Pasta'}), + const PollOption(text: 'Pizza', custom: {'color': 'red', 'text': 'Pasta'}), ); expect(result, const Result.success(_pizzaResponse)); @@ -2880,7 +2887,7 @@ void main() { }); test( - "StreamChatClient.getPollOption keeps custom fields named like the option's own fields out of its custom data", + "StreamChatClient.getPollOption keeps a custom field named like one of the option's own fields", () async { final response = api.PollOptionResponse( duration: '4.21ms', @@ -2892,7 +2899,7 @@ void main() { final result = await client.getPollOption('poll-id', 'pizza'); - expect(result.getOrNull()?.pollOption.extraData, const {'color': 'red'}); + expect(result.getOrNull()?.pollOption.custom, const {'color': 'red', 'text': 'custom-text'}); }, ); @@ -7198,7 +7205,7 @@ Poll _newPoll() => Poll( name: 'Lunch?', description: 'Pick one', options: const [ - PollOption(text: 'Pizza', extraData: {'color': 'red', 'text': 'Pasta'}), + PollOption(text: 'Pizza', custom: {'color': 'red', 'text': 'Pasta'}), PollOption(text: 'Sushi'), ], votingVisibility: VotingVisibility.anonymous, @@ -7207,8 +7214,8 @@ Poll _newPoll() => Poll( allowAnswers: true, allowUserSuggestedOptions: true, voteCount: 7, - // Custom data named like one of the poll's own fields is left out of the requests. - extraData: const {'topic': 'food', 'name': 'Dinner?'}, + // Custom data named like one of the poll's own fields is sent as custom data. + custom: const {'topic': 'food', 'name': 'Dinner?'}, ); const _generatedPizzaResponse = api.PollOptionResponse(duration: '4.21ms', pollOption: generatedPizza); diff --git a/packages/stream_chat/test/src/client/poll_fixtures.dart b/packages/stream_chat/test/src/client/poll_fixtures.dart index bcb1b9e682..41ff94addf 100644 --- a/packages/stream_chat/test/src/client/poll_fixtures.dart +++ b/packages/stream_chat/test/src/client/poll_fixtures.dart @@ -100,7 +100,7 @@ const generatedPizza = api.PollOptionResponseData( textI18n: {'it': 'Pizza'}, ); -const pizza = PollOption(id: 'pizza', text: 'Pizza', textI18n: {'it': 'Pizza'}, extraData: {'color': 'red'}); +const pizza = PollOption(id: 'pizza', text: 'Pizza', textI18n: {'it': 'Pizza'}, custom: {'color': 'red'}); final generatedPoll = api.PollResponseData( allowAnswers: true, @@ -155,7 +155,7 @@ final poll = Poll( createdById: 'luke', createdBy: voter, ownVotesAndAnswers: [vote, answer], - extraData: const {'topic': 'food'}, + custom: const {'topic': 'food'}, ); final generatedPollResponse = api.PollResponse(duration: '4.21ms', poll: generatedPoll); diff --git a/packages/stream_chat/test/src/core/models/converters/poll_v1_json_test.dart b/packages/stream_chat/test/src/core/models/converters/poll_v1_json_test.dart index 0aa6190a2e..1592c04c30 100644 --- a/packages/stream_chat/test/src/core/models/converters/poll_v1_json_test.dart +++ b/packages/stream_chat/test/src/core/models/converters/poll_v1_json_test.dart @@ -14,7 +14,7 @@ void main() { description: 'Pick one', descriptionI18n: const {'it': 'Scegline uno'}, options: const [ - PollOption(id: 'pizza', text: 'Pizza', textI18n: {'it': 'Pizza'}, extraData: {'color': 'red'}), + PollOption(id: 'pizza', text: 'Pizza', textI18n: {'it': 'Pizza'}, custom: {'color': 'red'}), PollOption(id: 'sushi', text: 'Sushi'), ], votingVisibility: VotingVisibility.public, @@ -35,7 +35,7 @@ void main() { createdBy: User.fromJson(const {'id': 'luke', 'name': 'Luke'}), createdAt: DateTime.utc(2024, 4, 17, 14, 46, 23), updatedAt: DateTime.utc(2024, 4, 18), - extraData: const {'topic': 'food'}, + custom: const {'topic': 'food'}, ), ); }); @@ -72,7 +72,7 @@ void main() { expect(poll.voteCountsByOption, isEmpty); expect(poll.latestVotesByOption, isEmpty); expect(poll.ownVotesAndAnswers, isEmpty); - expect(poll.extraData, isEmpty); + expect(poll.custom, isEmpty); }); test('Message.fromJson keeps the translations of a poll out of its custom data', () { @@ -88,7 +88,7 @@ void main() { }, }); - expect(message.poll!.extraData, {'topic': 'food'}); + expect(message.poll!.custom, {'topic': 'food'}); }); test('Message.fromJson keeps the translations of a poll option out of its custom data', () { @@ -108,7 +108,7 @@ void main() { }, }); - expect(message.poll!.options.single.extraData, {'color': 'red'}); + expect(message.poll!.options.single.custom, {'color': 'red'}); }); test('Message.fromJson keeps a voting visibility the SDK does not name', () { @@ -148,7 +148,8 @@ void main() { expect(event.pollVote, _vote); }); - test('Event.toJson writes the poll settings with the custom data beside them, without the translations', () { + test('Event.toJson writes the poll settings with the custom data beside them, without the translations or a custom ' + 'entry named like a key', () { final event = Event( type: EventType.pollUpdated, poll: Poll( @@ -158,10 +159,10 @@ void main() { description: 'Pick one', descriptionI18n: const {'it': 'Scegline uno'}, options: const [ - PollOption(id: 'pizza', text: 'Pizza', textI18n: {'it': 'Pizza'}, extraData: {'color': 'red'}), + PollOption(id: 'pizza', text: 'Pizza', textI18n: {'it': 'Pizza'}, custom: {'color': 'red', 'id': 'pasta'}), ], voteCount: 3, - extraData: const {'topic': 'food'}, + custom: const {'topic': 'food', 'name': 'Dinner?'}, ), ); @@ -193,11 +194,11 @@ void main() { id: 'poll-id', name: 'Lunch?', options: const [ - PollOption(id: 'pizza', text: 'Pizza', extraData: {'color': 'red'}), + PollOption(id: 'pizza', text: 'Pizza', custom: {'color': 'red'}), ], votingVisibility: VotingVisibility.anonymous, maxVotesAllowed: 2, - extraData: const {'topic': 'food'}, + custom: const {'topic': 'food'}, ); final event = Event.fromJson(Event(type: EventType.pollUpdated, poll: poll).toJson()); diff --git a/packages/stream_chat/test/src/core/models/poll_option_test.dart b/packages/stream_chat/test/src/core/models/poll_option_test.dart index 3a5424a32b..81ff44beb1 100644 --- a/packages/stream_chat/test/src/core/models/poll_option_test.dart +++ b/packages/stream_chat/test/src/core/models/poll_option_test.dart @@ -3,32 +3,32 @@ import 'package:test/test.dart'; void main() { test('PollOption.toData writes the id, the text, its translations and the custom data under their stored keys', () { - const option = PollOption(id: 'pizza', text: 'Pizza', textI18n: {'it': 'Pizza'}, extraData: {'color': 'red'}); + const option = PollOption(id: 'pizza', text: 'Pizza', textI18n: {'it': 'Pizza'}, custom: {'color': 'red'}); expect(option.toData(), { 'id': 'pizza', 'text': 'Pizza', 'text_i18n': {'it': 'Pizza'}, - 'extra_data': {'color': 'red'}, + 'custom': {'color': 'red'}, }); }); test('PollOption.toData leaves out an id and translations that are null', () { const option = PollOption(text: 'Pizza'); - expect(option.toData().keys, unorderedEquals(['text', 'extra_data'])); + expect(option.toData().keys, unorderedEquals(['text', 'custom'])); }); test('PollOption.fromData reads back the option written by toData', () { - const option = PollOption(id: 'pizza', text: 'Pizza', textI18n: {'it': 'Pizza'}, extraData: {'color': 'red'}); + const option = PollOption(id: 'pizza', text: 'Pizza', textI18n: {'it': 'Pizza'}, custom: {'color': 'red'}); expect(PollOption.fromData(option.toData()), option); }); test('PollOption compares its custom data for equality', () { - const option = PollOption(id: 'pizza', text: 'Pizza', extraData: {'color': 'red'}); + const option = PollOption(id: 'pizza', text: 'Pizza', custom: {'color': 'red'}); - expect(option, isNot(option.copyWith(extraData: const {'color': 'green'}))); + expect(option, isNot(option.copyWith(custom: const {'color': 'green'}))); }); test('PollOption.copyWith clears an id passed as null', () { diff --git a/packages/stream_chat/test/src/core/models/poll_test.dart b/packages/stream_chat/test/src/core/models/poll_test.dart index d88b6b2695..6f5a23b672 100644 --- a/packages/stream_chat/test/src/core/models/poll_test.dart +++ b/packages/stream_chat/test/src/core/models/poll_test.dart @@ -17,10 +17,10 @@ void main() { }); test('Poll compares its custom data for equality', () { - final poll = createTestPoll(id: 'poll-id', name: 'Lunch?', extraData: const {'topic': 'food'}); + final poll = createTestPoll(id: 'poll-id', name: 'Lunch?', custom: const {'topic': 'food'}); - expect(poll, poll.copyWith(extraData: const {'topic': 'food'})); - expect(poll, isNot(poll.copyWith(extraData: const {'topic': 'drinks'}))); + expect(poll, poll.copyWith(custom: const {'topic': 'food'})); + expect(poll, isNot(poll.copyWith(custom: const {'topic': 'drinks'}))); }); test('Poll.copyWith can clear the vote limit', () { @@ -136,7 +136,7 @@ Poll createTestPoll({ bool isClosed = false, DateTime? createdAt, DateTime? updatedAt, - Map? extraData, + Map? custom, }) { return Poll( id: id, @@ -151,6 +151,6 @@ Poll createTestPoll({ isClosed: isClosed, createdAt: createdAt ?? DateTime(2023), updatedAt: updatedAt ?? DateTime(2023), - extraData: extraData ?? {}, + custom: custom ?? {}, ); } diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index f6194a6455..af3403b6f9 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -8,6 +8,7 @@ - The sort argument on every list controller is typed `List`, `List` and so on, rather than `SortOrder`. On the controllers that have a default, the `sort` property is non-nullable: leaving it unset selects that default, and `ChannelSort.empty` / `MemberSort.empty` / … query with the ordering the API applies on its own. - `defaultChannelListSort`, `defaultMemberListSort`, `defaultUserListSort`, `defaultDraftListSort`, `defaultMessageReminderListSort` and `defaultPollVoteListSort` are removed. Use `ChannelSort.defaultSort`, `MemberSort.defaultSort` and so on from `stream_chat`. Leaving a controller's sort unset still applies the same ordering. - `StreamChannelListController.muteChannel` and `unmuteChannel` return a `Result` instead of throwing. +- `StreamPollController.addOption` and `updateOption` take the option's custom data as `custom` instead of `extraData`. 🐞 Fixed diff --git a/packages/stream_chat_flutter_core/lib/src/stream_poll_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_poll_controller.dart index 216ae0d3f0..aca9758513 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_poll_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_poll_controller.dart @@ -177,29 +177,29 @@ class StreamPollController extends ValueNotifier { return invalidErrors; } - /// Adds a new option with the provided [text] and [extraData]. + /// Adds a new option with the provided [text] and [custom]. /// /// The new option will be added to the end of the list of options. void addOption( String text, { - Map extraData = const {}, + Map custom = const {}, }) { final options = [...value.options]; - final newOption = PollOption(text: text, extraData: extraData); + final newOption = PollOption(text: text, custom: custom); value = value.copyWith(options: [...options, newOption]); } /// Updates the option at the provided [index] with the provided [text] and - /// [extraData]. + /// [custom]. void updateOption( String text, { required int index, - Map extraData = const {}, + Map custom = const {}, }) { final options = [...value.options]; options[index] = options[index].copyWith( text: text, - extraData: extraData, + custom: custom, ); value = value.copyWith(options: options); diff --git a/packages/stream_chat_flutter_core/test/stream_poll_controller_test.dart b/packages/stream_chat_flutter_core/test/stream_poll_controller_test.dart index 408b4c9c29..814fe8c83e 100644 --- a/packages/stream_chat_flutter_core/test/stream_poll_controller_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_poll_controller_test.dart @@ -77,11 +77,11 @@ void main() { final pollController = StreamPollController() ..addOption( 'Option 1', - extraData: {'key': 'value'}, + custom: {'key': 'value'}, ); expect(pollController.value.options.length, 2); - expect(pollController.value.options.last.extraData['key'], 'value'); + expect(pollController.value.options.last.custom['key'], 'value'); }); test('Update Option', () { diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md index 3a5df847e6..443017113a 100644 --- a/packages/stream_chat_persistence/CHANGELOG.md +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -11,7 +11,7 @@ - Every persistence call logged its own name at `info`; that call trace is `verbose` now. - A cached poll with a voting visibility the SDK does not name is read back instead of failing. -- A poll option's custom data is stored under its own key rather than beside the option's fields, and the schema version is bumped for it, so the local cache is rebuilt once on upgrade. +- A poll's custom data is stored in a `custom` column, and a poll option's under a `custom` key rather than beside the option's fields; the schema version is bumped for it, so the local cache is rebuilt once on upgrade. 🐞 Fixed 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 f3449ea86d..1125f1cb31 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 @@ -6487,13 +6487,13 @@ class $PollsTable extends Polls with TableInfo<$PollsTable, PollEntity> { defaultValue: currentDateAndTime, ); @override - late final GeneratedColumnWithTypeConverter?, String> extraData = GeneratedColumn( - 'extra_data', + late final GeneratedColumnWithTypeConverter?, String> custom = GeneratedColumn( + 'custom', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false, - ).withConverter?>($PollsTable.$converterextraDatan); + ).withConverter?>($PollsTable.$convertercustomn); @override List get $columns => [ id, @@ -6514,7 +6514,7 @@ class $PollsTable extends Polls with TableInfo<$PollsTable, PollEntity> { createdById, createdAt, updatedAt, - extraData, + custom, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -6719,10 +6719,10 @@ class $PollsTable extends Polls with TableInfo<$PollsTable, PollEntity> { DriftSqlType.dateTime, data['${effectivePrefix}updated_at'], )!, - extraData: $PollsTable.$converterextraDatan.fromSql( + custom: $PollsTable.$convertercustomn.fromSql( attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}extra_data'], + data['${effectivePrefix}custom'], ), ), ); @@ -6738,9 +6738,9 @@ class $PollsTable extends Polls with TableInfo<$PollsTable, PollEntity> { static TypeConverter, String> $converteroptions = ListConverter(); static TypeConverter $convertervotingVisibility = const VotingVisibilityConverter(); static TypeConverter, String> $convertervoteCountsByOption = MapConverter(); - static TypeConverter, String> $converterextraData = MapConverter(); - static TypeConverter?, String?> $converterextraDatan = NullAwareTypeConverter.wrap( - $converterextraData, + static TypeConverter, String> $convertercustom = MapConverter(); + static TypeConverter?, String?> $convertercustomn = NullAwareTypeConverter.wrap( + $convertercustom, ); } @@ -6808,8 +6808,8 @@ class PollEntity extends DataClass implements Insertable { /// The date when the poll was last updated. final DateTime updatedAt; - /// Map of custom poll extraData - final Map? extraData; + /// Custom data attached to the poll. + final Map? custom; const PollEntity({ required this.id, required this.name, @@ -6829,7 +6829,7 @@ class PollEntity extends DataClass implements Insertable { this.createdById, required this.createdAt, required this.updatedAt, - this.extraData, + this.custom, }); @override Map toColumns(bool nullToAbsent) { @@ -6880,9 +6880,9 @@ class PollEntity extends DataClass implements Insertable { } map['created_at'] = Variable(createdAt); map['updated_at'] = Variable(updatedAt); - if (!nullToAbsent || extraData != null) { - map['extra_data'] = Variable( - $PollsTable.$converterextraDatan.toSql(extraData), + if (!nullToAbsent || custom != null) { + map['custom'] = Variable( + $PollsTable.$convertercustomn.toSql(custom), ); } return map; @@ -6920,7 +6920,7 @@ class PollEntity extends DataClass implements Insertable { createdById: serializer.fromJson(json['createdById']), createdAt: serializer.fromJson(json['createdAt']), updatedAt: serializer.fromJson(json['updatedAt']), - extraData: serializer.fromJson?>(json['extraData']), + custom: serializer.fromJson?>(json['custom']), ); } @override @@ -6951,7 +6951,7 @@ class PollEntity extends DataClass implements Insertable { 'createdById': serializer.toJson(createdById), 'createdAt': serializer.toJson(createdAt), 'updatedAt': serializer.toJson(updatedAt), - 'extraData': serializer.toJson?>(extraData), + 'custom': serializer.toJson?>(custom), }; } @@ -6974,7 +6974,7 @@ class PollEntity extends DataClass implements Insertable { Value createdById = const Value.absent(), DateTime? createdAt, DateTime? updatedAt, - Value?> extraData = const Value.absent(), + Value?> custom = const Value.absent(), }) => PollEntity( id: id ?? this.id, name: name ?? this.name, @@ -6994,7 +6994,7 @@ class PollEntity extends DataClass implements Insertable { createdById: createdById.present ? createdById.value : this.createdById, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, - extraData: extraData.present ? extraData.value : this.extraData, + custom: custom.present ? custom.value : this.custom, ); PollEntity copyWithCompanion(PollsCompanion data) { return PollEntity( @@ -7018,7 +7018,7 @@ class PollEntity extends DataClass implements Insertable { createdById: data.createdById.present ? data.createdById.value : this.createdById, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, - extraData: data.extraData.present ? data.extraData.value : this.extraData, + custom: data.custom.present ? data.custom.value : this.custom, ); } @@ -7043,7 +7043,7 @@ class PollEntity extends DataClass implements Insertable { ..write('createdById: $createdById, ') ..write('createdAt: $createdAt, ') ..write('updatedAt: $updatedAt, ') - ..write('extraData: $extraData') + ..write('custom: $custom') ..write(')')) .toString(); } @@ -7068,7 +7068,7 @@ class PollEntity extends DataClass implements Insertable { createdById, createdAt, updatedAt, - extraData, + custom, ); @override bool operator ==(Object other) => @@ -7092,7 +7092,7 @@ class PollEntity extends DataClass implements Insertable { other.createdById == this.createdById && other.createdAt == this.createdAt && other.updatedAt == this.updatedAt && - other.extraData == this.extraData); + other.custom == this.custom); } class PollsCompanion extends UpdateCompanion { @@ -7114,7 +7114,7 @@ class PollsCompanion extends UpdateCompanion { final Value createdById; final Value createdAt; final Value updatedAt; - final Value?> extraData; + final Value?> custom; final Value rowid; const PollsCompanion({ this.id = const Value.absent(), @@ -7135,7 +7135,7 @@ class PollsCompanion extends UpdateCompanion { this.createdById = const Value.absent(), this.createdAt = const Value.absent(), this.updatedAt = const Value.absent(), - this.extraData = const Value.absent(), + this.custom = const Value.absent(), this.rowid = const Value.absent(), }); PollsCompanion.insert({ @@ -7157,13 +7157,13 @@ class PollsCompanion extends UpdateCompanion { this.createdById = const Value.absent(), this.createdAt = const Value.absent(), this.updatedAt = const Value.absent(), - this.extraData = const Value.absent(), + this.custom = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), name = Value(name), options = Value(options), voteCountsByOption = Value(voteCountsByOption); - static Insertable custom({ + static Insertable createCustom({ Expression? id, Expression? name, Expression? nameI18n, @@ -7182,7 +7182,7 @@ class PollsCompanion extends UpdateCompanion { Expression? createdById, Expression? createdAt, Expression? updatedAt, - Expression? extraData, + Expression? custom, Expression? rowid, }) { return RawValuesInsertable({ @@ -7204,7 +7204,7 @@ class PollsCompanion extends UpdateCompanion { if (createdById != null) 'created_by_id': createdById, if (createdAt != null) 'created_at': createdAt, if (updatedAt != null) 'updated_at': updatedAt, - if (extraData != null) 'extra_data': extraData, + if (custom != null) 'custom': custom, if (rowid != null) 'rowid': rowid, }); } @@ -7228,7 +7228,7 @@ class PollsCompanion extends UpdateCompanion { Value? createdById, Value? createdAt, Value? updatedAt, - Value?>? extraData, + Value?>? custom, Value? rowid, }) { return PollsCompanion( @@ -7250,7 +7250,7 @@ class PollsCompanion extends UpdateCompanion { createdById: createdById ?? this.createdById, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, - extraData: extraData ?? this.extraData, + custom: custom ?? this.custom, rowid: rowid ?? this.rowid, ); } @@ -7326,9 +7326,9 @@ class PollsCompanion extends UpdateCompanion { if (updatedAt.present) { map['updated_at'] = Variable(updatedAt.value); } - if (extraData.present) { - map['extra_data'] = Variable( - $PollsTable.$converterextraDatan.toSql(extraData.value), + if (custom.present) { + map['custom'] = Variable( + $PollsTable.$convertercustomn.toSql(custom.value), ); } if (rowid.present) { @@ -7358,7 +7358,7 @@ class PollsCompanion extends UpdateCompanion { ..write('createdById: $createdById, ') ..write('createdAt: $createdAt, ') ..write('updatedAt: $updatedAt, ') - ..write('extraData: $extraData, ') + ..write('custom: $custom, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -16097,7 +16097,7 @@ typedef $$PollsTableCreateCompanionBuilder = Value createdById, Value createdAt, Value updatedAt, - Value?> extraData, + Value?> custom, Value rowid, }); typedef $$PollsTableUpdateCompanionBuilder = @@ -16120,7 +16120,7 @@ typedef $$PollsTableUpdateCompanionBuilder = Value createdById, Value createdAt, Value updatedAt, - Value?> extraData, + Value?> custom, Value rowid, }); @@ -16246,11 +16246,10 @@ class $$PollsTableFilterComposer extends Composer<_$DriftChatDatabase, $PollsTab builder: (column) => ColumnFilters(column), ); - ColumnWithTypeConverterFilters?, Map, String> get extraData => - $composableBuilder( - column: $table.extraData, - builder: (column) => ColumnWithTypeConverterFilters(column), - ); + ColumnWithTypeConverterFilters?, Map, String> get custom => $composableBuilder( + column: $table.custom, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); Expression pollVotesRefs( Expression Function($$PollVotesTableFilterComposer f) f, @@ -16375,8 +16374,8 @@ class $$PollsTableOrderingComposer extends Composer<_$DriftChatDatabase, $PollsT builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get extraData => $composableBuilder( - column: $table.extraData, + ColumnOrderings get custom => $composableBuilder( + column: $table.custom, builder: (column) => ColumnOrderings(column), ); } @@ -16457,8 +16456,8 @@ class $$PollsTableAnnotationComposer extends Composer<_$DriftChatDatabase, $Poll GeneratedColumn get updatedAt => $composableBuilder(column: $table.updatedAt, builder: (column) => column); - GeneratedColumnWithTypeConverter?, String> get extraData => - $composableBuilder(column: $table.extraData, builder: (column) => column); + GeneratedColumnWithTypeConverter?, String> get custom => + $composableBuilder(column: $table.custom, builder: (column) => column); Expression pollVotesRefs( Expression Function($$PollVotesTableAnnotationComposer a) f, @@ -16528,7 +16527,7 @@ class $$PollsTableTableManager Value createdById = const Value.absent(), Value createdAt = const Value.absent(), Value updatedAt = const Value.absent(), - Value?> extraData = const Value.absent(), + Value?> custom = const Value.absent(), Value rowid = const Value.absent(), }) => PollsCompanion( id: id, @@ -16549,7 +16548,7 @@ class $$PollsTableTableManager createdById: createdById, createdAt: createdAt, updatedAt: updatedAt, - extraData: extraData, + custom: custom, rowid: rowid, ), createCompanionCallback: @@ -16572,7 +16571,7 @@ class $$PollsTableTableManager Value createdById = const Value.absent(), Value createdAt = const Value.absent(), Value updatedAt = const Value.absent(), - Value?> extraData = const Value.absent(), + Value?> custom = const Value.absent(), Value rowid = const Value.absent(), }) => PollsCompanion.insert( id: id, @@ -16593,7 +16592,7 @@ class $$PollsTableTableManager createdById: createdById, createdAt: createdAt, updatedAt: updatedAt, - extraData: extraData, + custom: custom, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/packages/stream_chat_persistence/lib/src/entity/polls.dart b/packages/stream_chat_persistence/lib/src/entity/polls.dart index 9df0208433..ec765000e3 100644 --- a/packages/stream_chat_persistence/lib/src/entity/polls.dart +++ b/packages/stream_chat_persistence/lib/src/entity/polls.dart @@ -71,8 +71,8 @@ class Polls extends Table { /// The date when the poll was last updated. DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); - /// Map of custom poll extraData - TextColumn get extraData => text().nullable().map(MapConverter())(); + /// Custom data attached to the poll. + TextColumn get custom => text().nullable().map(MapConverter())(); @override Set get primaryKey => {id}; diff --git a/packages/stream_chat_persistence/lib/src/mapper/poll_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/poll_mapper.dart index 099241851d..2637856e06 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/poll_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/poll_mapper.dart @@ -35,7 +35,7 @@ extension PollEntityX on PollEntity { createdById: createdById, createdBy: createdBy, ownVotesAndAnswers: ownVotesAndAnswers, - extraData: extraData ?? {}, + custom: custom ?? {}, ); } } @@ -62,6 +62,6 @@ extension PollX on Poll { voteCountsByOption: voteCountsByOption, voteCount: voteCount, createdById: createdById, - extraData: extraData, + custom: custom, ); } diff --git a/packages/stream_chat_persistence/test/src/dao/poll_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/poll_dao_test.dart index f01a54f543..cf8bfafc23 100644 --- a/packages/stream_chat_persistence/test/src/dao/poll_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/poll_dao_test.dart @@ -70,7 +70,7 @@ void main() { createdAt: DateTime.now(), updatedAt: DateTime.now(), enforceUniqueVote: false, - extraData: const {'test_extra_data': 'extraData'}, + custom: const {'test_extra_data': 'extraData'}, ); polls.add(poll); diff --git a/packages/stream_chat_persistence/test/src/dao/poll_vote_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/poll_vote_dao_test.dart index 9228d1d75b..faf93700fe 100644 --- a/packages/stream_chat_persistence/test/src/dao/poll_vote_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/poll_vote_dao_test.dart @@ -59,7 +59,7 @@ void main() { updatedAt: DateTime.now(), enforceUniqueVote: false, allowAnswers: true, - extraData: const {'test_extra_data': 'extraData'}, + custom: const {'test_extra_data': 'extraData'}, ); final votes = latestVotesByOption.values.expand((it) => it).toList(); diff --git a/packages/stream_chat_persistence/test/src/mapper/poll_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/poll_mapper_test.dart index 76ad85635a..87bdbc8b3c 100644 --- a/packages/stream_chat_persistence/test/src/mapper/poll_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/poll_mapper_test.dart @@ -56,9 +56,9 @@ void main() { votingVisibility: VotingVisibility.public, allowUserSuggestedOptions: true, options: const [ - '{"id":"option-1","text":"Red","extra_data":{"hex":"#f00"}}', - '{"id":"option-2","text":"Blue","extra_data":{}}', - '{"id":"option-3","text":"Green","extra_data":{}}', + '{"id":"option-1","text":"Red","custom":{"hex":"#f00"}}', + '{"id":"option-2","text":"Blue","custom":{}}', + '{"id":"option-3","text":"Green","custom":{}}', ], voteCount: voteCountsByOption.values.reduce((a, b) => a + b), voteCountsByOption: voteCountsByOption, @@ -68,7 +68,7 @@ void main() { isClosed: false, allowAnswers: true, answersCount: latestAnswers.length, - extraData: {'test_extra_data': 'extraData'}, + custom: {'test_extra_data': 'extraData'}, ); final poll = entity.toPoll( createdBy: currentUser, @@ -89,7 +89,7 @@ void main() { expect(poll.maxVotesAllowed, entity.maxVotesAllowed); expect(poll.allowUserSuggestedOptions, entity.allowUserSuggestedOptions); expect(poll.options, const [ - PollOption(id: 'option-1', text: 'Red', extraData: {'hex': '#f00'}), + PollOption(id: 'option-1', text: 'Red', custom: {'hex': '#f00'}), PollOption(id: 'option-2', text: 'Blue'), PollOption(id: 'option-3', text: 'Green'), ]); @@ -101,7 +101,7 @@ void main() { expect(poll.voteCountsByOption, entity.voteCountsByOption); expect(poll.voteCount, entity.voteCount); expect(poll.createdById, entity.createdById); - expect(poll.extraData, entity.extraData); + expect(poll.custom, entity.custom); }); test('toEntity should map poll into PollEntity', () { @@ -151,7 +151,7 @@ void main() { createdById: currentUser.id, allowUserSuggestedOptions: true, options: const [ - PollOption(id: 'option-1', text: 'Red', extraData: {'hex': '#f00'}), + PollOption(id: 'option-1', text: 'Red', custom: {'hex': '#f00'}), PollOption(id: 'option-2', text: 'Blue'), PollOption(id: 'option-3', text: 'Green'), ], @@ -162,7 +162,7 @@ void main() { enforceUniqueVote: false, allowAnswers: true, answersCount: latestAnswers.length, - extraData: const {'test_extra_data': 'extraData'}, + custom: const {'test_extra_data': 'extraData'}, ); final entity = poll.toEntity(); expect(entity, isA()); @@ -174,9 +174,9 @@ void main() { expect(entity.maxVotesAllowed, poll.maxVotesAllowed); expect(entity.allowUserSuggestedOptions, poll.allowUserSuggestedOptions); expect(entity.options, const [ - '{"id":"option-1","text":"Red","extra_data":{"hex":"#f00"}}', - '{"id":"option-2","text":"Blue","extra_data":{}}', - '{"id":"option-3","text":"Green","extra_data":{}}', + '{"id":"option-1","text":"Red","custom":{"hex":"#f00"}}', + '{"id":"option-2","text":"Blue","custom":{}}', + '{"id":"option-3","text":"Green","custom":{}}', ]); expect(entity.allowAnswers, poll.allowAnswers); expect(entity.answersCount, poll.answersCount); @@ -186,7 +186,7 @@ void main() { expect(entity.voteCountsByOption, poll.voteCountsByOption); expect(entity.voteCount, poll.voteCount); expect(entity.createdById, poll.createdById); - expect(entity.extraData, poll.extraData); + expect(entity.custom, poll.custom); }); test('a poll keeps the translations of its name, description and options through the cache', () { From 6e4f7ed8797afcc471e3e6d17f99bdfbb2166c68 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Wed, 14 Oct 2026 12:01:23 +0200 Subject: [PATCH 26/32] refactor(llc, persistence)!: name channel and member custom data custom and promote their parked fields Co-Authored-By: Claude Opus 5.5 --- .claude/skills/openapi-migration/SKILL.md | 8 +- .../message_search_list_view_test.dart | 2 +- docs/docs_screenshots/test/src/mocks.dart | 4 +- .../thread_list/thread_list_view_test.dart | 2 +- migrations/v11-migration.md | 54 +- openapi-migration/11-channels-and-members.md | 28 +- openapi-migration/15-partial-updates.md | 15 + openapi-migration/README.md | 12 +- openapi-migration/tool/generate_plan.py | 72 +- packages/stream_chat/CHANGELOG.md | 6 +- .../lib/src/client/channel/channel.dart | 54 +- .../stream_chat/lib/src/client/client.dart | 4 +- .../lib/src/core/models/channel_model.dart | 135 +- .../lib/src/core/models/channel_model.g.dart | 20 +- .../lib/src/core/models/channel_state.dart | 10 +- .../lib/src/core/models/member.dart | 98 +- .../lib/src/core/models/member.g.dart | 16 +- .../lib/src/core/util/serializer.dart | 15 +- .../lib/src/db/chat_persistence_client.dart | 1 + .../repository/mapper/channels_mapper.dart | 37 +- .../channel/channel_client_state_test.dart | 4 +- .../test/src/client/channel/channel_test.dart | 36 +- .../test/src/client/client_test.dart | 22 +- .../client_update_channel_partial_test.dart | 104 +- .../client_update_member_partial_test.dart | 101 +- .../test/src/core/api/channel_api_test.dart | 2 +- .../src/core/models/channel_state_test.dart | 16 +- .../test/src/core/models/channel_test.dart | 359 ++--- .../test/src/core/models/member_test.dart | 146 +- .../src/core/models/sort_comparison_test.dart | 4 +- .../src/core/models/sort_registry_test.dart | 2 +- .../test/src/core/util/serializer_test.dart | 12 + .../src/db/chat_persistence_client_test.dart | 19 +- packages/stream_chat/test/src/fakes.dart | 2 +- .../stream_message_search_list_tile.dart | 2 +- .../test/src/channel/channel_header_test.dart | 4 +- .../test/src/channel/channel_image_test.dart | 2 +- .../test/src/channel/channel_page_test.dart | 4 +- .../test/src/channel/thread_page_test.dart | 4 +- .../src/indicators/typing_indicator_test.dart | 4 +- .../src/indicators/unread_indicator_test.dart | 4 +- ...ssage_composer_content_insertion_test.dart | 4 +- .../message_composer_draft_test.dart | 4 +- .../message_composer_drop_test.dart | 4 +- .../message_composer_slow_mode_test.dart | 4 +- .../message_composer_surface_style_test.dart | 4 +- .../src/message_input/message_input_test.dart | 8 +- .../test/src/misc/system_message_test.dart | 12 +- .../test/src/misc/thread_header_test.dart | 4 +- .../stream_thread_list_tile_test.dart | 2 +- packages/stream_chat_persistence/CHANGELOG.md | 1 + .../lib/src/dao/channel_dao.dart | 25 +- .../lib/src/dao/channel_query_dao.dart | 29 +- .../lib/src/db/drift_chat_database.dart | 2 +- .../lib/src/db/drift_chat_database.g.dart | 1171 +++++++++++++++-- .../lib/src/entity/channels.dart | 28 +- .../lib/src/entity/members.dart | 19 +- .../lib/src/mapper/channel_mapper.dart | 25 +- .../lib/src/mapper/member_mapper.dart | 14 +- .../test/src/dao/channel_dao_test.dart | 7 + .../test/src/dao/channel_query_dao_test.dart | 8 +- .../test/src/dao/member_dao_test.dart | 6 +- .../test/src/mapper/channel_mapper_test.dart | 54 +- .../test/src/mapper/member_mapper_test.dart | 28 +- sample_app/benchmark/main_benchmark.dart | 2 +- .../lib/pages/group_chat_details_screen.dart | 2 +- sample_app/lib/pages/new_chat_screen.dart | 2 +- sample_app/lib/widgets/all_members_sheet.dart | 2 +- sample_app/lib/widgets/edit_group_sheet.dart | 2 +- 69 files changed, 1990 insertions(+), 929 deletions(-) diff --git a/.claude/skills/openapi-migration/SKILL.md b/.claude/skills/openapi-migration/SKILL.md index ad073a9f10..834ca71ae4 100644 --- a/.claude/skills/openapi-migration/SKILL.md +++ b/.claude/skills/openapi-migration/SKILL.md @@ -347,10 +347,10 @@ 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 `ChannelModel.hidden` and `User.name` - still are until groups 11 and 09 promote them. 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, 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. - **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/message_search/message_search_list_view_test.dart b/docs/docs_screenshots/test/message_search/message_search_list_view_test.dart index 66db36946e..73f6fc787e 100644 --- a/docs/docs_screenshots/test/message_search/message_search_list_view_test.dart +++ b/docs/docs_screenshots/test/message_search/message_search_list_view_test.dart @@ -23,7 +23,7 @@ GetMessageResponse _makeSearchResult({ ..channel = ChannelModel( id: 'ch-$messageId', type: 'messaging', - extraData: {'name': channelName}, + custom: {'name': channelName}, ); return response; } diff --git a/docs/docs_screenshots/test/src/mocks.dart b/docs/docs_screenshots/test/src/mocks.dart index 73095cc83a..f773a37b99 100644 --- a/docs/docs_screenshots/test/src/mocks.dart +++ b/docs/docs_screenshots/test/src/mocks.dart @@ -155,8 +155,8 @@ void setupMockChannel({ when(() => channel.isMutedStream).thenAnswer((_) => Stream.value(false)); when(() => channel.isPinned).thenReturn(false); when(() => channel.isPinnedStream).thenAnswer((_) => Stream.value(false)); - when(() => channel.extraDataStream).thenAnswer((_) => Stream.value({'name': channelName})); - when(() => channel.extraData).thenReturn({'name': channelName}); + when(() => channel.customStream).thenAnswer((_) => Stream.value({'name': channelName})); + when(() => channel.custom).thenReturn({'name': channelName}); when(() => channel.name).thenReturn(channelName); when(() => channel.nameStream).thenAnswer((_) => Stream.value(channelName)); when(() => channel.image).thenReturn(null); diff --git a/docs/docs_screenshots/test/thread_list/thread_list_view_test.dart b/docs/docs_screenshots/test/thread_list/thread_list_view_test.dart index 6c998e9860..95a2ab4006 100644 --- a/docs/docs_screenshots/test/thread_list/thread_list_view_test.dart +++ b/docs/docs_screenshots/test/thread_list/thread_list_view_test.dart @@ -41,7 +41,7 @@ Thread _makeThread({ channel: ChannelModel( id: id, type: 'messaging', - extraData: {'name': channelName}, + custom: {'name': channelName}, ), parentMessage: parentMessage, latestReplies: [latestReply], diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index 98e7463f94..62dfc6b740 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -35,6 +35,7 @@ onto Stream's OpenAPI-generated API client. - [User Groups](#user-groups) - [Link Previews](#link-previews) - [Partial Updates](#partial-updates) + - [Channel and Member Custom Data](#channel-and-member-custom-data) - [Migration Checklist](#migration-checklist) - [For AI Agents](#for-ai-agents) - [Contributing to this guide](#contributing-to-this-guide) @@ -82,6 +83,7 @@ from the spec, so don't subclass them or depend on their private constructors. | [**Polls**](#polls) | Poll calls return a `Result`; eight responses become `PollResponse`, `PollOptionResponse` and `PollVoteResponse`; queries take `limit`/`next`/`prev`; `VotingVisibility` is an extension type; `extraData` is `custom` | | [**File Upload**](#file-upload) | Uploads return a `Result` and deletes a `Result` instead of throwing; `AttachmentFileUploaderProvider` receives a `Dio` | | [**Partial Updates**](#partial-updates) | Channel and member partial updates — `updatePartial`, `updateName`, `updateImage`, slow mode, pin and archive — return a `Result` instead of throwing; their responses take the API's names, `UpdateChannelPartialResponse` and `UpdateMemberPartialResponse`, and `partialMemberUpdate` becomes `updateMemberPartial` | +| [**Channel and Member Custom Data**](#channel-and-member-custom-data) | `extraData` is `custom` on `ChannelModel`, `Member` and `Channel`, and holds the custom data verbatim; the channel and member flags it carried are fields | | _(filled in per feature as PRs land)_ | | --- @@ -123,7 +125,7 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `SortOption.ASC` / `.DESC` | `SortDirection.asc` / `.desc` | `retyped` | An enum carrying `value` (`1` / `-1`) rather than a bare `int` | | `const [SortOption.desc(f)]` | `[ChannelSort.desc(f)]` | `retyped` | **Drop the `const`.** A sort list can no longer be `const`: a `SortField` holds a closure that reads the value off the model, which is not a constant expression. Every v10 example wrote `const`, so expect this on the first line you touch | | `ChannelSortKey` / `MessageSortKey` / `UserSortKey` / … (extension types over `String`) | `ChannelSortField` / `MessageSearchSortField` / `UserSortField` / … | `retyped` | Same member names. A field is no longer a `String`: read `field.remote` for the wire name. `MessageSortKey` is named for its one endpoint: searching | -| `SortOption.desc('my_custom_field')` | `ChannelSort.desc(ChannelSortField.custom('my_custom_field'))` | `retyped` | Only for a field the model does not declare, and only on channel, member, user and message-search sorts — the other queries pin their sort to declared fields and reject a custom one. `custom` reads `extraData`, so pointing it at a declared field sends the right wire name but reads `null` locally — an offline or re-sorted list comes back unordered | +| `SortOption.desc('my_custom_field')` | `ChannelSort.desc(ChannelSortField.custom('my_custom_field'))` | `retyped` | Only for a field the model does not declare, and only on channel, member, user and message-search sorts — the other queries pin their sort to declared fields and reject a custom one. `custom` reads the model's custom data, so pointing it at a declared field sends the right wire name but reads `null` locally — an offline or re-sorted list comes back unordered | | `SortOrder` | `List`, `List`, … | `removed` | The typedef is gone; signatures name the model's sort type | | `SortOption.fromJson` | `ChannelSort.fromJson` | `moved` | `Sort` has no `fromJson`: the remote name has to resolve back to a declared field | | `SortOption(comparator:)` | — | `removed` | Declare a field whose value projects onto something orderable, or sort the list yourself | @@ -260,6 +262,12 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `PartialUpdateChannelResponse` / `PartialUpdateMemberResponse` identity `==` | value `==`, plus `copyWith` | `retyped` | Two instances with the same fields are now equal | | `StreamChatApi.channel.updateChannelPartial` / `enableSlowdown` / `disableSlowdown` / `updateMemberPartial` | the `StreamChatClient` methods | `removed` | The endpoints moved to the generated client | | `MemberUpdatePayload(pinned: true).toJson()` / `MemberUpdateType.pinned.name` | `{'pinned': true}` / `'pinned'` | `removed` | Same for `archived`. Or call `pinChannel` / `unpinChannel` / `archiveChannel` / `unarchiveChannel` | +| `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 | +| `member.extraData['notifications_muted']` / `['status']` / `['ban_from_future_channels']` / `['future_channel_ban_expires']` / `['deleted_at']` | `Member.notificationsMuted` / `status` / `banFromFutureChannels` / `futureChannelBanExpires` / `deletedAt` | `retyped` | Fields now, not custom data; `copyWith(custom:)` no longer sets them | +| `ChannelModel.custom` / `Member.custom` entry named like one of the model's own fields | the same entry, as custom data | `retyped` | Kept as the API returns it; `toJson` leaves it out so it cannot overwrite the field | +| `member.extraData['role']` | `Member.channelRole` | `removed` | The server deprecated `role` for `channel_role` | | _(more added per feature as PRs land)_ | | | | --- @@ -452,7 +460,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 `1104`, and the upgrade strategy +on v11. The Drift schema version moves from v10's `1000 + n` (`1035` in v10.5.0) to `1106`, 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 @@ -715,6 +723,48 @@ directly — `updateMemberPartial(channelId: …, channelType: …, set: {'pinne > field the API adds later reaches you without another break. `MemberUpdatePayload` and `MemberUpdateType` named > only two of the keys a membership accepts, so a plain map replaces them. +### Channel and Member Custom Data + +**`extraData` is renamed `custom` on `ChannelModel`, `Member` and `Channel`.** That covers the fields, the +constructor and `copyWith` arguments, `Channel.extraData`, `Channel.extraDataStream` (now `customStream`), and the +`extraData` argument of `StreamChatClient.channel`. + +```dart +// v10 +final channel = client.channel('messaging', id: 'general', extraData: {'topic': 'support'}); +final topic = channel.extraData['topic']; + +// v11 +final channel = client.channel('messaging', id: 'general', custom: {'topic': 'support'}); +final topic = channel.custom['topic']; +``` + +**The channel and member flags are fields, not custom data.** `ChannelModel.disabled`, `hidden` and `truncatedAt` +read the same, and `muted`, `blocked`, `truncatedBy`, the auto-translation settings and `Member.notificationsMuted`, +`status`, `banFromFutureChannels`, `futureChannelBanExpires` and `deletedAt` are new fields. `custom` no longer holds +any of their keys, and an entry with one of those names in `custom` or in `copyWith(custom:)` no longer sets the +field: + +```dart +// v10 +final hidden = channel.extraData['hidden'] == true; +final shown = model.copyWith(extraData: {...model.extraData, 'hidden': false}); + +// v11 +final hidden = channel.hidden; +final shown = model.copyWith(hidden: false); +``` + +**`custom` is the custom data exactly as the API returns it.** An entry named like one of the model's own fields +stays in `custom` and leaves the field alone; `toJson` leaves it out, so it cannot overwrite the field. +`Member.custom` no longer holds `role`, which the server deprecated: read `channelRole`. + +**Filter and sort on a flag through its declared field,** such as `ChannelFilterField.hidden`, rather than +`.custom('hidden')`, which reads `custom` locally and finds nothing there. + +> **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. + ### Moderation **Muting, banning and flagging return a `Result` instead of throwing**, on both `StreamChatClient` diff --git a/openapi-migration/11-channels-and-members.md b/openapi-migration/11-channels-and-members.md index f7ad4f5ddd..bffb0763b4 100644 --- a/openapi-migration/11-channels-and-members.md +++ b/openapi-migration/11-channels-and-members.md @@ -67,10 +67,10 @@ ## Decisions to make - `ChannelState`, `ChannelModel` and `Member` are public, persisted, and rebuilt from WebSocket events. Keep ours and map. -- **`ChannelModel` and `Member` promote their `extraData`-backed getters to fields** (README rule 9). `ChannelModel.disabled`, `hidden`, `muted`, `blocked`, `truncatedAt`, `truncatedBy`, `autoTranslationEnabled` and `autoTranslationLanguage`, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, `futureChannelBanExpires` and `deletedAt`, all arrive as root fields but live in `extraData` and are read back through getters; they become fields with persistence columns, and the constructors stop writing them into the custom data, which is renamed `custom`. `name` and `image` stay in it: `ChannelResponse` has no such fields. Their keys leave the custom data, a key set there no longer sets the field, and a `custom` filter or sort field naming one reads null locally. When promoting: (1) decide each field's `merge` rule. Today `ChannelModel.merge` keeps the flags and `truncatedAt` when a payload leaves them out and drops the other three, as on v11; `Member` has no `merge`. The server always sends `disabled`, omits `hidden` and `blocked` from every event, clears `truncated_by` only through a server-side update (and may leave it out where it does not load the user), never sends `muted` or `mute_expires_at` on the channel, and omits `auto_translation_*` when false or empty. (2) Make `toJson` leave out custom keys named like a field, or a same-named key overwrites the field in the output. (3) Add persistence columns; `truncatedBy` is a user, so it needs the users table or a stored map. Raised on [#2958](https://github.com/GetStream/stream-chat-flutter/pull/2958). - **Whether `ChannelConfig` exposes the generated-only config fields.** `ChannelConfigWithInfo` carries `allowedFlagReasons`, `automodBehavior`, `automodThresholds`, `blocklist`, `blocklistBehavior`, `blocklists`, `countMessages`, `customEvents`, `grants`, `name`, `partitionSize`, `partitionTtl`, `quotes` and `reminders`, which `ChannelConfig` does not model and v1 drops as well. `blocklists` and `automodThresholds` would need hand-written models. The mapper marks the drop with a TODO. - **Expose `hide_messages_before` on `ChannelState`.** `clear_history` writes it on the caller's member row, and the server returns it next to `channel` in each channel-state entry (`ChannelStateResponse.hideMessagesBefore`), never on the channel itself. v1 drops it today. - `sync` returns `SyncResponse` — one of the two models that needed the WSEvent generator patch. Verify it decodes before relying on it. +- **How `Channel` hands its creation data to `getOrCreateChannel`.** The `Channel` constructor and `StreamChatClient.channel` take one `custom` map, and v1 sends it whole as the request's flat `data`, so `client.channel('messaging', custom: {'members': [...]})` creates the channel with those members. v2's `ChannelInput` types `members`, `invites`, `team`, `frozen`, `disabled`, `createdBy`, `configOverrides` and the auto-translation settings, next to its own `custom`. Either the request mapper lifts those keys out of the map into the typed fields and sends the rest as `custom`, or `Channel` and `client.channel` take typed arguments such as `members`, a break. ## Decisions taken @@ -80,24 +80,15 @@ `ChannelConfig`, `Member`, `Command` and `ChatPreferences`, the way `user_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.** The - ones `ChannelModel` and `Member` did not read get a typed getter over `extraData`, the pattern `muted` - and `blocked` already used: `ChannelModel.truncatedBy`, `autoTranslationEnabled` and - `autoTranslationLanguage`, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, - `futureChannelBanExpires` and `deletedAt`. Their keys stay in `extraData`, so nothing breaks and the - offline cache keeps them without new columns. Promoting them to stored fields is the decision above. - Three get no getter: the channel's `hide_messages_before` and `mute_expires_at`, which the server +- **Every field `ChannelResponse` and `ChannelMemberResponse` declare is reachable on ours,** as a + field: [15](15-partial-updates.md) promoted the ones v10 parked in `extraData` and renamed it `custom`. + Three are left out: the channel's `hide_messages_before` and `mute_expires_at`, which the server never fills on the channel (a mute's expiry is `ChannelMute.expires`), and the member's deprecated `role`. The nested config types drop `ChatPreferences.distinctChannelMessages` and `Command`'s `createdAt` / `updatedAt`, which ours do not model. -- **The mapper writes the server fields where v1 JSON puts them,** into `extraData` under their wire - keys. Custom data named like one of those fields or a top-level field is dropped, as - `user_mapper.dart` does. A mapped channel or member still differs from a v1 decode in three ways, - all for group 09 or this group to settle: dates in `extraData` are formatted by the client rather - than kept as the server sent them (see the risk below); `truncatedBy`, `createdBy` and each member's - user go through `user_mapper.dart`, which drops the user fields `User` does not model; and - `mute_expires_at` and `hide_messages_before` are left out, where v1 would keep them in `extraData` if - the server sent them on the channel. +- **The mapper copies `custom` verbatim** (README rule 9). A mapped channel or member still differs from + a v1 decode in one way, for group 09 to settle: `truncatedBy`, `createdBy` and each member's user go + through `user_mapper.dart`, which drops the user fields `User` does not model. - **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. @@ -105,9 +96,8 @@ - `sync` and `queryMembers` live in `general_api.dart`, not `channel_api.dart` — this group reaches into that file. - `queryChannels` drives the channel list controllers and the offline cache; a shape change here is felt everywhere. -- A consumer reading `channel.extraData['hidden']` and friends loses the key once the flags are fields; none in this repo. +- Sending `Channel`'s `custom` map as `ChannelInput.custom` as it stands turns `members` and the other typed keys into custom data, so `client.channel('messaging', custom: {'members': [...]})` creates a channel without its members, silently. - **A zero timestamp decodes differently on v2.** v1 sends Go's zero time as `0001-01-01T00:00:00Z`; v2 encodes timestamps as epoch nanoseconds and sends `0`, which decodes as 1970-01-01. A truncated channel shows it: its `last_message_at` is zero time, so the same channel answers year 1 from a v1 query or WebSocket event and 1970 from `/api/v2/chat/channels`. Nothing in the SDK behaves differently today, since both precede `createdAt` (`lastUpdatedAt` falls back to it, and the read fallback and the `max` against new messages agree), but the two sources disagree on the value. Normalize it deliberately when `queryChannels` moves, and check the other timestamps the server can send as zero. -- **Dates the mapper writes into `extraData` are formatted differently from v1.** v1 keeps the server's string (`2020-01-29T03:22:47.63613Z`); the mapper writes `toIso8601String()` (`2020-01-29T03:22:47.636130Z`). The getters return the same `DateTime`, but `Member` compares `extraData`, so the same member from a v1 payload and from the mapper compares unequal. Promoting the fields removes it. - [08](08-moderation-and-blocklists.md)'s `muteChannel` drops `channelMute`, `channelMutes` and `ownUser` because the generated `ChannelMute` carries a `ChannelResponse?` and a `UserResponse?` where ours needs a non-nullable `ChannelModel` and `User`. `channels_mapper.dart` covers the channel side now; it needs 09 as well. Decide the null case there too — ours are non-nullable, the generated ones are not, the same question [14](14-banned-users.md) records for `BanResponse.user`. ## Definition of done @@ -128,5 +118,5 @@ retyped but whose docs it left alone. - [ ] Tests follow [`TESTING.md`](../TESTING.md): no `group` organizing a file by method, each name states its subject and behaviour. -- [ ] `channels_mapper.dart` has no `_shadowedCustomKeys`: `ChannelModel.custom` is the wire's `custom`, +- [x] `channels_mapper.dart` has no `_shadowedCustomKeys`: `ChannelModel.custom` is the wire's `custom`, verbatim, and no flag is written into it. diff --git a/openapi-migration/15-partial-updates.md b/openapi-migration/15-partial-updates.md index 5ffa36546c..de2ed60f2b 100644 --- a/openapi-migration/15-partial-updates.md +++ b/openapi-migration/15-partial-updates.md @@ -63,6 +63,21 @@ `toJson` and an enum the model rules retire. The client writes `{'pinned': true}` / `['pinned']`. - **The request always sends `set` and `unset`, `null` when not given,** where v1 left the key out. The server reads both the same, and still rejects a call with neither. +- **`ChannelModel` and `Member` promote the fields v10 parked in `extraData`, and rename it `custom`** + (README rule 9), since these calls are the first to map them. `ChannelModel.disabled`, `hidden`, + `muted`, `blocked`, `truncatedAt`, `truncatedBy`, `autoTranslationEnabled` and + `autoTranslationLanguage`, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, + `futureChannelBanExpires` and `deletedAt`, are fields with persistence columns; `truncatedBy` is + read back through the users table. `Channel.extraData`, `extraDataStream`, and the `extraData` + argument of the `Channel` constructor and of `StreamChatClient.channel` are renamed with it. +- **`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. `Member` leaves the deprecated `role` out of `custom`. +- **`ChannelModel.merge` keeps a flag the incoming channel leaves out, and turns auto translation off + when it is missing,** as before. Checked live on v1 and v2: queries always send `hidden` and + `blocked`; events leave them out, except the `channel.hidden` a block sends; `muted` is never sent on + a channel; `truncated_at` and `truncated_by` come on every event once the channel is truncated; + `disabled` and `frozen` always. A client token cannot set `auto_translation_*`, so that rule rests + on the backend leaving the keys out when false or empty. ## Risks diff --git a/openapi-migration/README.md b/openapi-migration/README.md index dcd6ab5c82..823b54d3bb 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -157,10 +157,10 @@ 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. `ChannelModel.hidden` and `User.name` are parked today, and groups 11 - and 09 promote them. `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. + 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 + 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. ### Temporary adapters @@ -191,9 +191,11 @@ surfaces before it reaches `Message` and `ChannelState`: - **08** is where we decide what *not* to expose: 34 generated operations against 10 hand-written methods. - **14** is `queryBannedUsers`, split out of 08 because it is the only moderation call that answers with a model. The `User` mappers it needs landed with group 04. -- **10–11** are the core of the SDK; 11 promotes the channel flags parked in `extraData` (rule 9). +- **10–11** are the core of the SDK. - **15** is the partial channel and member updates, split out of 11 so `channels_mapper.dart` lands on calls that read nothing into `ChannelState`. It landed first; the full update waits in 11 for group 10's message mappers. + As the first group to map `ChannelModel` and `Member`, it promoted their 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 diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index cea0b8e0f8..146141434e 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -666,23 +666,6 @@ def match(verb, path): decisions=[ '`ChannelState`, `ChannelModel` and `Member` are public, persisted, and rebuilt from WebSocket ' 'events. Keep ours and map.', - '**`ChannelModel` and `Member` promote their `extraData`-backed getters to fields** (README rule 9). ' - '`ChannelModel.disabled`, `hidden`, `muted`, `blocked`, `truncatedAt`, `truncatedBy`, ' - '`autoTranslationEnabled` and `autoTranslationLanguage`, and `Member.notificationsMuted`, ' - '`status`, `banFromFutureChannels`, `futureChannelBanExpires` and `deletedAt`, all arrive as root fields ' - 'but live in `extraData` and are read back through getters; they become fields with persistence ' - 'columns, and the constructors stop writing them into the custom data, which is renamed `custom`. ' - '`name` and `image` stay in it: `ChannelResponse` has no such fields. Their keys leave the custom data, ' - 'a key set there no longer sets the field, and a `custom` filter or sort field naming one reads null ' - 'locally. When promoting: (1) decide each field\'s `merge` rule. Today `ChannelModel.merge` keeps the ' - 'flags and `truncatedAt` when a payload leaves them out and drops the other three, as on v11; `Member` ' - 'has no `merge`. The server always sends `disabled`, omits `hidden` and `blocked` from every event, ' - 'clears `truncated_by` only through a server-side update (and may leave it out where it does not load ' - 'the user), never sends `muted` or `mute_expires_at` on the channel, and omits `auto_translation_*` when ' - 'false or empty. (2) Make `toJson` leave out custom keys named like a field, or a same-named key ' - 'overwrites the field in the output. (3) Add persistence columns; `truncatedBy` is a user, so it needs ' - 'the users table or a stored map. Raised on ' - '[#2958](https://github.com/GetStream/stream-chat-flutter/pull/2958).', '**Whether `ChannelConfig` exposes the generated-only config fields.** `ChannelConfigWithInfo` ' 'carries `allowedFlagReasons`, `automodBehavior`, `automodThresholds`, `blocklist`, ' '`blocklistBehavior`, `blocklists`, `countMessages`, `customEvents`, `grants`, `name`, ' @@ -694,14 +677,22 @@ def match(verb, path): '(`ChannelStateResponse.hideMessagesBefore`), never on the channel itself. v1 drops it today.', '`sync` returns `SyncResponse` — one of the two models that needed the WSEvent generator patch. ' 'Verify it decodes before relying on it.', + '**How `Channel` hands its creation data to `getOrCreateChannel`.** The `Channel` constructor and ' + '`StreamChatClient.channel` take one `custom` map, and v1 sends it whole as the request\'s flat `data`, ' + 'so `client.channel(\'messaging\', custom: {\'members\': [...]})` creates the channel with those ' + 'members. v2\'s `ChannelInput` types `members`, `invites`, `team`, `frozen`, `disabled`, `createdBy`, ' + '`configOverrides` and the auto-translation settings, next to its own `custom`. Either the request ' + 'mapper lifts those keys out of the map into the typed fields and sends the rest as `custom`, or ' + '`Channel` and `client.channel` take typed arguments such as `members`, a break.', ], risks=[ '`sync` and `queryMembers` live in `general_api.dart`, not `channel_api.dart` — this group reaches ' 'into that file.', '`queryChannels` drives the channel list controllers and the offline cache; a shape change here is ' 'felt everywhere.', - 'A consumer reading `channel.extraData[\'hidden\']` and friends loses the key once the flags are fields; ' - 'none in this repo.', + 'Sending `Channel`\'s `custom` map as `ChannelInput.custom` as it stands turns `members` and the other ' + 'typed keys into custom data, so `client.channel(\'messaging\', custom: {\'members\': [...]})` ' + 'creates a channel without its members, silently.', '**A zero timestamp decodes differently on v2.** v1 sends Go\'s zero time as ' '`0001-01-01T00:00:00Z`; v2 encodes timestamps as epoch nanoseconds and sends `0`, which decodes as ' '1970-01-01. A truncated channel shows it: its `last_message_at` is zero time, so the same channel ' @@ -710,11 +701,6 @@ def match(verb, path): 'to it, and the read fallback and the `max` against new messages agree), but the two sources disagree on ' 'the value. Normalize it deliberately when `queryChannels` moves, and check the other ' 'timestamps the server can send as zero.', - '**Dates the mapper writes into `extraData` are formatted differently from v1.** v1 keeps the ' - 'server\'s string (`2020-01-29T03:22:47.63613Z`); the mapper writes `toIso8601String()` ' - '(`2020-01-29T03:22:47.636130Z`). The getters return the same `DateTime`, but `Member` compares ' - '`extraData`, so the same member from a v1 payload and from the mapper compares unequal. Promoting ' - 'the fields removes it.', '[08](08-moderation-and-blocklists.md)\'s `muteChannel` drops `channelMute`, `channelMutes` and ' '`ownUser` because the generated `ChannelMute` carries a `ChannelResponse?` and a `UserResponse?` ' 'where ours needs a non-nullable `ChannelModel` and `User`. `channels_mapper.dart` covers the ' @@ -729,29 +715,20 @@ def match(verb, path): `ChannelConfig`, `Member`, `Command` and `ChatPreferences`, the way `user_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.** The - ones `ChannelModel` and `Member` did not read get a typed getter over `extraData`, the pattern `muted` - and `blocked` already used: `ChannelModel.truncatedBy`, `autoTranslationEnabled` and - `autoTranslationLanguage`, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, - `futureChannelBanExpires` and `deletedAt`. Their keys stay in `extraData`, so nothing breaks and the - offline cache keeps them without new columns. Promoting them to stored fields is the decision above. - Three get no getter: the channel's `hide_messages_before` and `mute_expires_at`, which the server + - **Every field `ChannelResponse` and `ChannelMemberResponse` declare is reachable on ours,** as a + field: [15](15-partial-updates.md) promoted the ones v10 parked in `extraData` and renamed it `custom`. + Three are left out: the channel's `hide_messages_before` and `mute_expires_at`, which the server never fills on the channel (a mute's expiry is `ChannelMute.expires`), and the member's deprecated `role`. The nested config types drop `ChatPreferences.distinctChannelMessages` and `Command`'s `createdAt` / `updatedAt`, which ours do not model. - - **The mapper writes the server fields where v1 JSON puts them,** into `extraData` under their wire - keys. Custom data named like one of those fields or a top-level field is dropped, as - `user_mapper.dart` does. A mapped channel or member still differs from a v1 decode in three ways, - all for group 09 or this group to settle: dates in `extraData` are formatted by the client rather - than kept as the server sent them (see the risk below); `truncatedBy`, `createdBy` and each member's - user go through `user_mapper.dart`, which drops the user fields `User` does not model; and - `mute_expires_at` and `hide_messages_before` are left out, where v1 would keep them in `extraData` if - the server sent them on the channel. + - **The mapper copies `custom` verbatim** (README rule 9). A mapped channel or member still differs from + a v1 decode in one way, for group 09 to settle: `truncatedBy`, `createdBy` and each member's user go + through `user_mapper.dart`, which drops the user fields `User` does not model. - **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. """), done=DONE + ( - '- [ ] `channels_mapper.dart` has no `_shadowedCustomKeys`: `ChannelModel.custom` is the wire\'s `custom`,\n' + '- [x] `channels_mapper.dart` has no `_shadowedCustomKeys`: `ChannelModel.custom` is the wire\'s `custom`,\n' ' verbatim, and no flag is written into it.\n' ), ), @@ -904,6 +881,21 @@ def match(verb, path): `toJson` and an enum the model rules retire. The client writes `{'pinned': true}` / `['pinned']`. - **The request always sends `set` and `unset`, `null` when not given,** where v1 left the key out. The server reads both the same, and still rejects a call with neither. + - **`ChannelModel` and `Member` promote the fields v10 parked in `extraData`, and rename it `custom`** + (README rule 9), since these calls are the first to map them. `ChannelModel.disabled`, `hidden`, + `muted`, `blocked`, `truncatedAt`, `truncatedBy`, `autoTranslationEnabled` and + `autoTranslationLanguage`, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, + `futureChannelBanExpires` and `deletedAt`, are fields with persistence columns; `truncatedBy` is + read back through the users table. `Channel.extraData`, `extraDataStream`, and the `extraData` + argument of the `Channel` constructor and of `StreamChatClient.channel` are renamed with it. + - **`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. `Member` leaves the deprecated `role` out of `custom`. + - **`ChannelModel.merge` keeps a flag the incoming channel leaves out, and turns auto translation off + when it is missing,** as before. Checked live on v1 and v2: queries always send `hidden` and + `blocked`; events leave them out, except the `channel.hidden` a block sends; `muted` is never sent on + a channel; `truncated_at` and `truncated_by` come on every event once the channel is truncated; + `disabled` and `frozen` always. A client token cannot set `auto_translation_*`, so that rule rests + on the backend leaving the keys out when false or empty. """), risks=[ '`disableSlowMode` unsets `cooldown`, which the server rejects on both v1 and v2 as a reserved field. ' diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 672898f0bd..7c770729a2 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -104,6 +104,10 @@ - `UpdateChannelPartialResponse.channel` and `UpdateMemberPartialResponse.channelMember` are nullable, and `UpdateChannelPartialResponse.members` is a non-nullable list. - `StreamChatApi.channel.updateChannelPartial`, `enableSlowdown`, `disableSlowdown` and `updateMemberPartial` are removed; call them on `StreamChatClient` instead. - `MemberUpdatePayload` and `MemberUpdateType` are removed; pass `set: {'pinned': true}` or `unset: ['pinned']` (and `archived`) to `updateMemberPartial`, or call `pinChannel`, `archiveChannel` and their counterparts. +- `ChannelModel.extraData` and `Member.extraData` are renamed `custom`, and so are `Channel.extraData`, `Channel.extraDataStream` (now `customStream`), and the `extraData` argument of the `Channel` constructor and of `StreamChatClient.channel`. +- `ChannelModel.disabled`, `hidden`, `muted`, `blocked`, `truncatedAt`, `truncatedBy` and the auto-translation settings, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, `futureChannelBanExpires` and `deletedAt`, are fields instead of entries in the custom data, so `custom` no longer holds `hidden`, `status` and the rest, and an entry with that name in `custom` or in `copyWith(custom:)` no longer sets the field. +- `ChannelModel.custom` and `Member.custom` hold the custom data exactly as the API returns it, and `toJson` leaves out an entry named like one of the model's own fields. +- `Member.custom` no longer holds `role`; read `channelRole`. 🐞 Fixed @@ -133,7 +137,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 `ChannelModel.truncatedBy`, `autoTranslationEnabled` and `autoTranslationLanguage`, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, `futureChannelBanExpires`, `deletedAt` and `topLevelFields`; the constructors, and `ChannelModel.copyWith`, accept the new fields. +- Added `ChannelModel.muted`, `blocked`, `truncatedBy`, `autoTranslationEnabled` and `autoTranslationLanguage`, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, `futureChannelBanExpires`, `deletedAt` and `topLevelFields`; the constructors and `copyWith` accept the new fields. - Added a `prev` argument to `queryPolls` and `queryPollVotes`, and a `prev` cursor on their responses, for paging backwards. 🔒 Security diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index e45a903036..e09925b2da 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -19,11 +19,11 @@ const incomingTypingStartEventTimeout = 7; /// /// {@template name} /// If an optional [name] argument is provided in the constructor then it -/// will be set on [extraData] with a key of 'name'. +/// will be set on [custom] with a key of 'name'. /// /// ```dart /// final channel = Channel(client, type, id, name: 'Channel name'); -/// print(channel.name == channel.extraData['name']); // true +/// print(channel.name == channel.custom['name']); // true /// ``` /// /// Before the channel is initialized the name can be set directly: @@ -43,11 +43,11 @@ const incomingTypingStartEventTimeout = 7; /// /// {@template image} /// If an optional [image] argument is provided in the constructor then it -/// will be set on [extraData] with a key of 'image'. +/// will be set on [custom] with a key of 'image'. /// /// ```dart /// final channel = Channel(client, type, id, image: 'https://getstream.io/image.png'); -/// print(channel.image == channel.extraData['image']); // true +/// print(channel.image == channel.custom['image']); // true /// ``` /// /// Before the channel is initialized the image can be set directly: @@ -65,18 +65,18 @@ const incomingTypingStartEventTimeout = 7; class Channel { /// Class that manages a specific channel. /// - /// Optional [extraData] and [image] properties can be provided. The [image] - /// is exposed to easily set a key of 'image' on [extraData]. + /// Optional [custom] and [image] properties can be provided. The [image] + /// is exposed to easily set a key of 'image' on [custom]. Channel( this._client, this._type, this._id, { String? name, String? image, - Map? extraData, + Map? custom, }) : _cid = _id != null ? '$_type:$_id' : null, - _extraData = { - ...?extraData, + _custom = { + ...?custom, if (name != null) 'name': name, if (image != null) 'image': image, } { @@ -92,7 +92,7 @@ class Channel { _id = channelState.channel!.id, _type = channelState.channel!.type, _cid = channelState.channel!.cid, - _extraData = channelState.channel!.extraData { + _custom = channelState.channel!.custom { _initState(channelState); // Initialize the state immediately. } @@ -106,7 +106,7 @@ class Channel { String? _id; String? _cid; - final Map _extraData; + final Map _custom; /// Shortcut to set channel name. /// @@ -118,7 +118,7 @@ class Channel { 'to update the channel name', ); } - _extraData.addAll({'name': name}); + _custom.addAll({'name': name}); } /// Shortcut to set channel image. @@ -131,17 +131,17 @@ class Channel { 'to update the channel image', ); } - _extraData.addAll({'image': image}); + _custom.addAll({'image': image}); } - set extraData(Map extraData) { + set custom(Map custom) { if (_isInitialized) { throw StateError( 'Once the channel is initialized you should use `channel.update` ' 'to update channel data', ); } - _extraData.addAll(extraData); + _custom.addAll(custom); } /// Whether this channel is identified by its member set rather than an @@ -524,11 +524,11 @@ class Channel { return state!.channelState.channel?.team; } - /// Channel extra data. - Map get extraData { - var data = state?.channelState.channel?.extraData; + /// Channel custom data. + Map get custom { + var data = state?.channelState.channel?.custom; if (data == null || data.isEmpty) { - data = _extraData; + data = _custom; } return data; } @@ -542,18 +542,18 @@ class Channel { return state!.channelStateStream.map((cs) => cs.channel?.ownCapabilities ?? []).distinct(); } - /// Channel extra data as a stream. - Stream> get extraDataStream { + /// Channel custom data as a stream. + Stream> get customStream { _checkInitialized(); return state!.channelStateStream.map( - (cs) => cs.channel?.extraData ?? _extraData, + (cs) => cs.channel?.custom ?? _custom, ); } /// Shortcut to get channel name. /// /// {@macro name} - String? get name => extraData['name'] as String?; + String? get name => custom['name'] as String?; /// Channel [name] as a stream. /// @@ -562,13 +562,13 @@ class Channel { /// {@macro name} Stream get nameStream { _checkInitialized(); - return extraDataStream.map((it) => it['name'] as String?).distinct(); + return customStream.map((it) => it['name'] as String?).distinct(); } /// Shortcut to get channel image. /// /// {@macro image} - String? get image => extraData['image'] as String?; + String? get image => custom['image'] as String?; /// Channel [image] as a stream. /// @@ -577,7 +577,7 @@ class Channel { /// {@macro image} Stream get imageStream { _checkInitialized(); - return extraDataStream.map((it) => it['image'] as String?).distinct(); + return customStream.map((it) => it['image'] as String?).distinct(); } /// The main Stream chat client. @@ -2157,7 +2157,7 @@ class Channel { channelState ??= await _client.queryChannel( type, channelId: id, - channelData: _extraData, + channelData: _custom, state: state, watch: watch, presence: presence, diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 264b82f95a..a001dce0af 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -1374,12 +1374,12 @@ class StreamChatClient { Channel channel( String type, { String? id, - Map? extraData, + Map? custom, }) { if (id != null && state.channels.containsKey('$type:$id')) { return state.channels['$type:$id']!; } - return Channel(this, type, id, extraData: extraData); + return Channel(this, type, id, custom: custom); } /// Creates a new channel diff --git a/packages/stream_chat/lib/src/core/models/channel_model.dart b/packages/stream_chat/lib/src/core/models/channel_model.dart index 00bc69428b..34f558267e 100644 --- a/packages/stream_chat/lib/src/core/models/channel_model.dart +++ b/packages/stream_chat/lib/src/core/models/channel_model.dart @@ -12,9 +12,6 @@ part 'channel_model.g.dart'; @JsonSerializable() class ChannelModel { /// Creates a channel model. - /// - /// The [disabled], [hidden], [muted], [blocked], [truncatedAt], [truncatedBy], [autoTranslationEnabled] and - /// [autoTranslationLanguage] arguments, when given, are stored in [extraData], overriding an entry of the same name. ChannelModel({ String? id, String? type, @@ -29,19 +26,19 @@ class ChannelModel { this.deletedAt, this.memberCount = 0, this.members, - Map extraData = const {}, + Map custom = const {}, this.team, this.cooldown = 0, - bool? disabled, - bool? hidden, - bool? muted, - bool? blocked, - DateTime? truncatedAt, + this.disabled, + this.hidden, + this.muted, + this.blocked, + this.truncatedAt, this.messageCount, this.filterTags, - User? truncatedBy, - bool? autoTranslationEnabled, - String? autoTranslationLanguage, + this.truncatedBy, + this.autoTranslationEnabled, + this.autoTranslationLanguage, }) : assert( (cid != null && cid.contains(':')) || (id != null && type != null), 'provide either a cid or an id and type', @@ -53,20 +50,7 @@ class ChannelModel { createdAt = createdAt ?? DateTime.now(), updatedAt = updatedAt ?? DateTime.now(), ownCapabilities = ownCapabilities?.map(ChannelCapability.new).toList(), - - // For backwards compatibility, set 'disabled', 'hidden', 'muted', 'blocked', 'truncated_at', 'truncated_by', - // 'auto_translation_enabled' and 'auto_translation_language' in [extraData]. - extraData = { - ...extraData, - if (disabled != null) 'disabled': disabled, - if (hidden != null) 'hidden': hidden, - if (muted != null) 'muted': muted, - if (blocked != null) 'blocked': blocked, - if (truncatedAt != null) 'truncated_at': truncatedAt.toIso8601String(), - if (truncatedBy != null) 'truncated_by': truncatedBy.toJson(), - if (autoTranslationEnabled != null) 'auto_translation_enabled': autoTranslationEnabled, - if (autoTranslationLanguage != null) 'auto_translation_language': autoTranslationLanguage, - }; + custom = {...custom}; /// Create a new instance from a json factory ChannelModel.fromJson(Map json) => _$ChannelModelFromJson( @@ -142,51 +126,50 @@ class ChannelModel { final int cooldown; /// True if the channel is disabled - @JsonKey(includeToJson: false, includeFromJson: false) - bool? get disabled => extraData['disabled'].safeCast(); + @JsonKey(includeIfNull: false) + final bool? disabled; /// True if the channel is hidden - @JsonKey(includeToJson: false, includeFromJson: false) - bool? get hidden => extraData['hidden'].safeCast(); + @JsonKey(includeIfNull: false) + final bool? hidden; /// True if the channel is muted by the current user - @JsonKey(includeToJson: false, includeFromJson: false) - bool? get muted => extraData['muted'].safeCast(); + @JsonKey(includeIfNull: false) + final bool? muted; /// True if the channel is blocked by the current user - @JsonKey(includeToJson: false, includeFromJson: false) - bool? get blocked => extraData['blocked'].safeCast(); + @JsonKey(includeIfNull: false) + final bool? blocked; /// The date of the last time channel got truncated - @JsonKey(includeToJson: false, includeFromJson: false) - DateTime? get truncatedAt { - final truncatedAt = extraData['truncated_at'].safeCast(); - if (truncatedAt != null && truncatedAt.isNotEmpty) { - return DateTime.parse(truncatedAt); - } + @JsonKey(includeIfNull: false, fromJson: _dateFromJson) + final DateTime? truncatedAt; - return null; - } + static DateTime? _dateFromJson(Object? value) => switch (value) { + final String date when date.isNotEmpty => DateTime.parse(date), + _ => null, + }; /// The user that last truncated this channel. - @JsonKey(includeToJson: false, includeFromJson: false) - User? get truncatedBy => _userFrom(extraData['truncated_by']); + @JsonKey(includeIfNull: false, fromJson: _userFromJson) + final User? truncatedBy; - static User? _userFrom(Object? value) => switch (value) { + static User? _userFromJson(Object? value) => switch (value) { final Map json when json['id'] is String => User.fromJson(json), _ => null, }; /// Whether messages in this channel are translated automatically. - @JsonKey(includeToJson: false, includeFromJson: false) - bool? get autoTranslationEnabled => extraData['auto_translation_enabled'].safeCast(); + @JsonKey(includeIfNull: false) + final bool? autoTranslationEnabled; /// The language messages in this channel are translated into automatically. - @JsonKey(includeToJson: false, includeFromJson: false) - String? get autoTranslationLanguage => extraData['auto_translation_language'].safeCast(); + @JsonKey(includeIfNull: false) + final String? autoTranslationLanguage; - /// Map of custom channel extraData - final Map extraData; + /// This channel's custom data: every key not in [topLevelFields]. + @JsonKey(name: 'extra_data') + final Map custom; /// The team the channel belongs to @JsonKey(includeToJson: false) @@ -194,7 +177,7 @@ class ChannelModel { /// Shortcut for channel name String? get name { - final name = extraData['name'].safeCast(); + final name = custom['name'].safeCast(); if (name != null && name.isNotEmpty) return name; return null; @@ -213,8 +196,7 @@ class ChannelModel { @JsonKey(includeToJson: false) final List? filterTags; - /// Known top level fields. - /// Useful for [Serializer] methods. + /// The JSON keys [ChannelModel.fromJson] reads into named fields; every other key is kept in [custom]. static const topLevelFields = [ 'id', 'type', @@ -233,11 +215,22 @@ class ChannelModel { 'cooldown', 'message_count', 'filter_tags', + 'disabled', + 'hidden', + 'muted', + 'blocked', + 'truncated_at', + 'truncated_by', + 'auto_translation_enabled', + 'auto_translation_language', ]; /// Serialize to json + /// + /// Writes the entries of [custom] at the root, leaving out any named like one of [topLevelFields]. Map toJson() => Serializer.moveFromExtraDataToRoot( _$ChannelModelToJson(this), + topLevelFields: topLevelFields, ); /// Creates a copy of [ChannelModel] with specified attributes overridden. @@ -255,7 +248,7 @@ class ChannelModel { DateTime? deletedAt, int? memberCount, List? members, - Map? extraData, + Map? custom, String? team, int? cooldown, bool? disabled, @@ -282,31 +275,19 @@ class ChannelModel { deletedAt: deletedAt ?? this.deletedAt, memberCount: memberCount ?? this.memberCount, members: members ?? this.members, - extraData: extraData ?? this.extraData, + custom: custom ?? this.custom, team: team ?? this.team, cooldown: cooldown ?? this.cooldown, - disabled: disabled ?? extraData?['disabled'].safeCast() ?? this.disabled, - hidden: hidden ?? extraData?['hidden'].safeCast() ?? this.hidden, - muted: muted ?? extraData?['muted'].safeCast() ?? this.muted, - blocked: blocked ?? extraData?['blocked'].safeCast() ?? this.blocked, - truncatedAt: - truncatedAt ?? - (extraData?['truncated_at'] == null - ? null - // ignore: cast_nullable_to_non_nullable - : DateTime.parse(extraData?['truncated_at'] as String)) ?? - this.truncatedAt, + disabled: disabled ?? this.disabled, + hidden: hidden ?? this.hidden, + muted: muted ?? this.muted, + blocked: blocked ?? this.blocked, + truncatedAt: truncatedAt ?? this.truncatedAt, messageCount: messageCount ?? this.messageCount, filterTags: filterTags ?? this.filterTags, - truncatedBy: truncatedBy ?? _userFrom(extraData?['truncated_by']) ?? this.truncatedBy, - autoTranslationEnabled: - autoTranslationEnabled ?? - extraData?['auto_translation_enabled'].safeCast() ?? - this.autoTranslationEnabled, - autoTranslationLanguage: - autoTranslationLanguage ?? - extraData?['auto_translation_language'].safeCast() ?? - this.autoTranslationLanguage, + truncatedBy: truncatedBy ?? this.truncatedBy, + autoTranslationEnabled: autoTranslationEnabled ?? this.autoTranslationEnabled, + autoTranslationLanguage: autoTranslationLanguage ?? this.autoTranslationLanguage, ); /// Returns a new [ChannelModel] that is a combination of this channelModel @@ -327,7 +308,7 @@ class ChannelModel { deletedAt: other.deletedAt, memberCount: other.memberCount, members: other.members, - extraData: other.extraData, + custom: other.custom, team: other.team, cooldown: other.cooldown, disabled: other.disabled, diff --git a/packages/stream_chat/lib/src/core/models/channel_model.g.dart b/packages/stream_chat/lib/src/core/models/channel_model.g.dart index d2c1ce62eb..9cc0438ddf 100644 --- a/packages/stream_chat/lib/src/core/models/channel_model.g.dart +++ b/packages/stream_chat/lib/src/core/models/channel_model.g.dart @@ -20,11 +20,19 @@ ChannelModel _$ChannelModelFromJson(Map json) => ChannelModel( deletedAt: json['deleted_at'] == null ? null : DateTime.parse(json['deleted_at'] as String), memberCount: (json['member_count'] as num?)?.toInt() ?? 0, members: (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList(), - extraData: json['extra_data'] as Map? ?? const {}, + custom: json['extra_data'] as Map? ?? const {}, team: json['team'] as String?, cooldown: (json['cooldown'] as num?)?.toInt() ?? 0, + disabled: json['disabled'] as bool?, + hidden: json['hidden'] as bool?, + muted: json['muted'] as bool?, + blocked: json['blocked'] as bool?, + truncatedAt: ChannelModel._dateFromJson(json['truncated_at']), messageCount: (json['message_count'] as num?)?.toInt(), filterTags: (json['filter_tags'] as List?)?.map((e) => e as String).toList(), + truncatedBy: ChannelModel._userFromJson(json['truncated_by']), + autoTranslationEnabled: json['auto_translation_enabled'] as bool?, + autoTranslationLanguage: json['auto_translation_language'] as String?, ); Map _$ChannelModelToJson(ChannelModel instance) => { @@ -32,5 +40,13 @@ Map _$ChannelModelToJson(ChannelModel instance) => { /// value off an instance with [value]. ChannelFilterField(super.remote, super.value); - /// Creates a field the SDK does not model, read from [ChannelModel.extraData]. + /// Creates a field the SDK does not model, read from [ChannelModel.custom]. /// /// The fields that depend on the calling user — `joined`, `has_unread`, /// `invite`, `distinct` and `app_banned` — are reached this way. They are @@ -155,7 +155,7 @@ class ChannelFilterField extends FilterField { /// **Supported operators:** `$eq`, `$in`, `$gt`, `$gte`, `$lt`, `$lte`, `$exists`, /// `$contains`, `$q`, `$autocomplete` factory ChannelFilterField.custom(String remote) { - return ChannelFilterField(remote, (it) => it.channel?.extraData[remote]); + return ChannelFilterField(remote, (it) => it.channel?.custom[remote]); } // Creates a channel filter field that declares its [collectionEquality]. @@ -166,7 +166,7 @@ class ChannelFilterField extends FilterField { // ```dart // ChannelFilterField._( // 'tags', - // (it) => it.channel?.extraData['tags'], + // (it) => it.channel?.custom['tags'], // collectionEquality: .containsExactly, // ); // ``` @@ -439,14 +439,14 @@ class ChannelSortField extends SortField { /// For a name the SDK has not modelled; prefer the fields declared here. ChannelSortField(super.remote, super.localValue); - /// Creates a field the SDK does not model, read from [ChannelModel.extraData]. + /// Creates a field the SDK does not model, read from [ChannelModel.custom]. /// /// Declared only where the API accepts a custom sort field. /// /// String values are compared as they are, without the case and diacritic /// folding a declared name field applies. factory ChannelSortField.custom(String remote) { - return ChannelSortField(remote, (it) => it.channel?.extraData[remote]); + return ChannelSortField(remote, (it) => it.channel?.custom[remote]); } /// Sorts channels by their last activity. diff --git a/packages/stream_chat/lib/src/core/models/member.dart b/packages/stream_chat/lib/src/core/models/member.dart index 94fa8cb968..6095afd3d9 100644 --- a/packages/stream_chat/lib/src/core/models/member.dart +++ b/packages/stream_chat/lib/src/core/models/member.dart @@ -14,9 +14,6 @@ part 'member.g.dart'; @JsonSerializable() class Member extends Equatable { /// Creates a member. - /// - /// The [notificationsMuted], [status], [banFromFutureChannels], [futureChannelBanExpires] and [deletedAt] arguments, - /// when given, are stored in [extraData], overriding an entry of the same name. Member({ this.user, this.inviteAcceptedAt, @@ -33,33 +30,23 @@ class Member extends Equatable { this.pinnedAt, this.archivedAt, this.deletedMessages = const [], - Map extraData = const {}, - bool? notificationsMuted, - String? status, - bool? banFromFutureChannels, - DateTime? futureChannelBanExpires, - DateTime? deletedAt, + Map custom = const {}, + this.notificationsMuted, + this.status, + this.banFromFutureChannels, + this.futureChannelBanExpires, + this.deletedAt, }) : userId = userId ?? user?.id, createdAt = createdAt ?? DateTime.now(), updatedAt = updatedAt ?? DateTime.now(), - // For backwards compatibility, set 'notifications_muted', 'status', 'ban_from_future_channels', - // 'future_channel_ban_expires' and 'deleted_at' in [extraData]. - extraData = { - ...extraData, - if (notificationsMuted != null) 'notifications_muted': notificationsMuted, - if (status != null) 'status': status, - if (banFromFutureChannels != null) 'ban_from_future_channels': banFromFutureChannels, - if (futureChannelBanExpires != null) - 'future_channel_ban_expires': futureChannelBanExpires.toUtc().toIso8601String(), - if (deletedAt != null) 'deleted_at': deletedAt.toUtc().toIso8601String(), - }; + custom = {...custom}; /// Create a new instance from a json factory Member.fromJson(Map json) => _$MemberFromJson( Serializer.moveToExtraDataFromRoot(json, topLevelFields), ); - /// The JSON keys [Member.fromJson] reads into named fields; every other key is kept in [extraData]. + /// The JSON keys [Member.fromJson] reads into named fields, or leaves out; every other key is kept in [custom]. static const topLevelFields = [ 'user', 'invite_accepted_at', @@ -76,6 +63,13 @@ class Member extends Equatable { 'pinned_at', 'archived_at', 'deleted_messages', + 'notifications_muted', + 'status', + 'ban_from_future_channels', + 'future_channel_ban_expires', + 'deleted_at', + // Listed so a `role` key never lands in [custom]; [channelRole] holds the member's role. + 'role', ]; /// The interested user @@ -126,37 +120,33 @@ class Member extends Equatable { /// visible to other channel members. final List deletedMessages; - /// Map of custom member extraData. - final Map extraData; + /// This member's custom data: every key not in [topLevelFields]. + @JsonKey(name: 'extra_data') + final Map custom; /// Whether this member muted notifications for the channel. - @JsonKey(includeToJson: false, includeFromJson: false) - bool? get notificationsMuted => extraData['notifications_muted'].safeCast(); + @JsonKey(includeIfNull: false) + final bool? notificationsMuted; /// The status of this membership, such as `member`, or `pending` while an invite is outstanding. - @JsonKey(includeToJson: false, includeFromJson: false) - String? get status => extraData['status'].safeCast(); + @JsonKey(includeIfNull: false) + final String? status; /// Whether this member is banned from future channels. - @JsonKey(includeToJson: false, includeFromJson: false) - bool? get banFromFutureChannels => extraData['ban_from_future_channels'].safeCast(); + @JsonKey(includeIfNull: false) + final bool? banFromFutureChannels; /// The date at which this member's ban from future channels expires. /// /// Null when that ban has no expiry. - @JsonKey(includeToJson: false, includeFromJson: false) - DateTime? get futureChannelBanExpires => _date('future_channel_ban_expires'); + @JsonKey(includeIfNull: false) + final DateTime? futureChannelBanExpires; /// The date at which this membership was deleted. /// /// Null while the member belongs to the channel. - @JsonKey(includeToJson: false, includeFromJson: false) - DateTime? get deletedAt => _date('deleted_at'); - - DateTime? _date(String key) => switch (extraData[key]) { - final String value => DateTime.tryParse(value), - _ => null, - }; + @JsonKey(includeIfNull: false) + final DateTime? deletedAt; /// Creates a copy of [Member] with specified attributes overridden. Member copyWith({ @@ -176,7 +166,12 @@ class Member extends Equatable { DateTime? banExpires, bool? shadowBanned, List? deletedMessages, - Map? extraData, + Map? custom, + bool? notificationsMuted, + String? status, + bool? banFromFutureChannels, + DateTime? futureChannelBanExpires, + DateTime? deletedAt, }) => Member( user: user ?? this.user, inviteAcceptedAt: inviteAcceptedAt ?? this.inviteAcceptedAt, @@ -193,12 +188,20 @@ class Member extends Equatable { createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, deletedMessages: deletedMessages ?? this.deletedMessages, - extraData: extraData ?? this.extraData, + custom: custom ?? this.custom, + notificationsMuted: notificationsMuted ?? this.notificationsMuted, + status: status ?? this.status, + banFromFutureChannels: banFromFutureChannels ?? this.banFromFutureChannels, + futureChannelBanExpires: futureChannelBanExpires ?? this.futureChannelBanExpires, + deletedAt: deletedAt ?? this.deletedAt, ); /// Serialize to json + /// + /// Writes the entries of [custom] at the root, leaving out any named like one of [topLevelFields]. Map toJson() => Serializer.moveFromExtraDataToRoot( _$MemberToJson(this), + topLevelFields: topLevelFields, ); @override @@ -218,7 +221,12 @@ class Member extends Equatable { createdAt, updatedAt, deletedMessages, - extraData, + custom, + notificationsMuted, + status, + banFromFutureChannels, + futureChannelBanExpires, + deletedAt, ]; } @@ -237,12 +245,12 @@ class MemberFilterField extends FilterField { /// value off an instance with [value]. MemberFilterField(super.remote, super.value); - /// Creates a field the SDK does not model, read from [Member.extraData]. + /// Creates a field the SDK does not model, read from [Member.custom]. /// /// **Supported operators:** `$eq`, `$in`, `$gt`, `$gte`, `$lt`, `$lte`, /// `$exists`, `$contains`, `$q`, `$autocomplete` factory MemberFilterField.custom(String remote) { - return MemberFilterField(remote, (it) => it.extraData[remote]); + return MemberFilterField(remote, (it) => it.custom[remote]); } /// Filters members by their user id. @@ -404,11 +412,11 @@ class MemberSortField extends SortField { /// For a name the SDK has not modelled; prefer the fields declared here. MemberSortField(super.remote, super.localValue); - /// Creates a field the SDK does not model, read from [Member.extraData]. + /// Creates a field the SDK does not model, read from [Member.custom]. /// /// Declared only where the API accepts a custom sort field. factory MemberSortField.custom(String remote) { - return MemberSortField(remote, (it) => it.extraData[remote]); + return MemberSortField(remote, (it) => it.custom[remote]); } /// Sorts members by their creation date in the channel. diff --git a/packages/stream_chat/lib/src/core/models/member.g.dart b/packages/stream_chat/lib/src/core/models/member.g.dart index 730051a2db..93e7ea5aeb 100644 --- a/packages/stream_chat/lib/src/core/models/member.g.dart +++ b/packages/stream_chat/lib/src/core/models/member.g.dart @@ -22,7 +22,14 @@ Member _$MemberFromJson(Map json) => Member( pinnedAt: json['pinned_at'] == null ? null : DateTime.parse(json['pinned_at'] as String), archivedAt: json['archived_at'] == null ? null : DateTime.parse(json['archived_at'] as String), deletedMessages: (json['deleted_messages'] as List?)?.map((e) => e as String).toList() ?? const [], - extraData: json['extra_data'] as Map? ?? const {}, + custom: json['extra_data'] as Map? ?? const {}, + notificationsMuted: json['notifications_muted'] as bool?, + status: json['status'] as String?, + banFromFutureChannels: json['ban_from_future_channels'] as bool?, + futureChannelBanExpires: json['future_channel_ban_expires'] == null + ? null + : DateTime.parse(json['future_channel_ban_expires'] as String), + deletedAt: json['deleted_at'] == null ? null : DateTime.parse(json['deleted_at'] as String), ); Map _$MemberToJson(Member instance) => { @@ -41,5 +48,10 @@ Map _$MemberToJson(Member instance) => { 'created_at': instance.createdAt.toIso8601String(), 'updated_at': instance.updatedAt.toIso8601String(), 'deleted_messages': instance.deletedMessages, - 'extra_data': instance.extraData, + 'extra_data': instance.custom, + 'notifications_muted': ?instance.notificationsMuted, + 'status': ?instance.status, + 'ban_from_future_channels': ?instance.banFromFutureChannels, + 'future_channel_ban_expires': ?instance.futureChannelBanExpires?.toIso8601String(), + 'deleted_at': ?instance.deletedAt?.toIso8601String(), }; diff --git a/packages/stream_chat/lib/src/core/util/serializer.dart b/packages/stream_chat/lib/src/core/util/serializer.dart index d1db1dedd3..eb4d817e8c 100644 --- a/packages/stream_chat/lib/src/core/util/serializer.dart +++ b/packages/stream_chat/lib/src/core/util/serializer.dart @@ -42,7 +42,8 @@ class Serializer { /// A copy of [json] with the entries of `extra_data` moved to the root. /// - /// An entry in `extra_data` replaces a root entry with the same key. + /// An entry in `extra_data` replaces a root entry with the same key, unless the key is one of [topLevelFields]: + /// such an entry is left out, so custom data cannot overwrite a field. /// /// ```dart /// Serializer.moveFromExtraDataToRoot({ @@ -58,12 +59,16 @@ class Serializer { /// // } /// ``` static Map moveFromExtraDataToRoot( - Map json, - ) { - final extraData = json['extra_data']; + Map json, { + List topLevelFields = const [], + }) { + final knownFields = _knownFieldsCache[topLevelFields] ??= topLevelFields.toSet(); + final extraData = json['extra_data'] as Map?; return { ...json, - if (extraData != null) ...extraData, + if (extraData != null) + for (final MapEntry(:key, :value) in extraData.entries) + if (!knownFields.contains(key)) key: value, }..remove('extra_data'); } diff --git a/packages/stream_chat/lib/src/db/chat_persistence_client.dart b/packages/stream_chat/lib/src/db/chat_persistence_client.dart index 084666cbfb..01d45da43c 100644 --- a/packages/stream_chat/lib/src/db/chat_persistence_client.dart +++ b/packages/stream_chat/lib/src/db/chat_persistence_client.dart @@ -491,6 +491,7 @@ abstract class ChatPersistenceClient { users.addAll( [ channel.createdBy, + channel.truncatedBy, ...?messages?.map((it) => it.user), ...?pinnedMessages?.map((it) => it.user), ...?reads?.map((it) => it.user), 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 0010f37353..305c247633 100644 --- a/packages/stream_chat/lib/src/repository/mapper/channels_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/channels_mapper.dart @@ -15,24 +15,7 @@ import 'user_mapper.dart'; /// Maps a generated [api.ChannelResponse] to a [ChannelModel]. extension ChannelResponseMapper on api.ChannelResponse { - // Custom keys named like one of the channel's own fields, including the ones it keeps in its extra data. - static const _shadowedCustomKeys = { - ...ChannelModel.topLevelFields, - 'disabled', - 'hidden', - 'muted', - 'blocked', - 'truncated_at', - 'truncated_by', - 'mute_expires_at', - 'auto_translation_enabled', - 'auto_translation_language', - 'hide_messages_before', - }; - /// Converts this response into a [ChannelModel]. - /// - /// Custom data named like one of the channel's own fields is left out of [ChannelModel.extraData]. ChannelModel toModel() => ChannelModel( id: id, type: type, @@ -47,7 +30,7 @@ extension ChannelResponseMapper on api.ChannelResponse { deletedAt: deletedAt, memberCount: memberCount ?? 0, members: members?.map((member) => member.toModel()).toList(), - extraData: {...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), + custom: custom, team: team, cooldown: cooldown ?? 0, disabled: disabled, @@ -125,20 +108,7 @@ extension ChatPreferencesMapper on api.ChatPreferences { /// Maps a generated [api.ChannelMemberResponse] to a [Member]. extension ChannelMemberResponseMapper on api.ChannelMemberResponse { - // Custom keys named like one of the member's own fields, including the ones it keeps in its extra data. - static const _shadowedCustomKeys = { - ...Member.topLevelFields, - 'notifications_muted', - 'status', - 'role', - 'ban_from_future_channels', - 'future_channel_ban_expires', - 'deleted_at', - }; - /// Converts this response into a [Member]. - /// - /// Custom data named like one of the member's own fields is left out of [Member.extraData]. Member toModel() => Member( user: user?.toModel(), inviteAcceptedAt: inviteAcceptedAt, @@ -155,10 +125,7 @@ extension ChannelMemberResponseMapper on api.ChannelMemberResponse { pinnedAt: pinnedAt, archivedAt: archivedAt, deletedMessages: deletedMessages ?? const [], - extraData: { - ...{...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), - if (role case final role?) 'role': role, - }, + custom: custom, notificationsMuted: notificationsMuted, status: status, banFromFutureChannels: banFromFutureChannels, diff --git a/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart b/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart index 08f4852be8..767f854d88 100644 --- a/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart @@ -1918,14 +1918,14 @@ void main() { id: channelId, type: channelType, memberCount: 42, - extraData: const {'name': 'updated-name'}, + custom: const {'name': 'updated-name'}, ), ), ); await Future.delayed(Duration.zero); expect(channel.memberCount, 42); - expect(channel.extraData['name'], 'updated-name'); + expect(channel.custom['name'], 'updated-name'); }); test('replaces the member list with the event members', () async { diff --git a/packages/stream_chat/test/src/client/channel/channel_test.dart b/packages/stream_chat/test/src/client/channel/channel_test.dart index da4683aab3..6ad86432f6 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -58,49 +58,49 @@ void main() { channel.dispose(); }); - test('should be able to set `extraData`', () { - expect(channel.extraData.isEmpty, isTrue); + test('should be able to set `custom`', () { + expect(channel.custom.isEmpty, isTrue); expect( - () => channel.extraData = {'name': 'test-channel-name'}, + () => channel.custom = {'name': 'test-channel-name'}, returnsNormally, ); - expect(channel.extraData.isEmpty, isFalse); - expect(channel.extraData.containsKey('name'), isTrue); - expect(channel.extraData['name'], 'test-channel-name'); + expect(channel.custom.isEmpty, isFalse); + expect(channel.custom.containsKey('name'), isTrue); + expect(channel.custom['name'], 'test-channel-name'); }); test('should be able to get and set `image`', () { - expect(channel.extraData.isEmpty, isTrue); + expect(channel.custom.isEmpty, isTrue); const imageUrl = 'https://getstream.io/some-image'; channel.image = imageUrl; expect(channel.image, imageUrl); - expect(channel.extraData['image'], imageUrl); + expect(channel.custom['image'], imageUrl); const newImage = 'https://getstream.io/new-image'; final newChannelInstance = Channel(client, channelType, channelId, image: newImage); expect(newChannelInstance.image, newImage); - expect(newChannelInstance.extraData['image'], newImage); + expect(newChannelInstance.custom['image'], newImage); }); test('should be able to get and set `name`', () { - expect(channel.extraData.isEmpty, isTrue); + expect(channel.custom.isEmpty, isTrue); const name = 'Channel name'; channel.name = name; expect(channel.name, name); - expect(channel.extraData['name'], name); + expect(channel.custom['name'], name); const newName = 'New channel name'; final newChannelInstance = Channel(client, channelType, channelId, name: newName); expect(newChannelInstance.name, newName); - expect(newChannelInstance.extraData['name'], newName); + expect(newChannelInstance.custom['name'], newName); }); test('setters remain usable after a failed watch()', () async { @@ -232,8 +232,8 @@ void main() { clearInteractions(client); }); - test('should throw if trying to set `extraData`', () { - expect(() => channel.extraData = {'name': 'test-channel-name'}, throwsA(isA())); + test('should throw if trying to set `custom`', () { + expect(() => channel.custom = {'name': 'test-channel-name'}, throwsA(isA())); }); test('should throw if trying to set `image`', () { @@ -3701,7 +3701,7 @@ void main() { final channelModel = ChannelModel( cid: channelCid, - extraData: channelData, + custom: channelData, ); when(() => client.updateChannel(channelId, channelType, channelData, message: any(named: 'message'))).thenAnswer( @@ -3717,7 +3717,7 @@ void main() { expect(res, isNotNull); expect(res.channel.cid, channelModel.cid); - expect(res.channel.extraData, channelData); + expect(res.channel.custom, channelData); expect(res.message?.id, updateMessage.id); verify(() => client.updateChannel(channelId, channelType, channelData, message: any(named: 'message'))).called(1); @@ -5530,7 +5530,7 @@ void main() { () async { // Set initial channel state with some properties final initialChannel = channel.state?.channelState.channel?.copyWith( - extraData: {'name': 'Test Channel'}, + custom: {'name': 'Test Channel'}, memberCount: 5, frozen: true, ); @@ -5697,7 +5697,7 @@ void main() { () async { // Set initial channel state with some properties final initialChannel = channel.state?.channelState.channel?.copyWith( - extraData: {'name': 'Test Channel'}, + custom: {'name': 'Test Channel'}, messageCount: 7, frozen: true, ); diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index bfb1d3fdb3..9cbc7bca99 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -1630,14 +1630,14 @@ void main() { ..channel = ChannelModel( id: channelId, type: channelType, - extraData: {...data}, + custom: {...data}, ), ); final res = await client.updateChannel(channelId, channelType, data); expect(res, isNotNull); expect(res.channel.cid, '$channelType:$channelId'); - expect(res.channel.extraData['name'], 'test-channel'); + expect(res.channel.custom['name'], 'test-channel'); verify(() => fakeChatApi.channel.updateChannel(channelId, channelType, data)).called(1); verifyNoMoreInteractions(fakeChatApi.channel); @@ -3099,14 +3099,14 @@ void main() { final channel = client.channel( channelType, id: channelId, - extraData: channelData, + custom: channelData, ); expect(channel, isNotNull); expect(channel.type, channelType); expect(channel.id, channelId); expect(channel.cid, '$channelType:$channelId'); - expect(channel.extraData, channelData); + expect(channel.custom, channelData); }); test('should return back in memory channel instance if available', () async { @@ -3118,7 +3118,7 @@ void main() { final channel = client.channel( channelType, id: channelId, - extraData: channelData, + custom: channelData, ); final channelState = ChannelState( @@ -3174,7 +3174,7 @@ void main() { const channelCid = '$channelType:$channelId'; final channelState = ChannelState( - channel: ChannelModel(cid: channelCid, extraData: channelData), + channel: ChannelModel(cid: channelCid, custom: channelData), ); when( @@ -3203,7 +3203,7 @@ void main() { expect(channel.type, channelType); expect(channel.id, channelId); expect(channel.cid, '$channelType:$channelId'); - expect(channel.extraData, channelData); + expect(channel.custom, channelData); verify( () => fakeChatApi.channel.queryChannel( @@ -3228,7 +3228,7 @@ void main() { const channelCid = '$channelType:$channelId'; final channelState = ChannelState( - channel: ChannelModel(cid: channelCid, extraData: channelData), + channel: ChannelModel(cid: channelCid, custom: channelData), ); when( @@ -3257,7 +3257,7 @@ void main() { expect(channel.type, channelType); expect(channel.id, channelId); expect(channel.cid, '$channelType:$channelId'); - expect(channel.extraData, channelData); + expect(channel.custom, channelData); verify( () => fakeChatApi.channel.queryChannel( @@ -3282,7 +3282,7 @@ void main() { const channelCid = '$channelType:$channelId'; final channelState = ChannelState( - channel: ChannelModel(cid: channelCid, extraData: channelData), + channel: ChannelModel(cid: channelCid, custom: channelData), ); when( @@ -3311,7 +3311,7 @@ void main() { expect(channel.type, channelType); expect(channel.id, channelId); expect(channel.cid, '$channelType:$channelId'); - expect(channel.extraData, channelData); + expect(channel.custom, channelData); verify( () => fakeChatApi.channel.queryChannel( diff --git a/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart b/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart index 763af069be..7b3ca1591e 100644 --- a/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart +++ b/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart @@ -74,7 +74,7 @@ void main() { expect(channel.filterTags, ['support']); }); - test('StreamChatClient.updateChannelPartial keeps the custom data and the server fields in extraData', () async { + test('StreamChatClient.updateChannelPartial returns the channel custom data as the response sends it', () async { const request = api.UpdateChannelPartialRequest(set: {'name': 'General'}); final defaultApi = MockDefaultApi(); @@ -85,59 +85,71 @@ void main() { final res = await client.updateChannelPartial('general', 'messaging', set: const {'name': 'General'}); - expect(res.getOrNull()!.channel!.extraData, { + expect(res.getOrNull()!.channel!.custom, { 'name': 'General', 'image': 'https://example.com/general.png', 'topic': 'anything', - 'disabled': false, - 'hidden': true, - 'muted': true, - 'blocked': false, - 'truncated_at': DateTime.utc(2026, 3, 2).toIso8601String(), - 'truncated_by': fakeUser('truncator').toJson(), - 'auto_translation_enabled': true, - 'auto_translation_language': 'fr', }); }); - test('StreamChatClient.updateChannelPartial drops channel custom data named like a channel field', () async { - final response = _updateChannelPartialResponse( - channel: api.ChannelResponse( - cid: 'messaging:general', - id: 'general', - type: 'messaging', - createdAt: DateTime.utc(2026), - updatedAt: DateTime.utc(2026, 2), - custom: const { - 'topic': 'anything', - 'cid': 'shadowed', - 'disabled': 'shadowed', - 'hidden': 'shadowed', - 'muted': 'shadowed', - 'blocked': 'shadowed', - 'truncated_at': 'shadowed', - 'truncated_by': 'shadowed', - 'mute_expires_at': 'shadowed', - 'auto_translation_enabled': 'shadowed', - 'auto_translation_language': 'shadowed', - 'hide_messages_before': 'shadowed', - }, - disabled: false, - frozen: false, - ), - ); - const request = api.UpdateChannelPartialRequest(set: {'name': 'General'}); - - final defaultApi = MockDefaultApi(); - when( - () => defaultApi.updateChannelPartial(type: 'messaging', id: 'general', updateChannelPartialRequest: request), - ).thenAnswer((_) async => Result.success(response)); - final client = _client(defaultApi); + test( + 'StreamChatClient.updateChannelPartial keeps channel custom data named like a channel field apart from the field', + () async { + final response = _updateChannelPartialResponse( + channel: api.ChannelResponse( + cid: 'messaging:general', + id: 'general', + type: 'messaging', + createdAt: DateTime.utc(2026), + updatedAt: DateTime.utc(2026, 2), + custom: const { + 'topic': 'anything', + 'cid': 'shadowed', + 'disabled': 'shadowed', + 'hidden': 'shadowed', + 'muted': 'shadowed', + 'blocked': 'shadowed', + 'truncated_at': 'shadowed', + 'truncated_by': 'shadowed', + 'mute_expires_at': 'shadowed', + 'auto_translation_enabled': 'shadowed', + 'auto_translation_language': 'shadowed', + 'hide_messages_before': 'shadowed', + }, + disabled: false, + frozen: false, + ), + ); + const request = api.UpdateChannelPartialRequest(set: {'name': 'General'}); - final res = await client.updateChannelPartial('general', 'messaging', set: const {'name': 'General'}); + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateChannelPartial(type: 'messaging', id: 'general', updateChannelPartialRequest: request), + ).thenAnswer((_) async => Result.success(response)); + final client = _client(defaultApi); - expect(res.getOrNull()!.channel!.extraData, {'topic': 'anything', 'disabled': false}); - }); + final res = await client.updateChannelPartial('general', 'messaging', set: const {'name': 'General'}); + + final channel = res.getOrNull()!.channel!; + expect(channel.custom, { + 'topic': 'anything', + 'cid': 'shadowed', + 'disabled': 'shadowed', + 'hidden': 'shadowed', + 'muted': 'shadowed', + 'blocked': 'shadowed', + 'truncated_at': 'shadowed', + 'truncated_by': 'shadowed', + 'mute_expires_at': 'shadowed', + 'auto_translation_enabled': 'shadowed', + 'auto_translation_language': 'shadowed', + 'hide_messages_before': 'shadowed', + }); + expect(channel.cid, 'messaging:general'); + expect(channel.disabled, isFalse); + expect(channel.hidden, isNull); + }, + ); test('StreamChatClient.updateChannelPartial falls back to the defaults for channel fields left out', () async { final response = _updateChannelPartialResponse( diff --git a/packages/stream_chat/test/src/client/client_update_member_partial_test.dart b/packages/stream_chat/test/src/client/client_update_member_partial_test.dart index f8dc631e2e..ee2f82a1fc 100644 --- a/packages/stream_chat/test/src/client/client_update_member_partial_test.dart +++ b/packages/stream_chat/test/src/client/client_update_member_partial_test.dart @@ -64,58 +64,55 @@ void main() { expect(res.exceptionOrNull(), error); }); - test('StreamChatClient.updateMemberPartial drops custom data named like a member field', () async { - final response = api.UpdateMemberPartialResponse( - duration: '0.01ms', - channelMember: api.ChannelMemberResponse( - banned: false, - channelRole: 'channel_member', - createdAt: DateTime.utc(2026), - updatedAt: DateTime.utc(2026, 2), - custom: const { - 'nickname': 'Mo', - 'user_id': 'shadowed', - 'notifications_muted': 'shadowed', - 'status': 'shadowed', - 'role': 'shadowed', - 'ban_from_future_channels': 'shadowed', - 'future_channel_ban_expires': 'shadowed', - 'deleted_at': 'shadowed', - }, - notificationsMuted: false, - shadowBanned: false, - ), - ); - const request = api.UpdateMemberPartialRequest(set: {'a': 1}); - - final defaultApi = MockDefaultApi(); - when( - () => defaultApi.updateMemberPartial(type: 'messaging', id: 'general', updateMemberPartialRequest: request), - ).thenAnswer((_) async => Result.success(response)); - final client = _client(defaultApi); - - final res = await client.updateMemberPartial(channelId: 'general', channelType: 'messaging', set: const {'a': 1}); - - expect(res.getOrNull()!.channelMember!.extraData, {'nickname': 'Mo', 'notifications_muted': false}); - }); - - test("StreamChatClient.updateMemberPartial keeps the member's role over a custom role", () async { - final response = api.UpdateMemberPartialResponse( - duration: '0.01ms', - channelMember: fakeChannelMemberResponse(custom: const {'role': 'shadowed'}), - ); - const request = api.UpdateMemberPartialRequest(set: {'a': 1}); - - final defaultApi = MockDefaultApi(); - when( - () => defaultApi.updateMemberPartial(type: 'messaging', id: 'general', updateMemberPartialRequest: request), - ).thenAnswer((_) async => Result.success(response)); - final client = _client(defaultApi); - - final res = await client.updateMemberPartial(channelId: 'general', channelType: 'messaging', set: const {'a': 1}); - - expect(res.getOrNull()!.channelMember!.extraData['role'], 'user'); - }); + test( + 'StreamChatClient.updateMemberPartial keeps custom data named like a member field apart from the field', + () async { + final response = api.UpdateMemberPartialResponse( + duration: '0.01ms', + channelMember: api.ChannelMemberResponse( + banned: false, + channelRole: 'channel_member', + createdAt: DateTime.utc(2026), + updatedAt: DateTime.utc(2026, 2), + custom: const { + 'nickname': 'Mo', + 'user_id': 'shadowed', + 'notifications_muted': 'shadowed', + 'status': 'shadowed', + 'role': 'shadowed', + 'ban_from_future_channels': 'shadowed', + 'future_channel_ban_expires': 'shadowed', + 'deleted_at': 'shadowed', + }, + notificationsMuted: false, + shadowBanned: false, + ), + ); + const request = api.UpdateMemberPartialRequest(set: {'a': 1}); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateMemberPartial(type: 'messaging', id: 'general', updateMemberPartialRequest: request), + ).thenAnswer((_) async => Result.success(response)); + final client = _client(defaultApi); + + final res = await client.updateMemberPartial(channelId: 'general', channelType: 'messaging', set: const {'a': 1}); + + final member = res.getOrNull()!.channelMember!; + expect(member.custom, { + 'nickname': 'Mo', + 'user_id': 'shadowed', + 'notifications_muted': 'shadowed', + 'status': 'shadowed', + 'role': 'shadowed', + 'ban_from_future_channels': 'shadowed', + 'future_channel_ban_expires': 'shadowed', + 'deleted_at': 'shadowed', + }); + expect(member.notificationsMuted, isFalse); + expect(member.status, isNull); + }, + ); test('StreamChatClient.updateMemberPartial falls back to the defaults for member fields left out', () async { final response = api.UpdateMemberPartialResponse( diff --git a/packages/stream_chat/test/src/core/api/channel_api_test.dart b/packages/stream_chat/test/src/core/api/channel_api_test.dart index a3edda1866..7815a67a5b 100644 --- a/packages/stream_chat/test/src/core/api/channel_api_test.dart +++ b/packages/stream_chat/test/src/core/api/channel_api_test.dart @@ -288,7 +288,7 @@ void main() { final channelModel = ChannelModel( id: channelId, type: channelType, - extraData: data, + custom: data, ); when( diff --git a/packages/stream_chat/test/src/core/models/channel_state_test.dart b/packages/stream_chat/test/src/core/models/channel_state_test.dart index 2738cfb023..d64ccc48f3 100644 --- a/packages/stream_chat/test/src/core/models/channel_state_test.dart +++ b/packages/stream_chat/test/src/core/models/channel_state_test.dart @@ -20,10 +20,10 @@ void main() { expect(channelState.channel?.updatedAt, DateTime.parse('2019-04-03T18:43:33.213374Z')); expect(channelState.channel?.createdBy, isA()); expect(channelState.channel?.frozen, true); - expect(channelState.channel?.extraData['example'], 1); - expect(channelState.channel?.extraData['name'], '#dev'); + expect(channelState.channel?.custom['example'], 1); + expect(channelState.channel?.custom['name'], '#dev'); expect( - channelState.channel?.extraData['image'], + channelState.channel?.custom['image'], 'https://cdn.chrisshort.net/testing-certificate-chains-in-go/GOPHER_MIC_DROP.png', ); expect(channelState.messages, isNotNull); @@ -151,11 +151,11 @@ void main() { ); }); - test('a custom field orders by the channel extra data', () { + test('a custom field orders by the channel custom data', () { expectOrders( ChannelSortField.custom('priority'), - createChannelState(id: 'low', extraData: const {'priority': 1}), - createChannelState(id: 'high', extraData: const {'priority': 10}), + createChannelState(id: 'low', custom: const {'priority': 1}), + createChannelState(id: 'high', custom: const {'priority': 10}), ); }); @@ -182,7 +182,7 @@ ChannelState createChannelState({ DateTime? updatedAt, DateTime? lastMessageAt, int? memberCount, - Map? extraData, + Map? custom, Draft? draft, }) { return ChannelState( @@ -194,7 +194,7 @@ ChannelState createChannelState({ createdAt: createdAt ?? DateTime(2023), updatedAt: updatedAt ?? DateTime(2023), memberCount: memberCount ?? 0, - extraData: extraData ?? {}, + custom: custom ?? {}, ), membership: Member(userId: 'user1'), draft: draft, diff --git a/packages/stream_chat/test/src/core/models/channel_test.dart b/packages/stream_chat/test/src/core/models/channel_test.dart index 38245b7b92..d494bf8845 100644 --- a/packages/stream_chat/test/src/core/models/channel_test.dart +++ b/packages/stream_chat/test/src/core/models/channel_test.dart @@ -13,8 +13,8 @@ void main() { expect(channel.id, equals('test')); expect(channel.type, equals('livestream')); expect(channel.cid, equals('livestream:test')); - expect(channel.extraData['cats'], equals(true)); - expect(channel.extraData['fruit'], equals(['bananas', 'apples'])); + expect(channel.custom['cats'], equals(true)); + expect(channel.custom['fruit'], equals(['bananas', 'apples'])); expect(channel.cooldown, equals(0)); expect(channel.truncatedBy?.id, 'truncator'); expect(channel.truncatedBy?.name, 'Truncator'); @@ -27,7 +27,7 @@ void main() { type: 'type', id: 'id', cid: 'a:a', - extraData: {'name': 'cool'}, + custom: {'name': 'cool'}, ); expect( @@ -47,7 +47,7 @@ void main() { type: 'type', id: 'id', cid: 'a:a', - extraData: {'name': 'cool'}, + custom: {'name': 'cool'}, ); expect( @@ -63,210 +63,137 @@ void main() { }); }); - test('hidden property and extraData manipulation', () { - final channel = ChannelModel(cid: 'test:cid', hidden: false); - - expect(channel.hidden, false); - expect(channel.extraData['hidden'], false); - expect(channel.toJson(), { - 'id': 'cid', - 'type': 'test', - 'frozen': false, - 'cooldown': 0, - 'hidden': false, - }); - expect(ChannelModel.fromJson(channel.toJson()).toJson(), { - 'id': 'cid', - 'type': 'test', - 'frozen': false, - 'cooldown': 0, - 'hidden': false, - }); - - var newChannel = channel.copyWith( - extraData: {'hidden': true}, - ); - - expect(newChannel.extraData['hidden'], true); - expect(newChannel.hidden, true); - - newChannel = channel.copyWith( - hidden: false, - ); - - expect(newChannel.extraData['hidden'], false); - expect(newChannel.hidden, false); - - newChannel = channel.copyWith( + test('ChannelModel writes its flags to JSON and reads them back', () { + final truncatedAt = DateTime.utc(2026, 3, 2); + final channel = ChannelModel( + cid: 'test:cid', + disabled: false, hidden: true, - extraData: {'hidden': true}, - ); - - expect(newChannel.extraData['hidden'], true); - expect(newChannel.hidden, true); - }); - - test('a non-boolean flag in replacement extraData falls back to the current value', () { - // The getters read these with `safeCast`, so `copyWith` has to as well — - // extra data is consumer-authored and can hold anything. - final channel = ChannelModel(cid: 'test:cid', muted: true, blocked: true); - - final newChannel = channel.copyWith( - extraData: {'muted': 'yes', 'blocked': 1}, + muted: true, + blocked: false, + truncatedAt: truncatedAt, + truncatedBy: User(id: 'truncator'), + autoTranslationEnabled: true, + autoTranslationLanguage: 'fr', + custom: const {'color': 'red'}, ); - - expect(newChannel.muted, true); - expect(newChannel.blocked, true); - }); - - test('disabled property and extraData manipulation', () { - final channel = ChannelModel(cid: 'test:cid', disabled: false); - - expect(channel.disabled, false); - expect(channel.extraData['disabled'], false); - expect(channel.toJson(), { - 'id': 'cid', - 'type': 'test', - 'frozen': false, - 'cooldown': 0, - 'disabled': false, - }); - expect(ChannelModel.fromJson(channel.toJson()).toJson(), { + final json = { 'id': 'cid', 'type': 'test', 'frozen': false, 'cooldown': 0, 'disabled': false, - }); - - var newChannel = channel.copyWith( - extraData: {'disabled': true}, - ); - - expect(newChannel.extraData['disabled'], true); - expect(newChannel.disabled, true); - - newChannel = channel.copyWith( - hidden: false, - ); + 'hidden': true, + 'muted': true, + 'blocked': false, + 'truncated_at': truncatedAt.toIso8601String(), + 'truncated_by': User(id: 'truncator').toJson(), + 'auto_translation_enabled': true, + 'auto_translation_language': 'fr', + 'color': 'red', + }; - expect(newChannel.extraData['disabled'], false); - expect(newChannel.disabled, false); + expect(channel.toJson(), json); + expect(ChannelModel.fromJson(channel.toJson()).toJson(), json); + }); - newChannel = channel.copyWith( + test('ChannelModel keeps custom data named like a field apart from the field', () { + final channel = ChannelModel( + cid: 'test:cid', hidden: true, - extraData: {'disabled': true}, + custom: const {'hidden': 'custom', 'id': 'custom', 'color': 'red'}, ); - expect(newChannel.extraData['disabled'], true); - expect(newChannel.disabled, true); + expect(channel.hidden, isTrue); + expect(channel.custom, {'hidden': 'custom', 'id': 'custom', 'color': 'red'}); }); - test('truncatedAt property and extraData manipulation', () { - final currentDate = DateTime.now(); - final channel = ChannelModel(cid: 'test:cid', truncatedAt: currentDate); + test('ChannelModel.toJson leaves out custom data named like a field', () { + final channel = ChannelModel( + cid: 'test:cid', + hidden: true, + custom: const {'hidden': 'custom', 'id': 'custom', 'color': 'red'}, + ); - expect(channel.truncatedAt, currentDate); - expect(channel.extraData['truncated_at'], currentDate.toIso8601String()); expect(channel.toJson(), { 'id': 'cid', 'type': 'test', 'frozen': false, 'cooldown': 0, - 'truncated_at': currentDate.toIso8601String(), - }); - expect(ChannelModel.fromJson(channel.toJson()).toJson(), { - 'id': 'cid', - 'type': 'test', - 'frozen': false, - 'cooldown': 0, - 'truncated_at': currentDate.toIso8601String(), - }); - - final dateOne = DateTime.now(); - var newChannel = channel.copyWith( - extraData: {'truncated_at': dateOne.toIso8601String()}, - ); - - expect(newChannel.extraData['truncated_at'], dateOne.toIso8601String()); - expect(newChannel.truncatedAt, dateOne); - - final dateTwo = DateTime.now(); - newChannel = channel.copyWith( - truncatedAt: dateTwo, - ); - - expect(newChannel.extraData['truncated_at'], dateTwo.toIso8601String()); - expect(newChannel.truncatedAt, dateTwo); - - final dateThree = DateTime.now(); - newChannel = channel.copyWith( - truncatedAt: dateThree, - extraData: {'truncated_at': dateThree.toIso8601String()}, - ); - - expect(newChannel.extraData['truncated_at'], dateThree.toIso8601String()); - expect(newChannel.truncatedAt, dateThree); - }); - - test('ChannelModel keeps truncatedBy and the auto-translation settings in extraData', () { - final channel = ChannelModel( - cid: 'test:cid', - truncatedBy: User(id: 'truncator'), - autoTranslationEnabled: true, - autoTranslationLanguage: 'fr', - ); - - expect(channel.extraData, { - 'truncated_by': User(id: 'truncator').toJson(), - 'auto_translation_enabled': true, - 'auto_translation_language': 'fr', + 'hidden': true, + 'color': 'red', }); }); - test('ChannelModel.copyWith keeps truncatedBy and the auto-translation settings the new extraData leaves out', () { + test('ChannelModel.copyWith keeps the flags it is not given', () { final channel = ChannelModel( cid: 'test:cid', + hidden: true, truncatedBy: User(id: 'truncator'), autoTranslationEnabled: true, autoTranslationLanguage: 'fr', ); - final newChannel = channel.copyWith(extraData: const {'color': 'red'}); + final newChannel = channel.copyWith(custom: const {'hidden': false}); + expect(newChannel.hidden, isTrue); expect(newChannel.truncatedBy?.id, 'truncator'); expect(newChannel.autoTranslationEnabled, isTrue); expect(newChannel.autoTranslationLanguage, 'fr'); }); - test('ChannelModel.copyWith replaces truncatedBy and the auto-translation settings', () { + test('ChannelModel.copyWith replaces the flags it is given', () { final channel = ChannelModel( cid: 'test:cid', + disabled: false, + hidden: false, + muted: false, + blocked: false, + truncatedAt: DateTime.utc(2026), truncatedBy: User(id: 'truncator'), autoTranslationEnabled: true, autoTranslationLanguage: 'fr', ); final newChannel = channel.copyWith( + disabled: true, + hidden: true, + muted: true, + blocked: true, + truncatedAt: DateTime.utc(2026, 2), truncatedBy: User(id: 'other'), autoTranslationEnabled: false, autoTranslationLanguage: 'de', ); + expect(newChannel.disabled, isTrue); + expect(newChannel.hidden, isTrue); + expect(newChannel.muted, isTrue); + expect(newChannel.blocked, isTrue); + expect(newChannel.truncatedAt, DateTime.utc(2026, 2)); expect(newChannel.truncatedBy?.id, 'other'); expect(newChannel.autoTranslationEnabled, isFalse); expect(newChannel.autoTranslationLanguage, 'de'); }); - test('ChannelModel.merge keeps truncatedBy when the other channel leaves it out', () { + test('ChannelModel.merge keeps the flags the other channel leaves out', () { final channel = ChannelModel( cid: 'test:cid', + hidden: true, + muted: true, + blocked: true, + truncatedAt: DateTime.utc(2026), truncatedBy: User(id: 'truncator'), ); - final merged = channel.merge(ChannelModel(cid: 'test:cid')); + final merged = channel.merge(ChannelModel(cid: 'test:cid', custom: const {'color': 'red'})); + expect(merged.hidden, isTrue); + expect(merged.muted, isTrue); + expect(merged.blocked, isTrue); + expect(merged.truncatedAt, DateTime.utc(2026)); expect(merged.truncatedBy?.id, 'truncator'); + expect(merged.custom, {'color': 'red'}); }); test('ChannelModel.merge turns auto translation off when the other channel leaves it out', () { @@ -277,156 +204,58 @@ void main() { expect(merged.autoTranslationEnabled, isFalse); }); - test('ChannelModel arguments override the same keys in extraData', () { - final channel = ChannelModel( - cid: 'test:cid', - autoTranslationEnabled: true, - autoTranslationLanguage: 'fr', - extraData: const {'auto_translation_enabled': false, 'auto_translation_language': 'de'}, - ); - - expect(channel.autoTranslationEnabled, isTrue); - expect(channel.autoTranslationLanguage, 'fr'); - }); - - test('ChannelModel.truncatedBy returns null when the extraData value is not a user with an id', () { - final noIdChannel = ChannelModel( - cid: 'test:channel', - extraData: const { - 'truncated_by': {'name': 'No id'}, - }, - ); - final textChannel = ChannelModel(cid: 'test:channel', extraData: const {'truncated_by': 'someone'}); + test('ChannelModel.fromJson reads no truncatedBy from a value that is not a user with an id', () { + final noIdChannel = ChannelModel.fromJson(const { + 'id': 'channel', + 'type': 'test', + 'truncated_by': {'name': 'No id'}, + }); + final textChannel = ChannelModel.fromJson(const {'id': 'channel', 'type': 'test', 'truncated_by': 'someone'}); expect(noIdChannel.truncatedBy, isNull); expect(textChannel.truncatedBy, isNull); }); - test('.name should fetch from extraData if available', () { - final channel = ChannelModel( - cid: 'test:channel', - extraData: const {'name': 'Test Channel'}, - ); + test('ChannelModel.fromJson reads no truncatedAt from an empty string', () { + final channel = ChannelModel.fromJson(const {'id': 'channel', 'type': 'test', 'truncated_at': ''}); - expect(channel.name, 'Test Channel'); + expect(channel.truncatedAt, isNull); }); - test('.name should return null if not available in extraData', () { + test('.name should fetch from custom if available', () { final channel = ChannelModel( cid: 'test:channel', - extraData: const {}, + custom: const {'name': 'Test Channel'}, ); - expect(channel.name, isNull); + expect(channel.name, 'Test Channel'); }); - test('.name should return null if extraData value is not String', () { + test('.name should return null if not available in custom', () { final channel = ChannelModel( cid: 'test:channel', - extraData: const {'name': true}, + custom: const {}, ); expect(channel.name, isNull); }); - test('.name should return null if extraData value is empty', () { + test('.name should return null if custom value is not String', () { final channel = ChannelModel( cid: 'test:channel', - extraData: const {'name': ''}, + custom: const {'name': true}, ); expect(channel.name, isNull); }); - test('.disabled should fetch from extraData if available', () { - final channel = ChannelModel( - cid: 'test:channel', - extraData: const {'disabled': true}, - ); - - expect(channel.disabled, true); - }); - - test('.disabled should return null if not available in extraData', () { - final channel = ChannelModel( - cid: 'test:channel', - extraData: const {}, - ); - - expect(channel.disabled, isNull); - }); - - test('.disabled should return null if extraData value is not bool', () { - final channel = ChannelModel( - cid: 'test:channel', - extraData: const {'disabled': 'true'}, - ); - - expect(channel.disabled, isNull); - }); - - test('.hidden should fetch from extraData if available', () { - final channel = ChannelModel( - cid: 'test:channel', - extraData: const {'hidden': true}, - ); - - expect(channel.hidden, true); - }); - - test('.hidden should return null if not available in extraData', () { - final channel = ChannelModel( - cid: 'test:channel', - extraData: const {}, - ); - - expect(channel.hidden, isNull); - }); - - test('.hidden should return null if extraData value is not bool', () { - final channel = ChannelModel( - cid: 'test:channel', - extraData: const {'hidden': 'false'}, - ); - - expect(channel.hidden, isNull); - }); - - test('.truncatedAt should fetch from extraData if available', () { - final testDate = DateTime.now(); - final channel = ChannelModel( - cid: 'test:channel', - extraData: {'truncated_at': testDate.toIso8601String()}, - ); - - expect(channel.truncatedAt, testDate); - }); - - test('.truncatedAt should return null if not available in extraData', () { + test('.name should return null if custom value is empty', () { final channel = ChannelModel( cid: 'test:channel', - extraData: const {}, + custom: const {'name': ''}, ); - expect(channel.truncatedAt, isNull); - }); - - test('.truncatedAt should return null if extraData value is not String', () { - final channel = ChannelModel( - cid: 'test:channel', - extraData: const {'truncated_at': true}, - ); - - expect(channel.truncatedAt, isNull); - }); - - test('.truncatedAt should return null if extraData value is empty', () { - final channel = ChannelModel( - cid: 'test:channel', - extraData: const {'truncated_at': ''}, - ); - - expect(channel.truncatedAt, isNull); + expect(channel.name, isNull); }); test('should parse filterTags from json correctly', () { diff --git a/packages/stream_chat/test/src/core/models/member_test.dart b/packages/stream_chat/test/src/core/models/member_test.dart index 16be78c71b..74376bb55d 100644 --- a/packages/stream_chat/test/src/core/models/member_test.dart +++ b/packages/stream_chat/test/src/core/models/member_test.dart @@ -13,7 +13,7 @@ void main() { expect(member.createdAt, DateTime.parse('2020-01-28T22:17:30.95443Z')); expect(member.updatedAt, DateTime.parse('2020-01-28T22:17:30.95443Z')); expect(member.deletedMessages, ['msg-1', 'msg-2', 'msg-3']); - expect(member.extraData['some_custom_field'], 'with_custom_data'); + expect(member.custom['some_custom_field'], 'with_custom_data'); expect(member.notificationsMuted, isTrue); expect(member.status, 'member'); expect(member.banFromFutureChannels, isTrue); @@ -21,54 +21,118 @@ void main() { expect(member.deletedAt, DateTime.parse('2020-01-29T22:17:30.95443Z')); }); - test( - 'Member keeps notificationsMuted, status, banFromFutureChannels, futureChannelBanExpires and deletedAt in extraData', - () { - final member = Member( - userId: 'user', - notificationsMuted: true, - status: 'member', - banFromFutureChannels: true, - futureChannelBanExpires: DateTime.utc(2021), - deletedAt: DateTime.utc(2020), - extraData: const {'color': 'red'}, - ); + test('Member writes its fields to JSON and reads them back', () { + final member = Member( + userId: 'user', + createdAt: DateTime.utc(2020), + updatedAt: DateTime.utc(2020), + notificationsMuted: true, + status: 'member', + banFromFutureChannels: true, + futureChannelBanExpires: DateTime.utc(2021), + deletedAt: DateTime.utc(2020, 2), + custom: const {'color': 'red'}, + ); + + expect(Member.fromJson(member.toJson()), member); + }); - expect(member.extraData, { - 'color': 'red', - 'notifications_muted': true, - 'status': 'member', - 'ban_from_future_channels': true, - 'future_channel_ban_expires': DateTime.utc(2021).toIso8601String(), - 'deleted_at': DateTime.utc(2020).toIso8601String(), - }); - }, - ); - - test('Member arguments override the same keys in extraData', () { + test('Member keeps custom data named like a field apart from the field', () { final member = Member( userId: 'user', status: 'member', - notificationsMuted: true, - extraData: const {'status': 'pending', 'notifications_muted': false}, + custom: const {'status': 'custom', 'color': 'red'}, ); expect(member.status, 'member'); - expect(member.notificationsMuted, isTrue); + expect(member.custom, {'status': 'custom', 'color': 'red'}); + }); + + test('Member.toJson leaves out custom data named like a field', () { + final member = Member( + userId: 'user', + status: 'member', + custom: const {'status': 'custom', 'color': 'red'}, + ); + + expect(member.toJson()['status'], 'member'); + expect(member.toJson()['color'], 'red'); + }); + + test('Member.toJson leaves out a custom role', () { + final member = Member(userId: 'user', custom: const {'role': 'custom'}); + + expect(member.toJson().containsKey('role'), isFalse); }); - test('Member.deletedAt returns null for an extraData value that is not a date', () { - final textMember = Member(userId: 'user', extraData: const {'deleted_at': 'yesterday'}); - final numberMember = Member(userId: 'user', extraData: const {'deleted_at': 42}); + test('Member.copyWith replaces the fields it is given', () { + final member = Member( + userId: 'user', + notificationsMuted: false, + status: 'pending', + banFromFutureChannels: false, + futureChannelBanExpires: DateTime.utc(2021), + deletedAt: DateTime.utc(2020), + ); + + final newMember = member.copyWith( + notificationsMuted: true, + status: 'member', + banFromFutureChannels: true, + futureChannelBanExpires: DateTime.utc(2022), + deletedAt: DateTime.utc(2023), + ); - expect(textMember.deletedAt, isNull); - expect(numberMember.deletedAt, isNull); + expect(newMember.notificationsMuted, isTrue); + expect(newMember.status, 'member'); + expect(newMember.banFromFutureChannels, isTrue); + expect(newMember.futureChannelBanExpires, DateTime.utc(2022)); + expect(newMember.deletedAt, DateTime.utc(2023)); }); - test('Member.futureChannelBanExpires returns null for an empty extraData value', () { - final member = Member(userId: 'user', extraData: const {'future_channel_ban_expires': ''}); + test('Members that differ only in a promoted field are not equal', () { + final createdAt = DateTime.utc(2020); + Member member({ + bool? notificationsMuted, + String? status, + bool? banFromFutureChannels, + DateTime? expires, + DateTime? deletedAt, + }) => Member( + userId: 'user', + createdAt: createdAt, + updatedAt: createdAt, + notificationsMuted: notificationsMuted, + status: status, + banFromFutureChannels: banFromFutureChannels, + futureChannelBanExpires: expires, + deletedAt: deletedAt, + ); + + expect(member(notificationsMuted: true), isNot(member(notificationsMuted: false))); + expect(member(status: 'member'), isNot(member(status: 'pending'))); + expect(member(banFromFutureChannels: true), isNot(member(banFromFutureChannels: false))); + expect(member(expires: DateTime.utc(2021)), isNot(member(expires: DateTime.utc(2022)))); + expect(member(deletedAt: DateTime.utc(2021)), isNot(member(deletedAt: DateTime.utc(2022)))); + }); + + test('Member.copyWith keeps the fields it is not given', () { + final member = Member( + userId: 'user', + notificationsMuted: true, + status: 'member', + banFromFutureChannels: true, + futureChannelBanExpires: DateTime.utc(2021), + deletedAt: DateTime.utc(2020), + ); + + final newMember = member.copyWith(custom: const {'color': 'red'}); - expect(member.futureChannelBanExpires, isNull); + expect(newMember.notificationsMuted, isTrue); + expect(newMember.status, 'member'); + expect(newMember.banFromFutureChannels, isTrue); + expect(newMember.futureChannelBanExpires, DateTime.utc(2021)); + expect(newMember.deletedAt, DateTime.utc(2020)); }); group('MemberSortField', () { @@ -132,11 +196,11 @@ void main() { ); }); - test('a custom field orders by the member extra data', () { + test('a custom field orders by the member custom data', () { expectOrders( MemberSortField.custom('activityScore'), - createTestMember(userId: 'a', extraData: const {'activityScore': 10}), - createTestMember(userId: 'b', extraData: const {'activityScore': 75}), + createTestMember(userId: 'a', custom: const {'activityScore': 10}), + createTestMember(userId: 'b', custom: const {'activityScore': 75}), ); }); @@ -159,7 +223,7 @@ Member createTestMember({ DateTime? updatedAt, DateTime? userLastActive, bool includeUser = true, - Map? extraData, + Map? custom, }) { return Member( userId: userId, @@ -173,6 +237,6 @@ Member createTestMember({ channelRole: channelRole, createdAt: createdAt ?? DateTime(2023), updatedAt: updatedAt ?? DateTime(2023), - extraData: extraData ?? {}, + custom: custom ?? {}, ); } diff --git a/packages/stream_chat/test/src/core/models/sort_comparison_test.dart b/packages/stream_chat/test/src/core/models/sort_comparison_test.dart index 9094b2ed99..5131d88de4 100644 --- a/packages/stream_chat/test/src/core/models/sort_comparison_test.dart +++ b/packages/stream_chat/test/src/core/models/sort_comparison_test.dart @@ -207,7 +207,7 @@ void main() { id: 'red-team', type: 'messaging', createdAt: createdAt, - extraData: const {'team': 'red'}, + custom: const {'team': 'red'}, ), ), ]; @@ -224,7 +224,7 @@ void main() { id: 'red-team', type: 'messaging', createdAt: createdAt, - extraData: const {'team': 'red'}, + custom: const {'team': 'red'}, ), ), ChannelState( diff --git a/packages/stream_chat/test/src/core/models/sort_registry_test.dart b/packages/stream_chat/test/src/core/models/sort_registry_test.dart index 2c8005d183..08da89ee37 100644 --- a/packages/stream_chat/test/src/core/models/sort_registry_test.dart +++ b/packages/stream_chat/test/src/core/models/sort_registry_test.dart @@ -151,7 +151,7 @@ void main() { }); test('`fromRemote` falls back to a field the API added and the SDK has not', () { - // A channel key with no typed property deserializes into extraData, + // A channel key with no typed property deserializes into custom, // which is where the custom fallback looks — so the sort still works. ChannelState channelWith(Object? value) => ChannelState.fromJson({ 'channel': { diff --git a/packages/stream_chat/test/src/core/util/serializer_test.dart b/packages/stream_chat/test/src/core/util/serializer_test.dart index 77073f8a96..f34a8a4126 100644 --- a/packages/stream_chat/test/src/core/util/serializer_test.dart +++ b/packages/stream_chat/test/src/core/util/serializer_test.dart @@ -49,4 +49,16 @@ void main() { expect(json, {'name': 'extra'}); }); + + test('moveFromExtraDataToRoot leaves out an extra_data entry named like one of the top level fields', () { + final json = Serializer.moveFromExtraDataToRoot( + { + 'name': 'root', + 'extra_data': {'name': 'extra', 'role': 'extra', 'color': 'red'}, + }, + topLevelFields: ['name', 'role'], + ); + + expect(json, {'name': 'root', 'color': 'red'}); + }); } diff --git a/packages/stream_chat/test/src/db/chat_persistence_client_test.dart b/packages/stream_chat/test/src/db/chat_persistence_client_test.dart index 5e35ffe050..6046fcb293 100644 --- a/packages/stream_chat/test/src/db/chat_persistence_client_test.dart +++ b/packages/stream_chat/test/src/db/chat_persistence_client_test.dart @@ -143,8 +143,11 @@ class TestPersistenceClient extends ChatPersistenceClient { @override Future updatePollVotes(List pollVotes) => Future.value(); + // The users passed to every [updateUsers] call, in order. + final updatedUsers = []; + @override - Future updateUsers(List users) => Future.value(); + Future updateUsers(List users) async => updatedUsers.addAll(users); @override Future bulkUpdateMembers(Map?> members) => Future.value(); @@ -290,5 +293,19 @@ void main() { ); persistenceClient.updateChannelStates([channelState]); }); + + test('updateChannelStates stores the users who created and last truncated the channel', () async { + final client = TestPersistenceClient(); + final creator = User(id: 'creator'); + final truncator = User(id: 'truncator'); + + await client.updateChannelStates([ + ChannelState( + channel: ChannelModel(cid: 'test:cid', createdBy: creator, truncatedBy: truncator), + ), + ]); + + expect(client.updatedUsers, containsAll([creator, truncator])); + }); }); } diff --git a/packages/stream_chat/test/src/fakes.dart b/packages/stream_chat/test/src/fakes.dart index 792eb7b8e7..1fb05629d7 100644 --- a/packages/stream_chat/test/src/fakes.dart +++ b/packages/stream_chat/test/src/fakes.dart @@ -304,7 +304,7 @@ Member fakeChannelMember() => Member( pinnedAt: DateTime.utc(2026, 1, 4), archivedAt: DateTime.utc(2026, 1, 5), deletedMessages: const ['message-1'], - extraData: const {'nickname': 'Mo', 'role': 'user'}, + custom: const {'nickname': 'Mo'}, notificationsMuted: true, status: 'member', banFromFutureChannels: true, 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 b3a21e3522..22ed18a10c 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 @@ -172,7 +172,7 @@ class MessageSearchListTileTitle extends StatelessWidget { Widget build(BuildContext context) { final user = messageResponse.message.user!; final channel = messageResponse.channel; - final channelName = channel?.extraData['name']; + final channelName = channel?.custom['name']; return Text.rich( TextSpan( diff --git a/packages/stream_chat_flutter/test/src/channel/channel_header_test.dart b/packages/stream_chat_flutter/test/src/channel/channel_header_test.dart index 00e5f6e2b4..c89b14f92d 100644 --- a/packages/stream_chat_flutter/test/src/channel/channel_header_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/channel_header_test.dart @@ -234,12 +234,12 @@ void main() { when(() => channel.client).thenReturn(client); when(() => channel.isMuted).thenReturn(false); when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); - when(() => channel.extraDataStream).thenAnswer( + when(() => channel.customStream).thenAnswer( (i) => Stream.value({ 'name': 'test', }), ); - when(() => channel.extraData).thenReturn({ + when(() => channel.custom).thenReturn({ 'name': 'test', }); when(() => channelState.unreadCount).thenReturn(1); diff --git a/packages/stream_chat_flutter/test/src/channel/channel_image_test.dart b/packages/stream_chat_flutter/test/src/channel/channel_image_test.dart index c6593b5f9c..763981b093 100644 --- a/packages/stream_chat_flutter/test/src/channel/channel_image_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/channel_image_test.dart @@ -98,7 +98,7 @@ void main() { ), }), ); - when(() => channel.extraData).thenReturn({ + when(() => channel.custom).thenReturn({ 'name': 'test', }); diff --git a/packages/stream_chat_flutter/test/src/channel/channel_page_test.dart b/packages/stream_chat_flutter/test/src/channel/channel_page_test.dart index c593fe15b6..7545ea927d 100644 --- a/packages/stream_chat_flutter/test/src/channel/channel_page_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/channel_page_test.dart @@ -299,8 +299,8 @@ Future _pumpChannelPage( when(() => channel.imageStream).thenAnswer((_) => Stream.value(null)); when(() => channel.isMuted).thenReturn(false); when(() => channel.isMutedStream).thenAnswer((_) => Stream.value(false)); - when(() => channel.extraData).thenReturn({'name': 'test'}); - when(() => channel.extraDataStream).thenAnswer((_) => Stream.value({'name': 'test'})); + when(() => channel.custom).thenReturn({'name': 'test'}); + when(() => channel.customStream).thenAnswer((_) => Stream.value({'name': 'test'})); when(() => channelState.members).thenReturn([]); when(() => channelState.membersStream).thenAnswer((_) => Stream.value([])); diff --git a/packages/stream_chat_flutter/test/src/channel/thread_page_test.dart b/packages/stream_chat_flutter/test/src/channel/thread_page_test.dart index ac563654aa..cb3c34e5c8 100644 --- a/packages/stream_chat_flutter/test/src/channel/thread_page_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/thread_page_test.dart @@ -249,8 +249,8 @@ Future _pumpThreadPage( when(() => channel.imageStream).thenAnswer((_) => Stream.value(null)); when(() => channel.isMuted).thenReturn(false); when(() => channel.isMutedStream).thenAnswer((_) => Stream.value(false)); - when(() => channel.extraData).thenReturn({'name': 'test'}); - when(() => channel.extraDataStream).thenAnswer((_) => Stream.value({'name': 'test'})); + when(() => channel.custom).thenReturn({'name': 'test'}); + when(() => channel.customStream).thenAnswer((_) => Stream.value({'name': 'test'})); when( () => channel.getReplies( any(), diff --git a/packages/stream_chat_flutter/test/src/indicators/typing_indicator_test.dart b/packages/stream_chat_flutter/test/src/indicators/typing_indicator_test.dart index bd85cee1a3..94faa2ce90 100644 --- a/packages/stream_chat_flutter/test/src/indicators/typing_indicator_test.dart +++ b/packages/stream_chat_flutter/test/src/indicators/typing_indicator_test.dart @@ -27,12 +27,12 @@ void main() { when(() => channel.client).thenReturn(client); when(() => channel.isMuted).thenReturn(false); when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); - when(() => channel.extraDataStream).thenAnswer( + when(() => channel.customStream).thenAnswer( (i) => Stream.value({ 'name': 'test', }), ); - when(() => channel.extraData).thenReturn({ + when(() => channel.custom).thenReturn({ 'name': 'test', }); when(() => channelState.membersStream).thenAnswer( diff --git a/packages/stream_chat_flutter/test/src/indicators/unread_indicator_test.dart b/packages/stream_chat_flutter/test/src/indicators/unread_indicator_test.dart index d8bf20b406..6016f2f638 100644 --- a/packages/stream_chat_flutter/test/src/indicators/unread_indicator_test.dart +++ b/packages/stream_chat_flutter/test/src/indicators/unread_indicator_test.dart @@ -22,12 +22,12 @@ void main() { when(() => channel.lastMessageAt).thenReturn(lastMessageAt); when(() => channel.state).thenReturn(channelState); when(() => channel.client).thenReturn(client); - when(() => channel.extraDataStream).thenAnswer( + when(() => channel.customStream).thenAnswer( (i) => Stream.value({ 'name': 'test', }), ); - when(() => channel.extraData).thenReturn({ + when(() => channel.custom).thenReturn({ 'name': 'test', }); diff --git a/packages/stream_chat_flutter/test/src/message_input/message_composer_content_insertion_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_composer_content_insertion_test.dart index e2535a6e7b..05a5defd7b 100644 --- a/packages/stream_chat_flutter/test/src/message_input/message_composer_content_insertion_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/message_composer_content_insertion_test.dart @@ -62,8 +62,8 @@ Future _pumpComposer( when(() => channel.client).thenReturn(client); when(channel.getRemainingCooldown).thenReturn(0); when(() => channel.lastMessageAt).thenReturn(DateTime.parse('2020-06-22 12:00:00')); - when(() => channel.extraData).thenReturn({'name': 'test'}); - when(() => channel.extraDataStream).thenAnswer((_) => Stream.value({'name': 'test'})); + when(() => channel.custom).thenReturn({'name': 'test'}); + when(() => channel.customStream).thenAnswer((_) => Stream.value({'name': 'test'})); when(() => channelState.members).thenReturn([member]); when(() => channelState.membersStream).thenAnswer((_) => Stream.value([member])); when(() => channelState.messages).thenReturn([]); diff --git a/packages/stream_chat_flutter/test/src/message_input/message_composer_draft_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_composer_draft_test.dart index 38e8773254..8dffd88c43 100644 --- a/packages/stream_chat_flutter/test/src/message_input/message_composer_draft_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/message_composer_draft_test.dart @@ -34,8 +34,8 @@ void main() { when(() => channel.client).thenReturn(client); when(channel.getRemainingCooldown).thenReturn(0); when(() => channel.lastMessageAt).thenReturn(DateTime.parse('2020-06-22 12:00:00')); - when(() => channel.extraData).thenReturn({'name': 'test'}); - when(() => channel.extraDataStream).thenAnswer((_) => Stream.value({'name': 'test'})); + when(() => channel.custom).thenReturn({'name': 'test'}); + when(() => channel.customStream).thenAnswer((_) => Stream.value({'name': 'test'})); when(() => channelState.members).thenReturn([ Member( userId: 'user-id', diff --git a/packages/stream_chat_flutter/test/src/message_input/message_composer_drop_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_composer_drop_test.dart index 5c582aba92..dedf8ef10a 100644 --- a/packages/stream_chat_flutter/test/src/message_input/message_composer_drop_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/message_composer_drop_test.dart @@ -60,8 +60,8 @@ Future pumpComposerWithSettings( when(() => channel.client).thenReturn(client); when(channel.getRemainingCooldown).thenReturn(0); when(() => channel.lastMessageAt).thenReturn(DateTime.parse('2020-06-22 12:00:00')); - when(() => channel.extraData).thenReturn({'name': 'test'}); - when(() => channel.extraDataStream).thenAnswer((_) => Stream.value({'name': 'test'})); + when(() => channel.custom).thenReturn({'name': 'test'}); + when(() => channel.customStream).thenAnswer((_) => Stream.value({'name': 'test'})); when(() => channelState.members).thenReturn([ Member( userId: 'user-id', diff --git a/packages/stream_chat_flutter/test/src/message_input/message_composer_slow_mode_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_composer_slow_mode_test.dart index 88af2c4f4b..f289a70fb8 100644 --- a/packages/stream_chat_flutter/test/src/message_input/message_composer_slow_mode_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/message_composer_slow_mode_test.dart @@ -42,8 +42,8 @@ void main() { when(() => channel.isPinned).thenReturn(false); when(() => channel.isPinnedStream).thenAnswer((_) => Stream.value(false)); when(() => channel.isDistinct).thenReturn(false); - when(() => channel.extraDataStream).thenAnswer((_) => Stream.value({'name': 'test'})); - when(() => channel.extraData).thenReturn({'name': 'test'}); + when(() => channel.customStream).thenAnswer((_) => Stream.value({'name': 'test'})); + when(() => channel.custom).thenReturn({'name': 'test'}); when(() => channel.name).thenReturn('test'); when(() => channel.nameStream).thenAnswer((_) => Stream.value('test')); when(() => channel.image).thenReturn(null); diff --git a/packages/stream_chat_flutter/test/src/message_input/message_composer_surface_style_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_composer_surface_style_test.dart index e4c0a5a3eb..1b0a69256b 100644 --- a/packages/stream_chat_flutter/test/src/message_input/message_composer_surface_style_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/message_composer_surface_style_test.dart @@ -218,8 +218,8 @@ Future _pumpComposer( when(() => channel.client).thenReturn(client); when(channel.getRemainingCooldown).thenReturn(0); when(() => channel.lastMessageAt).thenReturn(null); - when(() => channel.extraData).thenReturn({'name': 'test'}); - when(() => channel.extraDataStream).thenAnswer((_) => Stream.value({'name': 'test'})); + when(() => channel.custom).thenReturn({'name': 'test'}); + when(() => channel.customStream).thenAnswer((_) => Stream.value({'name': 'test'})); when(() => channelState.members).thenReturn([]); when(() => channelState.membersStream).thenAnswer((_) => Stream.value([])); when(() => channelState.messages).thenReturn([]); diff --git a/packages/stream_chat_flutter/test/src/message_input/message_input_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_input_test.dart index 158344b361..589a2f6b76 100644 --- a/packages/stream_chat_flutter/test/src/message_input/message_input_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/message_input_test.dart @@ -409,8 +409,8 @@ void main() { when(channel.getRemainingCooldown).thenReturn(0); when(() => channel.isMuted).thenReturn(false); when(() => channel.isMutedStream).thenAnswer((_) => Stream.value(false)); - when(() => channel.extraData).thenReturn({'name': 'test'}); - when(() => channel.extraDataStream).thenAnswer((_) => Stream.value({'name': 'test'})); + when(() => channel.custom).thenReturn({'name': 'test'}); + when(() => channel.customStream).thenAnswer((_) => Stream.value({'name': 'test'})); when(() => channelState.isUpToDate).thenReturn(true); when(() => channelState.members).thenReturn([ Member( @@ -1385,12 +1385,12 @@ MaterialApp buildWidget(Widget input) { when(channel.getRemainingCooldown).thenReturn(0); when(() => channel.isMuted).thenReturn(false); when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); - when(() => channel.extraDataStream).thenAnswer( + when(() => channel.customStream).thenAnswer( (i) => Stream.value({ 'name': 'test', }), ); - when(() => channel.extraData).thenReturn({ + when(() => channel.custom).thenReturn({ 'name': 'test', }); when(() => channelState.membersStream).thenAnswer( diff --git a/packages/stream_chat_flutter/test/src/misc/system_message_test.dart b/packages/stream_chat_flutter/test/src/misc/system_message_test.dart index 4c8a81f60f..c10d8a4a1e 100644 --- a/packages/stream_chat_flutter/test/src/misc/system_message_test.dart +++ b/packages/stream_chat_flutter/test/src/misc/system_message_test.dart @@ -22,12 +22,12 @@ void main() { when(() => channel.lastMessageAt).thenReturn(lastMessageAt); when(() => channel.state).thenReturn(channelState); when(() => channel.client).thenReturn(client); - when(() => channel.extraDataStream).thenAnswer( + when(() => channel.customStream).thenAnswer( (i) => Stream.value({ 'name': 'test', }), ); - when(() => channel.extraData).thenReturn({ + when(() => channel.custom).thenReturn({ 'name': 'test', }); @@ -81,12 +81,12 @@ void main() { when(() => channel.lastMessageAt).thenReturn(lastMessageAt); when(() => channel.state).thenReturn(channelState); when(() => channel.client).thenReturn(client); - when(() => channel.extraDataStream).thenAnswer( + when(() => channel.customStream).thenAnswer( (i) => Stream.value({ 'name': 'test', }), ); - when(() => channel.extraData).thenReturn({ + when(() => channel.custom).thenReturn({ 'name': 'test', }); @@ -130,12 +130,12 @@ void main() { when(() => channel.lastMessageAt).thenReturn(lastMessageAt); when(() => channel.state).thenReturn(channelState); when(() => channel.client).thenReturn(client); - when(() => channel.extraDataStream).thenAnswer( + when(() => channel.customStream).thenAnswer( (i) => Stream.value({ 'name': 'test', }), ); - when(() => channel.extraData).thenReturn({ + when(() => channel.custom).thenReturn({ 'name': 'test', }); diff --git a/packages/stream_chat_flutter/test/src/misc/thread_header_test.dart b/packages/stream_chat_flutter/test/src/misc/thread_header_test.dart index de78cbc7c0..3e5fe81e75 100644 --- a/packages/stream_chat_flutter/test/src/misc/thread_header_test.dart +++ b/packages/stream_chat_flutter/test/src/misc/thread_header_test.dart @@ -142,12 +142,12 @@ void main() { when(() => channel.client).thenReturn(client); when(() => channel.isMuted).thenReturn(false); when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); - when(() => channel.extraDataStream).thenAnswer( + when(() => channel.customStream).thenAnswer( (i) => Stream.value({ 'name': 'test', }), ); - when(() => channel.extraData).thenReturn({ + when(() => channel.custom).thenReturn({ 'name': 'test', }); when(() => channelState.unreadCount).thenReturn(1); diff --git a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_thread_list_tile_test.dart b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_thread_list_tile_test.dart index 690e21f151..fea5308324 100644 --- a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_thread_list_tile_test.dart +++ b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_thread_list_tile_test.dart @@ -15,7 +15,7 @@ void main() { channelCid: 'channel-type:channel-id', channel: ChannelModel( cid: 'channel-type:channel-id', - extraData: const {'name': 'Group ride'}, + custom: const {'name': 'Group ride'}, ), parentMessageId: 'parent-message-id', parentMessage: Message( diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md index 75dac631b2..11b409321e 100644 --- a/packages/stream_chat_persistence/CHANGELOG.md +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -12,6 +12,7 @@ - Every persistence call logged its own name at `info`; that call trace is `verbose` now. - A cached poll with a voting visibility the SDK does not name is read back instead of failing. - A poll's custom data is stored in a `custom` column, and a poll option's under a `custom` key rather than beside the option's fields; the schema version is bumped for it, so the local cache is rebuilt once on upgrade. +- A channel's and a member's custom data are stored in a `custom` column, and the fields it used to carry in columns of their own; the schema version is bumped for it, so the local cache is rebuilt once on upgrade. 🐞 Fixed diff --git a/packages/stream_chat_persistence/lib/src/dao/channel_dao.dart b/packages/stream_chat_persistence/lib/src/dao/channel_dao.dart index 98dab74418..bc65d46dbc 100644 --- a/packages/stream_chat_persistence/lib/src/dao/channel_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/channel_dao.dart @@ -14,16 +14,21 @@ class ChannelDao extends DatabaseAccessor with _$ChannelDaoMi ChannelDao(super.db); /// Get channel by cid - Future getChannelByCid(String cid) async => (select(channels)..where((c) => c.cid.equals(cid))) - .join([ - leftOuterJoin(users, channels.createdById.equalsExp(users.id)), - ]) - .map((rows) { - final channel = rows.readTable(channels); - final createdBy = rows.readTableOrNull(users); - return channel.toChannelModel(createdBy: createdBy?.toUser()); - }) - .getSingleOrNull(); + Future getChannelByCid(String cid) async { + final truncatedByUsers = alias(users, 'truncated_by_users'); + return (select(channels)..where((c) => c.cid.equals(cid))) + .join([ + leftOuterJoin(users, channels.createdById.equalsExp(users.id)), + leftOuterJoin(truncatedByUsers, channels.truncatedById.equalsExp(truncatedByUsers.id)), + ]) + .map((rows) { + final channel = rows.readTable(channels); + final createdBy = rows.readTableOrNull(users); + final truncatedBy = rows.readTableOrNull(truncatedByUsers); + return channel.toChannelModel(createdBy: createdBy?.toUser(), truncatedBy: truncatedBy?.toUser()); + }) + .getSingleOrNull(); + } /// Delete all channels by matching cid in [cids] /// diff --git a/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart b/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart index 84fa499ead..03f2fdb0a1 100644 --- a/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart @@ -120,16 +120,7 @@ class ChannelQueryDao extends DatabaseAccessor with _$Channel final cachedChannelCids = await getCachedChannelCids(filter); final query = select(channels)..where((c) => c.cid.isIn(cachedChannelCids)); - final cachedChannels = await query - .join([ - leftOuterJoin(users, channels.createdById.equalsExp(users.id)), - ]) - .map((row) { - final createdByEntity = row.readTableOrNull(users); - final channelEntity = row.readTable(channels); - return channelEntity.toChannelModel(createdBy: createdByEntity?.toUser()); - }) - .get(); + final cachedChannels = await _withUsers(query).get(); return cachedChannels; } @@ -156,19 +147,27 @@ class ChannelQueryDao extends DatabaseAccessor with _$Channel return (const [], metadata?.filter, metadata?.sort); } - final cachedChannels = await (select(channels)..where((c) => c.cid.isIn(cachedCids))) + final cachedChannels = await _withUsers(select(channels)..where((c) => c.cid.isIn(cachedCids))).get(); + + return (cachedChannels, metadata?.filter, metadata?.sort); + } + + // Joins the users who created and last truncated each channel in [query]. + Selectable _withUsers(SimpleSelectStatement<$ChannelsTable, ChannelEntity> query) { + final truncatedByUsers = alias(users, 'truncated_by_users'); + return query .join([ leftOuterJoin(users, channels.createdById.equalsExp(users.id)), + leftOuterJoin(truncatedByUsers, channels.truncatedById.equalsExp(truncatedByUsers.id)), ]) .map((row) { final createdByEntity = row.readTableOrNull(users); + final truncatedByEntity = row.readTableOrNull(truncatedByUsers); final channelEntity = row.readTable(channels); return channelEntity.toChannelModel( createdBy: createdByEntity?.toUser(), + truncatedBy: truncatedByEntity?.toUser(), ); - }) - .get(); - - return (cachedChannels, metadata?.filter, metadata?.sort); + }); } } 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 247b17d188..685ee6b015 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 + 5; + int get schemaVersion => 1100 + 6; // 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 d0cae8180e..ce1fb22ed2 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 @@ -161,14 +161,109 @@ class $ChannelsTable extends Channels with TableInfo<$ChannelsTable, ChannelEnti type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _disabledMeta = const VerificationMeta( + 'disabled', + ); @override - late final GeneratedColumnWithTypeConverter?, String> extraData = GeneratedColumn( - 'extra_data', + late final GeneratedColumn disabled = GeneratedColumn( + 'disabled', + aliasedName, + true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("disabled" IN (0, 1))', + ), + ); + static const VerificationMeta _hiddenMeta = const VerificationMeta('hidden'); + @override + late final GeneratedColumn hidden = GeneratedColumn( + 'hidden', + aliasedName, + true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("hidden" IN (0, 1))', + ), + ); + static const VerificationMeta _mutedMeta = const VerificationMeta('muted'); + @override + late final GeneratedColumn muted = GeneratedColumn( + 'muted', + aliasedName, + true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("muted" IN (0, 1))', + ), + ); + static const VerificationMeta _blockedMeta = const VerificationMeta( + 'blocked', + ); + @override + late final GeneratedColumn blocked = GeneratedColumn( + 'blocked', + aliasedName, + true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("blocked" IN (0, 1))', + ), + ); + static const VerificationMeta _truncatedAtMeta = const VerificationMeta( + 'truncatedAt', + ); + @override + late final GeneratedColumn truncatedAt = GeneratedColumn( + 'truncated_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _truncatedByIdMeta = const VerificationMeta( + 'truncatedById', + ); + @override + late final GeneratedColumn truncatedById = GeneratedColumn( + 'truncated_by_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _autoTranslationEnabledMeta = const VerificationMeta('autoTranslationEnabled'); + @override + late final GeneratedColumn autoTranslationEnabled = GeneratedColumn( + 'auto_translation_enabled', + aliasedName, + true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("auto_translation_enabled" IN (0, 1))', + ), + ); + static const VerificationMeta _autoTranslationLanguageMeta = const VerificationMeta('autoTranslationLanguage'); + @override + late final GeneratedColumn autoTranslationLanguage = GeneratedColumn( + 'auto_translation_language', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + late final GeneratedColumnWithTypeConverter?, String> custom = GeneratedColumn( + 'custom', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false, - ).withConverter?>($ChannelsTable.$converterextraDatan); + ).withConverter?>($ChannelsTable.$convertercustomn); @override List get $columns => [ id, @@ -186,7 +281,15 @@ class $ChannelsTable extends Channels with TableInfo<$ChannelsTable, ChannelEnti createdById, filterTags, team, - extraData, + disabled, + hidden, + muted, + blocked, + truncatedAt, + truncatedById, + autoTranslationEnabled, + autoTranslationLanguage, + custom, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -287,6 +390,66 @@ class $ChannelsTable extends Channels with TableInfo<$ChannelsTable, ChannelEnti team.isAcceptableOrUnknown(data['team']!, _teamMeta), ); } + if (data.containsKey('disabled')) { + context.handle( + _disabledMeta, + disabled.isAcceptableOrUnknown(data['disabled']!, _disabledMeta), + ); + } + if (data.containsKey('hidden')) { + context.handle( + _hiddenMeta, + hidden.isAcceptableOrUnknown(data['hidden']!, _hiddenMeta), + ); + } + if (data.containsKey('muted')) { + context.handle( + _mutedMeta, + muted.isAcceptableOrUnknown(data['muted']!, _mutedMeta), + ); + } + if (data.containsKey('blocked')) { + context.handle( + _blockedMeta, + blocked.isAcceptableOrUnknown(data['blocked']!, _blockedMeta), + ); + } + if (data.containsKey('truncated_at')) { + context.handle( + _truncatedAtMeta, + truncatedAt.isAcceptableOrUnknown( + data['truncated_at']!, + _truncatedAtMeta, + ), + ); + } + if (data.containsKey('truncated_by_id')) { + context.handle( + _truncatedByIdMeta, + truncatedById.isAcceptableOrUnknown( + data['truncated_by_id']!, + _truncatedByIdMeta, + ), + ); + } + if (data.containsKey('auto_translation_enabled')) { + context.handle( + _autoTranslationEnabledMeta, + autoTranslationEnabled.isAcceptableOrUnknown( + data['auto_translation_enabled']!, + _autoTranslationEnabledMeta, + ), + ); + } + if (data.containsKey('auto_translation_language')) { + context.handle( + _autoTranslationLanguageMeta, + autoTranslationLanguage.isAcceptableOrUnknown( + data['auto_translation_language']!, + _autoTranslationLanguageMeta, + ), + ); + } return context; } @@ -362,10 +525,42 @@ class $ChannelsTable extends Channels with TableInfo<$ChannelsTable, ChannelEnti DriftSqlType.string, data['${effectivePrefix}team'], ), - extraData: $ChannelsTable.$converterextraDatan.fromSql( + disabled: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}disabled'], + ), + hidden: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}hidden'], + ), + muted: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}muted'], + ), + blocked: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}blocked'], + ), + truncatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}truncated_at'], + ), + truncatedById: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}truncated_by_id'], + ), + autoTranslationEnabled: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}auto_translation_enabled'], + ), + autoTranslationLanguage: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}auto_translation_language'], + ), + custom: $ChannelsTable.$convertercustomn.fromSql( attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}extra_data'], + data['${effectivePrefix}custom'], ), ), ); @@ -385,9 +580,9 @@ class $ChannelsTable extends Channels with TableInfo<$ChannelsTable, ChannelEnti static TypeConverter?, String?> $converterfilterTagsn = NullAwareTypeConverter.wrap( $converterfilterTags, ); - static TypeConverter, String> $converterextraData = MapConverter(); - static TypeConverter?, String?> $converterextraDatan = NullAwareTypeConverter.wrap( - $converterextraData, + static TypeConverter, String> $convertercustom = MapConverter(); + static TypeConverter?, String?> $convertercustomn = NullAwareTypeConverter.wrap( + $convertercustom, ); } @@ -437,8 +632,32 @@ class ChannelEntity extends DataClass implements Insertable { /// The team the channel belongs to final String? team; - /// Map of custom channel extraData - final Map? extraData; + /// Whether the channel is disabled. + final bool? disabled; + + /// Whether the channel is hidden. + final bool? hidden; + + /// Whether the current user muted the channel. + final bool? muted; + + /// Whether the current user blocked the channel. + final bool? blocked; + + /// The date the channel was last truncated. + final DateTime? truncatedAt; + + /// The id of the user that last truncated the channel. + final String? truncatedById; + + /// Whether messages in the channel are translated automatically. + final bool? autoTranslationEnabled; + + /// The language messages in the channel are translated into automatically. + final String? autoTranslationLanguage; + + /// Custom data attached to the channel. + final Map? custom; const ChannelEntity({ required this.id, required this.type, @@ -455,7 +674,15 @@ class ChannelEntity extends DataClass implements Insertable { this.createdById, this.filterTags, this.team, - this.extraData, + this.disabled, + this.hidden, + this.muted, + this.blocked, + this.truncatedAt, + this.truncatedById, + this.autoTranslationEnabled, + this.autoTranslationLanguage, + this.custom, }); @override Map toColumns(bool nullToAbsent) { @@ -497,9 +724,35 @@ class ChannelEntity extends DataClass implements Insertable { if (!nullToAbsent || team != null) { map['team'] = Variable(team); } - if (!nullToAbsent || extraData != null) { - map['extra_data'] = Variable( - $ChannelsTable.$converterextraDatan.toSql(extraData), + if (!nullToAbsent || disabled != null) { + map['disabled'] = Variable(disabled); + } + if (!nullToAbsent || hidden != null) { + map['hidden'] = Variable(hidden); + } + if (!nullToAbsent || muted != null) { + map['muted'] = Variable(muted); + } + if (!nullToAbsent || blocked != null) { + map['blocked'] = Variable(blocked); + } + if (!nullToAbsent || truncatedAt != null) { + map['truncated_at'] = Variable(truncatedAt); + } + if (!nullToAbsent || truncatedById != null) { + map['truncated_by_id'] = Variable(truncatedById); + } + if (!nullToAbsent || autoTranslationEnabled != null) { + map['auto_translation_enabled'] = Variable(autoTranslationEnabled); + } + if (!nullToAbsent || autoTranslationLanguage != null) { + map['auto_translation_language'] = Variable( + autoTranslationLanguage, + ); + } + if (!nullToAbsent || custom != null) { + map['custom'] = Variable( + $ChannelsTable.$convertercustomn.toSql(custom), ); } return map; @@ -528,7 +781,19 @@ class ChannelEntity extends DataClass implements Insertable { createdById: serializer.fromJson(json['createdById']), filterTags: serializer.fromJson?>(json['filterTags']), team: serializer.fromJson(json['team']), - extraData: serializer.fromJson?>(json['extraData']), + disabled: serializer.fromJson(json['disabled']), + hidden: serializer.fromJson(json['hidden']), + muted: serializer.fromJson(json['muted']), + blocked: serializer.fromJson(json['blocked']), + truncatedAt: serializer.fromJson(json['truncatedAt']), + truncatedById: serializer.fromJson(json['truncatedById']), + autoTranslationEnabled: serializer.fromJson( + json['autoTranslationEnabled'], + ), + autoTranslationLanguage: serializer.fromJson( + json['autoTranslationLanguage'], + ), + custom: serializer.fromJson?>(json['custom']), ); } @override @@ -550,7 +815,19 @@ class ChannelEntity extends DataClass implements Insertable { 'createdById': serializer.toJson(createdById), 'filterTags': serializer.toJson?>(filterTags), 'team': serializer.toJson(team), - 'extraData': serializer.toJson?>(extraData), + 'disabled': serializer.toJson(disabled), + 'hidden': serializer.toJson(hidden), + 'muted': serializer.toJson(muted), + 'blocked': serializer.toJson(blocked), + 'truncatedAt': serializer.toJson(truncatedAt), + 'truncatedById': serializer.toJson(truncatedById), + 'autoTranslationEnabled': serializer.toJson( + autoTranslationEnabled, + ), + 'autoTranslationLanguage': serializer.toJson( + autoTranslationLanguage, + ), + 'custom': serializer.toJson?>(custom), }; } @@ -570,7 +847,15 @@ class ChannelEntity extends DataClass implements Insertable { Value createdById = const Value.absent(), Value?> filterTags = const Value.absent(), Value team = const Value.absent(), - Value?> extraData = const Value.absent(), + Value disabled = const Value.absent(), + Value hidden = const Value.absent(), + Value muted = const Value.absent(), + Value blocked = const Value.absent(), + Value truncatedAt = const Value.absent(), + Value truncatedById = const Value.absent(), + Value autoTranslationEnabled = const Value.absent(), + Value autoTranslationLanguage = const Value.absent(), + Value?> custom = const Value.absent(), }) => ChannelEntity( id: id ?? this.id, type: type ?? this.type, @@ -587,7 +872,17 @@ class ChannelEntity extends DataClass implements Insertable { createdById: createdById.present ? createdById.value : this.createdById, filterTags: filterTags.present ? filterTags.value : this.filterTags, team: team.present ? team.value : this.team, - extraData: extraData.present ? extraData.value : this.extraData, + disabled: disabled.present ? disabled.value : this.disabled, + hidden: hidden.present ? hidden.value : this.hidden, + muted: muted.present ? muted.value : this.muted, + blocked: blocked.present ? blocked.value : this.blocked, + truncatedAt: truncatedAt.present ? truncatedAt.value : this.truncatedAt, + truncatedById: truncatedById.present ? truncatedById.value : this.truncatedById, + autoTranslationEnabled: autoTranslationEnabled.present ? autoTranslationEnabled.value : this.autoTranslationEnabled, + autoTranslationLanguage: autoTranslationLanguage.present + ? autoTranslationLanguage.value + : this.autoTranslationLanguage, + custom: custom.present ? custom.value : this.custom, ); ChannelEntity copyWithCompanion(ChannelsCompanion data) { return ChannelEntity( @@ -606,7 +901,19 @@ class ChannelEntity extends DataClass implements Insertable { createdById: data.createdById.present ? data.createdById.value : this.createdById, filterTags: data.filterTags.present ? data.filterTags.value : this.filterTags, team: data.team.present ? data.team.value : this.team, - extraData: data.extraData.present ? data.extraData.value : this.extraData, + disabled: data.disabled.present ? data.disabled.value : this.disabled, + hidden: data.hidden.present ? data.hidden.value : this.hidden, + muted: data.muted.present ? data.muted.value : this.muted, + blocked: data.blocked.present ? data.blocked.value : this.blocked, + truncatedAt: data.truncatedAt.present ? data.truncatedAt.value : this.truncatedAt, + truncatedById: data.truncatedById.present ? data.truncatedById.value : this.truncatedById, + autoTranslationEnabled: data.autoTranslationEnabled.present + ? data.autoTranslationEnabled.value + : this.autoTranslationEnabled, + autoTranslationLanguage: data.autoTranslationLanguage.present + ? data.autoTranslationLanguage.value + : this.autoTranslationLanguage, + custom: data.custom.present ? data.custom.value : this.custom, ); } @@ -628,13 +935,21 @@ class ChannelEntity extends DataClass implements Insertable { ..write('createdById: $createdById, ') ..write('filterTags: $filterTags, ') ..write('team: $team, ') - ..write('extraData: $extraData') + ..write('disabled: $disabled, ') + ..write('hidden: $hidden, ') + ..write('muted: $muted, ') + ..write('blocked: $blocked, ') + ..write('truncatedAt: $truncatedAt, ') + ..write('truncatedById: $truncatedById, ') + ..write('autoTranslationEnabled: $autoTranslationEnabled, ') + ..write('autoTranslationLanguage: $autoTranslationLanguage, ') + ..write('custom: $custom') ..write(')')) .toString(); } @override - int get hashCode => Object.hash( + int get hashCode => Object.hashAll([ id, type, cid, @@ -650,8 +965,16 @@ class ChannelEntity extends DataClass implements Insertable { createdById, filterTags, team, - extraData, - ); + disabled, + hidden, + muted, + blocked, + truncatedAt, + truncatedById, + autoTranslationEnabled, + autoTranslationLanguage, + custom, + ]); @override bool operator ==(Object other) => identical(this, other) || @@ -671,7 +994,15 @@ class ChannelEntity extends DataClass implements Insertable { other.createdById == this.createdById && other.filterTags == this.filterTags && other.team == this.team && - other.extraData == this.extraData); + other.disabled == this.disabled && + other.hidden == this.hidden && + other.muted == this.muted && + other.blocked == this.blocked && + other.truncatedAt == this.truncatedAt && + other.truncatedById == this.truncatedById && + other.autoTranslationEnabled == this.autoTranslationEnabled && + other.autoTranslationLanguage == this.autoTranslationLanguage && + other.custom == this.custom); } class ChannelsCompanion extends UpdateCompanion { @@ -690,7 +1021,15 @@ class ChannelsCompanion extends UpdateCompanion { final Value createdById; final Value?> filterTags; final Value team; - final Value?> extraData; + final Value disabled; + final Value hidden; + final Value muted; + final Value blocked; + final Value truncatedAt; + final Value truncatedById; + final Value autoTranslationEnabled; + final Value autoTranslationLanguage; + final Value?> custom; final Value rowid; const ChannelsCompanion({ this.id = const Value.absent(), @@ -708,7 +1047,15 @@ class ChannelsCompanion extends UpdateCompanion { this.createdById = const Value.absent(), this.filterTags = const Value.absent(), this.team = const Value.absent(), - this.extraData = const Value.absent(), + this.disabled = const Value.absent(), + this.hidden = const Value.absent(), + this.muted = const Value.absent(), + this.blocked = const Value.absent(), + this.truncatedAt = const Value.absent(), + this.truncatedById = const Value.absent(), + this.autoTranslationEnabled = const Value.absent(), + this.autoTranslationLanguage = const Value.absent(), + this.custom = const Value.absent(), this.rowid = const Value.absent(), }); ChannelsCompanion.insert({ @@ -727,13 +1074,21 @@ class ChannelsCompanion extends UpdateCompanion { this.createdById = const Value.absent(), this.filterTags = const Value.absent(), this.team = const Value.absent(), - this.extraData = const Value.absent(), + this.disabled = const Value.absent(), + this.hidden = const Value.absent(), + this.muted = const Value.absent(), + this.blocked = const Value.absent(), + this.truncatedAt = const Value.absent(), + this.truncatedById = const Value.absent(), + this.autoTranslationEnabled = const Value.absent(), + this.autoTranslationLanguage = const Value.absent(), + this.custom = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), type = Value(type), cid = Value(cid), config = Value(config); - static Insertable custom({ + static Insertable createCustom({ Expression? id, Expression? type, Expression? cid, @@ -749,7 +1104,15 @@ class ChannelsCompanion extends UpdateCompanion { Expression? createdById, Expression? filterTags, Expression? team, - Expression? extraData, + Expression? disabled, + Expression? hidden, + Expression? muted, + Expression? blocked, + Expression? truncatedAt, + Expression? truncatedById, + Expression? autoTranslationEnabled, + Expression? autoTranslationLanguage, + Expression? custom, Expression? rowid, }) { return RawValuesInsertable({ @@ -768,7 +1131,15 @@ class ChannelsCompanion extends UpdateCompanion { if (createdById != null) 'created_by_id': createdById, if (filterTags != null) 'filter_tags': filterTags, if (team != null) 'team': team, - if (extraData != null) 'extra_data': extraData, + if (disabled != null) 'disabled': disabled, + if (hidden != null) 'hidden': hidden, + if (muted != null) 'muted': muted, + if (blocked != null) 'blocked': blocked, + if (truncatedAt != null) 'truncated_at': truncatedAt, + if (truncatedById != null) 'truncated_by_id': truncatedById, + if (autoTranslationEnabled != null) 'auto_translation_enabled': autoTranslationEnabled, + if (autoTranslationLanguage != null) 'auto_translation_language': autoTranslationLanguage, + if (custom != null) 'custom': custom, if (rowid != null) 'rowid': rowid, }); } @@ -789,7 +1160,15 @@ class ChannelsCompanion extends UpdateCompanion { Value? createdById, Value?>? filterTags, Value? team, - Value?>? extraData, + Value? disabled, + Value? hidden, + Value? muted, + Value? blocked, + Value? truncatedAt, + Value? truncatedById, + Value? autoTranslationEnabled, + Value? autoTranslationLanguage, + Value?>? custom, Value? rowid, }) { return ChannelsCompanion( @@ -808,7 +1187,15 @@ class ChannelsCompanion extends UpdateCompanion { createdById: createdById ?? this.createdById, filterTags: filterTags ?? this.filterTags, team: team ?? this.team, - extraData: extraData ?? this.extraData, + disabled: disabled ?? this.disabled, + hidden: hidden ?? this.hidden, + muted: muted ?? this.muted, + blocked: blocked ?? this.blocked, + truncatedAt: truncatedAt ?? this.truncatedAt, + truncatedById: truncatedById ?? this.truncatedById, + autoTranslationEnabled: autoTranslationEnabled ?? this.autoTranslationEnabled, + autoTranslationLanguage: autoTranslationLanguage ?? this.autoTranslationLanguage, + custom: custom ?? this.custom, rowid: rowid ?? this.rowid, ); } @@ -867,9 +1254,37 @@ class ChannelsCompanion extends UpdateCompanion { if (team.present) { map['team'] = Variable(team.value); } - if (extraData.present) { - map['extra_data'] = Variable( - $ChannelsTable.$converterextraDatan.toSql(extraData.value), + if (disabled.present) { + map['disabled'] = Variable(disabled.value); + } + if (hidden.present) { + map['hidden'] = Variable(hidden.value); + } + if (muted.present) { + map['muted'] = Variable(muted.value); + } + if (blocked.present) { + map['blocked'] = Variable(blocked.value); + } + if (truncatedAt.present) { + map['truncated_at'] = Variable(truncatedAt.value); + } + if (truncatedById.present) { + map['truncated_by_id'] = Variable(truncatedById.value); + } + if (autoTranslationEnabled.present) { + map['auto_translation_enabled'] = Variable( + autoTranslationEnabled.value, + ); + } + if (autoTranslationLanguage.present) { + map['auto_translation_language'] = Variable( + autoTranslationLanguage.value, + ); + } + if (custom.present) { + map['custom'] = Variable( + $ChannelsTable.$convertercustomn.toSql(custom.value), ); } if (rowid.present) { @@ -896,7 +1311,15 @@ class ChannelsCompanion extends UpdateCompanion { ..write('createdById: $createdById, ') ..write('filterTags: $filterTags, ') ..write('team: $team, ') - ..write('extraData: $extraData, ') + ..write('disabled: $disabled, ') + ..write('hidden: $hidden, ') + ..write('muted: $muted, ') + ..write('blocked: $blocked, ') + ..write('truncatedAt: $truncatedAt, ') + ..write('truncatedById: $truncatedById, ') + ..write('autoTranslationEnabled: $autoTranslationEnabled, ') + ..write('autoTranslationLanguage: $autoTranslationLanguage, ') + ..write('custom: $custom, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -9713,13 +10136,66 @@ class $MembersTable extends Members with TableInfo<$MembersTable, MemberEntity> defaultValue: const Constant(false), ); @override - late final GeneratedColumnWithTypeConverter?, String> extraData = GeneratedColumn( - 'extra_data', + late final GeneratedColumnWithTypeConverter?, String> custom = GeneratedColumn( + 'custom', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter?>($MembersTable.$convertercustomn); + static const VerificationMeta _notificationsMutedMeta = const VerificationMeta('notificationsMuted'); + @override + late final GeneratedColumn notificationsMuted = GeneratedColumn( + 'notifications_muted', + aliasedName, + true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("notifications_muted" IN (0, 1))', + ), + ); + static const VerificationMeta _statusMeta = const VerificationMeta('status'); + @override + late final GeneratedColumn status = GeneratedColumn( + 'status', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false, - ).withConverter?>($MembersTable.$converterextraDatan); + ); + static const VerificationMeta _banFromFutureChannelsMeta = const VerificationMeta('banFromFutureChannels'); + @override + late final GeneratedColumn banFromFutureChannels = GeneratedColumn( + 'ban_from_future_channels', + aliasedName, + true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("ban_from_future_channels" IN (0, 1))', + ), + ); + static const VerificationMeta _futureChannelBanExpiresMeta = const VerificationMeta('futureChannelBanExpires'); + @override + late final GeneratedColumn futureChannelBanExpires = GeneratedColumn( + 'future_channel_ban_expires', + 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 _createdAtMeta = const VerificationMeta( 'createdAt', ); @@ -9766,7 +10242,12 @@ class $MembersTable extends Members with TableInfo<$MembersTable, MemberEntity> pinnedAt, archivedAt, isModerator, - extraData, + custom, + notificationsMuted, + status, + banFromFutureChannels, + futureChannelBanExpires, + deletedAt, createdAt, updatedAt, deletedMessages, @@ -9874,22 +10355,61 @@ class $MembersTable extends Members with TableInfo<$MembersTable, MemberEntity> ), ); } - if (data.containsKey('created_at')) { + if (data.containsKey('notifications_muted')) { context.handle( - _createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + _notificationsMutedMeta, + notificationsMuted.isAcceptableOrUnknown( + data['notifications_muted']!, + _notificationsMutedMeta, + ), ); } - if (data.containsKey('updated_at')) { + if (data.containsKey('status')) { context.handle( - _updatedAtMeta, - updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), + _statusMeta, + status.isAcceptableOrUnknown(data['status']!, _statusMeta), ); } - return context; - } - - @override + if (data.containsKey('ban_from_future_channels')) { + context.handle( + _banFromFutureChannelsMeta, + banFromFutureChannels.isAcceptableOrUnknown( + data['ban_from_future_channels']!, + _banFromFutureChannelsMeta, + ), + ); + } + if (data.containsKey('future_channel_ban_expires')) { + context.handle( + _futureChannelBanExpiresMeta, + futureChannelBanExpires.isAcceptableOrUnknown( + data['future_channel_ban_expires']!, + _futureChannelBanExpiresMeta, + ), + ); + } + if (data.containsKey('deleted_at')) { + context.handle( + _deletedAtMeta, + deletedAt.isAcceptableOrUnknown(data['deleted_at']!, _deletedAtMeta), + ); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } + if (data.containsKey('updated_at')) { + context.handle( + _updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), + ); + } + return context; + } + + @override Set get $primaryKey => {userId, channelCid}; @override MemberEntity map(Map data, {String? tablePrefix}) { @@ -9943,12 +10463,32 @@ class $MembersTable extends Members with TableInfo<$MembersTable, MemberEntity> DriftSqlType.bool, data['${effectivePrefix}is_moderator'], )!, - extraData: $MembersTable.$converterextraDatan.fromSql( + custom: $MembersTable.$convertercustomn.fromSql( attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}extra_data'], + data['${effectivePrefix}custom'], ), ), + notificationsMuted: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}notifications_muted'], + ), + status: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status'], + ), + banFromFutureChannels: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}ban_from_future_channels'], + ), + futureChannelBanExpires: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}future_channel_ban_expires'], + ), + deletedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}deleted_at'], + ), createdAt: attachedDatabase.typeMapping.read( DriftSqlType.dateTime, data['${effectivePrefix}created_at'], @@ -9971,9 +10511,9 @@ class $MembersTable extends Members with TableInfo<$MembersTable, MemberEntity> return $MembersTable(attachedDatabase, alias); } - static TypeConverter, String> $converterextraData = MapConverter(); - static TypeConverter?, String?> $converterextraDatan = NullAwareTypeConverter.wrap( - $converterextraData, + static TypeConverter, String> $convertercustom = MapConverter(); + static TypeConverter?, String?> $convertercustomn = NullAwareTypeConverter.wrap( + $convertercustom, ); static TypeConverter, String> $converterdeletedMessages = ListConverter(); } @@ -10017,8 +10557,23 @@ class MemberEntity extends DataClass implements Insertable { /// True if the user is a moderator of the channel final bool isModerator; - /// Map of custom member extraData - final Map? extraData; + /// Custom data attached to the member. + final Map? custom; + + /// Whether the member muted notifications for the channel. + final bool? notificationsMuted; + + /// The status of the membership. + final String? status; + + /// Whether the member is banned from future channels. + final bool? banFromFutureChannels; + + /// The date at which the member's ban from future channels expires. + final DateTime? futureChannelBanExpires; + + /// The date at which the membership was deleted. + final DateTime? deletedAt; /// The date of creation final DateTime createdAt; @@ -10044,7 +10599,12 @@ class MemberEntity extends DataClass implements Insertable { this.pinnedAt, this.archivedAt, required this.isModerator, - this.extraData, + this.custom, + this.notificationsMuted, + this.status, + this.banFromFutureChannels, + this.futureChannelBanExpires, + this.deletedAt, required this.createdAt, required this.updatedAt, required this.deletedMessages, @@ -10076,11 +10636,28 @@ class MemberEntity extends DataClass implements Insertable { map['archived_at'] = Variable(archivedAt); } map['is_moderator'] = Variable(isModerator); - if (!nullToAbsent || extraData != null) { - map['extra_data'] = Variable( - $MembersTable.$converterextraDatan.toSql(extraData), + if (!nullToAbsent || custom != null) { + map['custom'] = Variable( + $MembersTable.$convertercustomn.toSql(custom), + ); + } + if (!nullToAbsent || notificationsMuted != null) { + map['notifications_muted'] = Variable(notificationsMuted); + } + if (!nullToAbsent || status != null) { + map['status'] = Variable(status); + } + if (!nullToAbsent || banFromFutureChannels != null) { + map['ban_from_future_channels'] = Variable(banFromFutureChannels); + } + if (!nullToAbsent || futureChannelBanExpires != null) { + map['future_channel_ban_expires'] = Variable( + futureChannelBanExpires, ); } + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = Variable(deletedAt); + } map['created_at'] = Variable(createdAt); map['updated_at'] = Variable(updatedAt); { @@ -10113,7 +10690,18 @@ class MemberEntity extends DataClass implements Insertable { pinnedAt: serializer.fromJson(json['pinnedAt']), archivedAt: serializer.fromJson(json['archivedAt']), isModerator: serializer.fromJson(json['isModerator']), - extraData: serializer.fromJson?>(json['extraData']), + custom: serializer.fromJson?>(json['custom']), + notificationsMuted: serializer.fromJson( + json['notificationsMuted'], + ), + status: serializer.fromJson(json['status']), + banFromFutureChannels: serializer.fromJson( + json['banFromFutureChannels'], + ), + futureChannelBanExpires: serializer.fromJson( + json['futureChannelBanExpires'], + ), + deletedAt: serializer.fromJson(json['deletedAt']), createdAt: serializer.fromJson(json['createdAt']), updatedAt: serializer.fromJson(json['updatedAt']), deletedMessages: serializer.fromJson>( @@ -10137,7 +10725,14 @@ class MemberEntity extends DataClass implements Insertable { 'pinnedAt': serializer.toJson(pinnedAt), 'archivedAt': serializer.toJson(archivedAt), 'isModerator': serializer.toJson(isModerator), - 'extraData': serializer.toJson?>(extraData), + 'custom': serializer.toJson?>(custom), + 'notificationsMuted': serializer.toJson(notificationsMuted), + 'status': serializer.toJson(status), + 'banFromFutureChannels': serializer.toJson(banFromFutureChannels), + 'futureChannelBanExpires': serializer.toJson( + futureChannelBanExpires, + ), + 'deletedAt': serializer.toJson(deletedAt), 'createdAt': serializer.toJson(createdAt), 'updatedAt': serializer.toJson(updatedAt), 'deletedMessages': serializer.toJson>(deletedMessages), @@ -10157,7 +10752,12 @@ class MemberEntity extends DataClass implements Insertable { Value pinnedAt = const Value.absent(), Value archivedAt = const Value.absent(), bool? isModerator, - Value?> extraData = const Value.absent(), + Value?> custom = const Value.absent(), + Value notificationsMuted = const Value.absent(), + Value status = const Value.absent(), + Value banFromFutureChannels = const Value.absent(), + Value futureChannelBanExpires = const Value.absent(), + Value deletedAt = const Value.absent(), DateTime? createdAt, DateTime? updatedAt, List? deletedMessages, @@ -10174,7 +10774,14 @@ class MemberEntity extends DataClass implements Insertable { pinnedAt: pinnedAt.present ? pinnedAt.value : this.pinnedAt, archivedAt: archivedAt.present ? archivedAt.value : this.archivedAt, isModerator: isModerator ?? this.isModerator, - extraData: extraData.present ? extraData.value : this.extraData, + custom: custom.present ? custom.value : this.custom, + notificationsMuted: notificationsMuted.present ? notificationsMuted.value : this.notificationsMuted, + status: status.present ? status.value : this.status, + banFromFutureChannels: banFromFutureChannels.present ? banFromFutureChannels.value : this.banFromFutureChannels, + futureChannelBanExpires: futureChannelBanExpires.present + ? futureChannelBanExpires.value + : this.futureChannelBanExpires, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, deletedMessages: deletedMessages ?? this.deletedMessages, @@ -10193,7 +10800,16 @@ class MemberEntity extends DataClass implements Insertable { pinnedAt: data.pinnedAt.present ? data.pinnedAt.value : this.pinnedAt, archivedAt: data.archivedAt.present ? data.archivedAt.value : this.archivedAt, isModerator: data.isModerator.present ? data.isModerator.value : this.isModerator, - extraData: data.extraData.present ? data.extraData.value : this.extraData, + custom: data.custom.present ? data.custom.value : this.custom, + notificationsMuted: data.notificationsMuted.present ? data.notificationsMuted.value : this.notificationsMuted, + status: data.status.present ? data.status.value : this.status, + banFromFutureChannels: data.banFromFutureChannels.present + ? data.banFromFutureChannels.value + : this.banFromFutureChannels, + futureChannelBanExpires: data.futureChannelBanExpires.present + ? data.futureChannelBanExpires.value + : this.futureChannelBanExpires, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, deletedMessages: data.deletedMessages.present ? data.deletedMessages.value : this.deletedMessages, @@ -10215,7 +10831,12 @@ class MemberEntity extends DataClass implements Insertable { ..write('pinnedAt: $pinnedAt, ') ..write('archivedAt: $archivedAt, ') ..write('isModerator: $isModerator, ') - ..write('extraData: $extraData, ') + ..write('custom: $custom, ') + ..write('notificationsMuted: $notificationsMuted, ') + ..write('status: $status, ') + ..write('banFromFutureChannels: $banFromFutureChannels, ') + ..write('futureChannelBanExpires: $futureChannelBanExpires, ') + ..write('deletedAt: $deletedAt, ') ..write('createdAt: $createdAt, ') ..write('updatedAt: $updatedAt, ') ..write('deletedMessages: $deletedMessages') @@ -10224,7 +10845,7 @@ class MemberEntity extends DataClass implements Insertable { } @override - int get hashCode => Object.hash( + int get hashCode => Object.hashAll([ userId, channelCid, channelRole, @@ -10237,11 +10858,16 @@ class MemberEntity extends DataClass implements Insertable { pinnedAt, archivedAt, isModerator, - extraData, + custom, + notificationsMuted, + status, + banFromFutureChannels, + futureChannelBanExpires, + deletedAt, createdAt, updatedAt, deletedMessages, - ); + ]); @override bool operator ==(Object other) => identical(this, other) || @@ -10258,7 +10884,12 @@ class MemberEntity extends DataClass implements Insertable { other.pinnedAt == this.pinnedAt && other.archivedAt == this.archivedAt && other.isModerator == this.isModerator && - other.extraData == this.extraData && + other.custom == this.custom && + other.notificationsMuted == this.notificationsMuted && + other.status == this.status && + other.banFromFutureChannels == this.banFromFutureChannels && + other.futureChannelBanExpires == this.futureChannelBanExpires && + other.deletedAt == this.deletedAt && other.createdAt == this.createdAt && other.updatedAt == this.updatedAt && other.deletedMessages == this.deletedMessages); @@ -10277,7 +10908,12 @@ class MembersCompanion extends UpdateCompanion { final Value pinnedAt; final Value archivedAt; final Value isModerator; - final Value?> extraData; + final Value?> custom; + final Value notificationsMuted; + final Value status; + final Value banFromFutureChannels; + final Value futureChannelBanExpires; + final Value deletedAt; final Value createdAt; final Value updatedAt; final Value> deletedMessages; @@ -10295,7 +10931,12 @@ class MembersCompanion extends UpdateCompanion { this.pinnedAt = const Value.absent(), this.archivedAt = const Value.absent(), this.isModerator = const Value.absent(), - this.extraData = const Value.absent(), + this.custom = const Value.absent(), + this.notificationsMuted = const Value.absent(), + this.status = const Value.absent(), + this.banFromFutureChannels = const Value.absent(), + this.futureChannelBanExpires = const Value.absent(), + this.deletedAt = const Value.absent(), this.createdAt = const Value.absent(), this.updatedAt = const Value.absent(), this.deletedMessages = const Value.absent(), @@ -10314,7 +10955,12 @@ class MembersCompanion extends UpdateCompanion { this.pinnedAt = const Value.absent(), this.archivedAt = const Value.absent(), this.isModerator = const Value.absent(), - this.extraData = const Value.absent(), + this.custom = const Value.absent(), + this.notificationsMuted = const Value.absent(), + this.status = const Value.absent(), + this.banFromFutureChannels = const Value.absent(), + this.futureChannelBanExpires = const Value.absent(), + this.deletedAt = const Value.absent(), this.createdAt = const Value.absent(), this.updatedAt = const Value.absent(), required List deletedMessages, @@ -10322,7 +10968,7 @@ class MembersCompanion extends UpdateCompanion { }) : userId = Value(userId), channelCid = Value(channelCid), deletedMessages = Value(deletedMessages); - static Insertable custom({ + static Insertable createCustom({ Expression? userId, Expression? channelCid, Expression? channelRole, @@ -10335,7 +10981,12 @@ class MembersCompanion extends UpdateCompanion { Expression? pinnedAt, Expression? archivedAt, Expression? isModerator, - Expression? extraData, + Expression? custom, + Expression? notificationsMuted, + Expression? status, + Expression? banFromFutureChannels, + Expression? futureChannelBanExpires, + Expression? deletedAt, Expression? createdAt, Expression? updatedAt, Expression? deletedMessages, @@ -10354,7 +11005,12 @@ class MembersCompanion extends UpdateCompanion { if (pinnedAt != null) 'pinned_at': pinnedAt, if (archivedAt != null) 'archived_at': archivedAt, if (isModerator != null) 'is_moderator': isModerator, - if (extraData != null) 'extra_data': extraData, + if (custom != null) 'custom': custom, + if (notificationsMuted != null) 'notifications_muted': notificationsMuted, + if (status != null) 'status': status, + if (banFromFutureChannels != null) 'ban_from_future_channels': banFromFutureChannels, + if (futureChannelBanExpires != null) 'future_channel_ban_expires': futureChannelBanExpires, + if (deletedAt != null) 'deleted_at': deletedAt, if (createdAt != null) 'created_at': createdAt, if (updatedAt != null) 'updated_at': updatedAt, if (deletedMessages != null) 'deleted_messages': deletedMessages, @@ -10375,7 +11031,12 @@ class MembersCompanion extends UpdateCompanion { Value? pinnedAt, Value? archivedAt, Value? isModerator, - Value?>? extraData, + Value?>? custom, + Value? notificationsMuted, + Value? status, + Value? banFromFutureChannels, + Value? futureChannelBanExpires, + Value? deletedAt, Value? createdAt, Value? updatedAt, Value>? deletedMessages, @@ -10394,7 +11055,12 @@ class MembersCompanion extends UpdateCompanion { pinnedAt: pinnedAt ?? this.pinnedAt, archivedAt: archivedAt ?? this.archivedAt, isModerator: isModerator ?? this.isModerator, - extraData: extraData ?? this.extraData, + custom: custom ?? this.custom, + notificationsMuted: notificationsMuted ?? this.notificationsMuted, + status: status ?? this.status, + banFromFutureChannels: banFromFutureChannels ?? this.banFromFutureChannels, + futureChannelBanExpires: futureChannelBanExpires ?? this.futureChannelBanExpires, + deletedAt: deletedAt ?? this.deletedAt, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, deletedMessages: deletedMessages ?? this.deletedMessages, @@ -10441,11 +11107,30 @@ class MembersCompanion extends UpdateCompanion { if (isModerator.present) { map['is_moderator'] = Variable(isModerator.value); } - if (extraData.present) { - map['extra_data'] = Variable( - $MembersTable.$converterextraDatan.toSql(extraData.value), + if (custom.present) { + map['custom'] = Variable( + $MembersTable.$convertercustomn.toSql(custom.value), ); } + if (notificationsMuted.present) { + map['notifications_muted'] = Variable(notificationsMuted.value); + } + if (status.present) { + map['status'] = Variable(status.value); + } + if (banFromFutureChannels.present) { + map['ban_from_future_channels'] = Variable( + banFromFutureChannels.value, + ); + } + if (futureChannelBanExpires.present) { + map['future_channel_ban_expires'] = Variable( + futureChannelBanExpires.value, + ); + } + if (deletedAt.present) { + map['deleted_at'] = Variable(deletedAt.value); + } if (createdAt.present) { map['created_at'] = Variable(createdAt.value); } @@ -10478,7 +11163,12 @@ class MembersCompanion extends UpdateCompanion { ..write('pinnedAt: $pinnedAt, ') ..write('archivedAt: $archivedAt, ') ..write('isModerator: $isModerator, ') - ..write('extraData: $extraData, ') + ..write('custom: $custom, ') + ..write('notificationsMuted: $notificationsMuted, ') + ..write('status: $status, ') + ..write('banFromFutureChannels: $banFromFutureChannels, ') + ..write('futureChannelBanExpires: $futureChannelBanExpires, ') + ..write('deletedAt: $deletedAt, ') ..write('createdAt: $createdAt, ') ..write('updatedAt: $updatedAt, ') ..write('deletedMessages: $deletedMessages, ') @@ -12098,7 +12788,15 @@ typedef $$ChannelsTableCreateCompanionBuilder = Value createdById, Value?> filterTags, Value team, - Value?> extraData, + Value disabled, + Value hidden, + Value muted, + Value blocked, + Value truncatedAt, + Value truncatedById, + Value autoTranslationEnabled, + Value autoTranslationLanguage, + Value?> custom, Value rowid, }); typedef $$ChannelsTableUpdateCompanionBuilder = @@ -12118,7 +12816,15 @@ typedef $$ChannelsTableUpdateCompanionBuilder = Value createdById, Value?> filterTags, Value team, - Value?> extraData, + Value disabled, + Value hidden, + Value muted, + Value blocked, + Value truncatedAt, + Value truncatedById, + Value autoTranslationEnabled, + Value autoTranslationLanguage, + Value?> custom, Value rowid, }); @@ -12301,11 +13007,50 @@ class $$ChannelsTableFilterComposer extends Composer<_$DriftChatDatabase, $Chann builder: (column) => ColumnFilters(column), ); - ColumnWithTypeConverterFilters?, Map, String> get extraData => - $composableBuilder( - column: $table.extraData, - builder: (column) => ColumnWithTypeConverterFilters(column), - ); + ColumnFilters get disabled => $composableBuilder( + column: $table.disabled, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get hidden => $composableBuilder( + column: $table.hidden, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get muted => $composableBuilder( + column: $table.muted, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get blocked => $composableBuilder( + column: $table.blocked, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get truncatedAt => $composableBuilder( + column: $table.truncatedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get truncatedById => $composableBuilder( + column: $table.truncatedById, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get autoTranslationEnabled => $composableBuilder( + column: $table.autoTranslationEnabled, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get autoTranslationLanguage => $composableBuilder( + column: $table.autoTranslationLanguage, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters?, Map, String> get custom => $composableBuilder( + column: $table.custom, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); Expression messagesRefs( Expression Function($$MessagesTableFilterComposer f) f, @@ -12511,8 +13256,48 @@ class $$ChannelsTableOrderingComposer extends Composer<_$DriftChatDatabase, $Cha builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get extraData => $composableBuilder( - column: $table.extraData, + ColumnOrderings get disabled => $composableBuilder( + column: $table.disabled, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get hidden => $composableBuilder( + column: $table.hidden, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get muted => $composableBuilder( + column: $table.muted, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get blocked => $composableBuilder( + column: $table.blocked, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get truncatedAt => $composableBuilder( + column: $table.truncatedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get truncatedById => $composableBuilder( + column: $table.truncatedById, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get autoTranslationEnabled => $composableBuilder( + column: $table.autoTranslationEnabled, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get autoTranslationLanguage => $composableBuilder( + column: $table.autoTranslationLanguage, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get custom => $composableBuilder( + column: $table.custom, builder: (column) => ColumnOrderings(column), ); } @@ -12574,8 +13359,36 @@ class $$ChannelsTableAnnotationComposer extends Composer<_$DriftChatDatabase, $C GeneratedColumn get team => $composableBuilder(column: $table.team, builder: (column) => column); - GeneratedColumnWithTypeConverter?, String> get extraData => - $composableBuilder(column: $table.extraData, builder: (column) => column); + GeneratedColumn get disabled => $composableBuilder(column: $table.disabled, builder: (column) => column); + + GeneratedColumn get hidden => $composableBuilder(column: $table.hidden, builder: (column) => column); + + GeneratedColumn get muted => $composableBuilder(column: $table.muted, builder: (column) => column); + + GeneratedColumn get blocked => $composableBuilder(column: $table.blocked, builder: (column) => column); + + GeneratedColumn get truncatedAt => $composableBuilder( + column: $table.truncatedAt, + builder: (column) => column, + ); + + GeneratedColumn get truncatedById => $composableBuilder( + column: $table.truncatedById, + builder: (column) => column, + ); + + GeneratedColumn get autoTranslationEnabled => $composableBuilder( + column: $table.autoTranslationEnabled, + builder: (column) => column, + ); + + GeneratedColumn get autoTranslationLanguage => $composableBuilder( + column: $table.autoTranslationLanguage, + builder: (column) => column, + ); + + GeneratedColumnWithTypeConverter?, String> get custom => + $composableBuilder(column: $table.custom, builder: (column) => column); Expression messagesRefs( Expression Function($$MessagesTableAnnotationComposer a) f, @@ -12744,7 +13557,15 @@ class $$ChannelsTableTableManager Value createdById = const Value.absent(), Value?> filterTags = const Value.absent(), Value team = const Value.absent(), - Value?> extraData = const Value.absent(), + Value disabled = const Value.absent(), + Value hidden = const Value.absent(), + Value muted = const Value.absent(), + Value blocked = const Value.absent(), + Value truncatedAt = const Value.absent(), + Value truncatedById = const Value.absent(), + Value autoTranslationEnabled = const Value.absent(), + Value autoTranslationLanguage = const Value.absent(), + Value?> custom = const Value.absent(), Value rowid = const Value.absent(), }) => ChannelsCompanion( id: id, @@ -12762,7 +13583,15 @@ class $$ChannelsTableTableManager createdById: createdById, filterTags: filterTags, team: team, - extraData: extraData, + disabled: disabled, + hidden: hidden, + muted: muted, + blocked: blocked, + truncatedAt: truncatedAt, + truncatedById: truncatedById, + autoTranslationEnabled: autoTranslationEnabled, + autoTranslationLanguage: autoTranslationLanguage, + custom: custom, rowid: rowid, ), createCompanionCallback: @@ -12782,7 +13611,15 @@ class $$ChannelsTableTableManager Value createdById = const Value.absent(), Value?> filterTags = const Value.absent(), Value team = const Value.absent(), - Value?> extraData = const Value.absent(), + Value disabled = const Value.absent(), + Value hidden = const Value.absent(), + Value muted = const Value.absent(), + Value blocked = const Value.absent(), + Value truncatedAt = const Value.absent(), + Value truncatedById = const Value.absent(), + Value autoTranslationEnabled = const Value.absent(), + Value autoTranslationLanguage = const Value.absent(), + Value?> custom = const Value.absent(), Value rowid = const Value.absent(), }) => ChannelsCompanion.insert( id: id, @@ -12800,7 +13637,15 @@ class $$ChannelsTableTableManager createdById: createdById, filterTags: filterTags, team: team, - extraData: extraData, + disabled: disabled, + hidden: hidden, + muted: muted, + blocked: blocked, + truncatedAt: truncatedAt, + truncatedById: truncatedById, + autoTranslationEnabled: autoTranslationEnabled, + autoTranslationLanguage: autoTranslationLanguage, + custom: custom, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -18099,7 +18944,12 @@ typedef $$MembersTableCreateCompanionBuilder = Value pinnedAt, Value archivedAt, Value isModerator, - Value?> extraData, + Value?> custom, + Value notificationsMuted, + Value status, + Value banFromFutureChannels, + Value futureChannelBanExpires, + Value deletedAt, Value createdAt, Value updatedAt, required List deletedMessages, @@ -18119,7 +18969,12 @@ typedef $$MembersTableUpdateCompanionBuilder = Value pinnedAt, Value archivedAt, Value isModerator, - Value?> extraData, + Value?> custom, + Value notificationsMuted, + Value status, + Value banFromFutureChannels, + Value futureChannelBanExpires, + Value deletedAt, Value createdAt, Value updatedAt, Value> deletedMessages, @@ -18210,11 +19065,35 @@ class $$MembersTableFilterComposer extends Composer<_$DriftChatDatabase, $Member builder: (column) => ColumnFilters(column), ); - ColumnWithTypeConverterFilters?, Map, String> get extraData => - $composableBuilder( - column: $table.extraData, - builder: (column) => ColumnWithTypeConverterFilters(column), - ); + ColumnWithTypeConverterFilters?, Map, String> get custom => $composableBuilder( + column: $table.custom, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnFilters get notificationsMuted => $composableBuilder( + column: $table.notificationsMuted, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get banFromFutureChannels => $composableBuilder( + column: $table.banFromFutureChannels, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get futureChannelBanExpires => $composableBuilder( + column: $table.futureChannelBanExpires, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get deletedAt => $composableBuilder( + column: $table.deletedAt, + builder: (column) => ColumnFilters(column), + ); ColumnFilters get createdAt => $composableBuilder( column: $table.createdAt, @@ -18317,8 +19196,33 @@ class $$MembersTableOrderingComposer extends Composer<_$DriftChatDatabase, $Memb builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get extraData => $composableBuilder( - column: $table.extraData, + ColumnOrderings get custom => $composableBuilder( + column: $table.custom, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get notificationsMuted => $composableBuilder( + column: $table.notificationsMuted, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get banFromFutureChannels => $composableBuilder( + column: $table.banFromFutureChannels, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get futureChannelBanExpires => $composableBuilder( + column: $table.futureChannelBanExpires, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get deletedAt => $composableBuilder( + column: $table.deletedAt, builder: (column) => ColumnOrderings(column), ); @@ -18411,8 +19315,27 @@ class $$MembersTableAnnotationComposer extends Composer<_$DriftChatDatabase, $Me builder: (column) => column, ); - GeneratedColumnWithTypeConverter?, String> get extraData => - $composableBuilder(column: $table.extraData, builder: (column) => column); + GeneratedColumnWithTypeConverter?, String> get custom => + $composableBuilder(column: $table.custom, builder: (column) => column); + + GeneratedColumn get notificationsMuted => $composableBuilder( + column: $table.notificationsMuted, + builder: (column) => column, + ); + + GeneratedColumn get status => $composableBuilder(column: $table.status, builder: (column) => column); + + GeneratedColumn get banFromFutureChannels => $composableBuilder( + column: $table.banFromFutureChannels, + builder: (column) => column, + ); + + GeneratedColumn get futureChannelBanExpires => $composableBuilder( + column: $table.futureChannelBanExpires, + builder: (column) => column, + ); + + GeneratedColumn get deletedAt => $composableBuilder(column: $table.deletedAt, builder: (column) => column); GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); @@ -18483,7 +19406,12 @@ class $$MembersTableTableManager Value pinnedAt = const Value.absent(), Value archivedAt = const Value.absent(), Value isModerator = const Value.absent(), - Value?> extraData = const Value.absent(), + Value?> custom = const Value.absent(), + Value notificationsMuted = const Value.absent(), + Value status = const Value.absent(), + Value banFromFutureChannels = const Value.absent(), + Value futureChannelBanExpires = const Value.absent(), + Value deletedAt = const Value.absent(), Value createdAt = const Value.absent(), Value updatedAt = const Value.absent(), Value> deletedMessages = const Value.absent(), @@ -18501,7 +19429,12 @@ class $$MembersTableTableManager pinnedAt: pinnedAt, archivedAt: archivedAt, isModerator: isModerator, - extraData: extraData, + custom: custom, + notificationsMuted: notificationsMuted, + status: status, + banFromFutureChannels: banFromFutureChannels, + futureChannelBanExpires: futureChannelBanExpires, + deletedAt: deletedAt, createdAt: createdAt, updatedAt: updatedAt, deletedMessages: deletedMessages, @@ -18521,7 +19454,12 @@ class $$MembersTableTableManager Value pinnedAt = const Value.absent(), Value archivedAt = const Value.absent(), Value isModerator = const Value.absent(), - Value?> extraData = const Value.absent(), + Value?> custom = const Value.absent(), + Value notificationsMuted = const Value.absent(), + Value status = const Value.absent(), + Value banFromFutureChannels = const Value.absent(), + Value futureChannelBanExpires = const Value.absent(), + Value deletedAt = const Value.absent(), Value createdAt = const Value.absent(), Value updatedAt = const Value.absent(), required List deletedMessages, @@ -18539,7 +19477,12 @@ class $$MembersTableTableManager pinnedAt: pinnedAt, archivedAt: archivedAt, isModerator: isModerator, - extraData: extraData, + custom: custom, + notificationsMuted: notificationsMuted, + status: status, + banFromFutureChannels: banFromFutureChannels, + futureChannelBanExpires: futureChannelBanExpires, + deletedAt: deletedAt, createdAt: createdAt, updatedAt: updatedAt, deletedMessages: deletedMessages, diff --git a/packages/stream_chat_persistence/lib/src/entity/channels.dart b/packages/stream_chat_persistence/lib/src/entity/channels.dart index 2b20d1d425..4d1be31002 100644 --- a/packages/stream_chat_persistence/lib/src/entity/channels.dart +++ b/packages/stream_chat_persistence/lib/src/entity/channels.dart @@ -50,8 +50,32 @@ class Channels extends Table { /// The team the channel belongs to TextColumn get team => text().nullable()(); - /// Map of custom channel extraData - TextColumn get extraData => text().nullable().map(MapConverter())(); + /// Whether the channel is disabled. + BoolColumn get disabled => boolean().nullable()(); + + /// Whether the channel is hidden. + BoolColumn get hidden => boolean().nullable()(); + + /// Whether the current user muted the channel. + BoolColumn get muted => boolean().nullable()(); + + /// Whether the current user blocked the channel. + BoolColumn get blocked => boolean().nullable()(); + + /// The date the channel was last truncated. + DateTimeColumn get truncatedAt => dateTime().nullable()(); + + /// The id of the user that last truncated the channel. + TextColumn get truncatedById => text().nullable()(); + + /// Whether messages in the channel are translated automatically. + BoolColumn get autoTranslationEnabled => boolean().nullable()(); + + /// The language messages in the channel are translated into automatically. + TextColumn get autoTranslationLanguage => text().nullable()(); + + /// Custom data attached to the channel. + TextColumn get custom => text().nullable().map(MapConverter())(); @override Set get primaryKey => {cid}; diff --git a/packages/stream_chat_persistence/lib/src/entity/members.dart b/packages/stream_chat_persistence/lib/src/entity/members.dart index 72079a7e0a..672fa05310 100644 --- a/packages/stream_chat_persistence/lib/src/entity/members.dart +++ b/packages/stream_chat_persistence/lib/src/entity/members.dart @@ -45,8 +45,23 @@ class Members extends Table { /// True if the user is a moderator of the channel BoolColumn get isModerator => boolean().withDefault(const Constant(false))(); - /// Map of custom member extraData - TextColumn get extraData => text().nullable().map(MapConverter())(); + /// Custom data attached to the member. + TextColumn get custom => text().nullable().map(MapConverter())(); + + /// Whether the member muted notifications for the channel. + BoolColumn get notificationsMuted => boolean().nullable()(); + + /// The status of the membership. + TextColumn get status => text().nullable()(); + + /// Whether the member is banned from future channels. + BoolColumn get banFromFutureChannels => boolean().nullable()(); + + /// The date at which the member's ban from future channels expires. + DateTimeColumn get futureChannelBanExpires => dateTime().nullable()(); + + /// The date at which the membership was deleted. + DateTimeColumn get deletedAt => dateTime().nullable()(); /// The date of creation DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); diff --git a/packages/stream_chat_persistence/lib/src/mapper/channel_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/channel_mapper.dart index ba5c5113f4..4a277e5fa0 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/channel_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/channel_mapper.dart @@ -4,7 +4,7 @@ import '../db/drift_chat_database.dart'; /// Useful mapping functions for [ChannelEntity] extension ChannelEntityX on ChannelEntity { /// Maps a [ChannelEntity] into [ChannelModel] - ChannelModel toChannelModel({User? createdBy}) { + ChannelModel toChannelModel({User? createdBy, User? truncatedBy}) { final config = ChannelConfig.fromJson(this.config); return ChannelModel( id: id, @@ -22,13 +22,22 @@ extension ChannelEntityX on ChannelEntity { createdBy: createdBy, filterTags: filterTags, team: team, - extraData: extraData ?? {}, + custom: custom ?? {}, + disabled: disabled, + hidden: hidden, + muted: muted, + blocked: blocked, + truncatedAt: truncatedAt, + truncatedBy: truncatedBy, + autoTranslationEnabled: autoTranslationEnabled, + autoTranslationLanguage: autoTranslationLanguage, ); } /// Maps a [ChannelEntity] into [ChannelState] ChannelState toChannelState({ User? createdBy, + User? truncatedBy, List members = const [], List reads = const [], List messages = const [], @@ -38,7 +47,7 @@ extension ChannelEntityX on ChannelEntity { read: reads, messages: messages, pinnedMessages: pinnedMessages, - channel: toChannelModel(createdBy: createdBy), + channel: toChannelModel(createdBy: createdBy, truncatedBy: truncatedBy), ); } @@ -61,6 +70,14 @@ extension ChannelModelX on ChannelModel { createdById: createdBy?.id, filterTags: filterTags, team: team, - extraData: extraData, + custom: custom, + disabled: disabled, + hidden: hidden, + muted: muted, + blocked: blocked, + truncatedAt: truncatedAt, + truncatedById: truncatedBy?.id, + autoTranslationEnabled: autoTranslationEnabled, + autoTranslationLanguage: autoTranslationLanguage, ); } diff --git a/packages/stream_chat_persistence/lib/src/mapper/member_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/member_mapper.dart index 26e65bf224..c1c50531f6 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/member_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/member_mapper.dart @@ -20,7 +20,12 @@ extension MemberEntityX on MemberEntity { archivedAt: archivedAt, isModerator: isModerator, deletedMessages: deletedMessages, - extraData: extraData ?? {}, + custom: custom ?? {}, + notificationsMuted: notificationsMuted, + status: status, + banFromFutureChannels: banFromFutureChannels, + futureChannelBanExpires: futureChannelBanExpires, + deletedAt: deletedAt, ); } @@ -43,6 +48,11 @@ extension MemberX on Member { channelRole: channelRole, updatedAt: updatedAt, deletedMessages: deletedMessages, - extraData: extraData, + custom: custom, + notificationsMuted: notificationsMuted, + status: status, + banFromFutureChannels: banFromFutureChannels, + futureChannelBanExpires: futureChannelBanExpires, + deletedAt: deletedAt, ); } diff --git a/packages/stream_chat_persistence/test/src/dao/channel_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/channel_dao_test.dart index c14159dc2b..ee630fd3a6 100644 --- a/packages/stream_chat_persistence/test/src/dao/channel_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/channel_dao_test.dart @@ -24,12 +24,17 @@ void main() { expect(channel, isNull); // Saving a dummy channel + final createdBy = User(id: 'creatorId'); + final truncatedBy = User(id: 'truncatorId'); final dummyChannel = ChannelModel( id: id, type: type, cid: cid, config: ChannelConfig(), + createdBy: createdBy, + truncatedBy: truncatedBy, ); + await database.userDao.updateUsers([createdBy, truncatedBy]); await channelDao.updateChannels([dummyChannel]); // Should match the dummy channel @@ -38,6 +43,8 @@ void main() { expect(updatedChannel!.id, id); expect(updatedChannel.cid, cid); expect(updatedChannel.type, type); + expect(updatedChannel.createdBy?.id, createdBy.id); + expect(updatedChannel.truncatedBy?.id, truncatedBy.id); }); test('deleteChannelByCids', () async { diff --git a/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart index edd4245b68..83f1680954 100644 --- a/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart @@ -74,6 +74,7 @@ void main() { final cids = List.generate(count, (index) => 'testCid$index'); final users = List.generate(count, (index) => User(id: 'testId$index')); + final truncators = List.generate(count, (index) => User(id: 'truncatorId$index')); final channels = List.generate( count, (index) => ChannelModel( @@ -81,9 +82,10 @@ void main() { type: 'testType$index', cid: cids[index], createdBy: users[index], + truncatedBy: truncators[index], config: ChannelConfig(), filterTags: ['tag$index'], - extraData: {'test_custom_field': index + 3}, + custom: {'test_custom_field': index + 3}, createdAt: now, memberCount: index + 3, lastMessageAt: now.add(Duration(hours: index)), @@ -91,7 +93,7 @@ void main() { ), ).toList(growable: false); - await userDao.updateUsers(users); + await userDao.updateUsers([...users, ...truncators]); await channelDao.updateChannels(channels); await channelQueryDao.updateChannelQueries(filter, cids); @@ -126,6 +128,8 @@ void main() { expect(updatedChannel.memberCount, insertedChannel.memberCount); expect(updatedChannel.filterTags, insertedChannel.filterTags); expect(updatedChannel.team, insertedChannel.team); + expect(updatedChannel.createdBy?.id, insertedChannel.createdBy?.id); + expect(updatedChannel.truncatedBy?.id, insertedChannel.truncatedBy?.id); // Should match createdAt date expect( diff --git a/packages/stream_chat_persistence/test/src/dao/member_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/member_dao_test.dart index 46c2718d01..7c0b1d9727 100644 --- a/packages/stream_chat_persistence/test/src/dao/member_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/member_dao_test.dart @@ -35,7 +35,7 @@ void main() { inviteAcceptedAt: DateTime.now(), channelRole: 'testRole', updatedAt: DateTime.now(), - extraData: const {'extra_test_field': 'extraTestData'}, + custom: const {'extra_test_field': 'extraTestData'}, ), ); await database.userDao.updateUsers(users); @@ -75,7 +75,7 @@ void main() { fetchedMember.inviteAcceptedAt, isSameDateAs(member.inviteAcceptedAt), ); - expect(fetchedMember.extraData, member.extraData); + expect(fetchedMember.custom, member.custom); } }); @@ -164,7 +164,7 @@ void main() { fetchedMember.inviteAcceptedAt, isSameDateAs(member.inviteAcceptedAt), ); - expect(fetchedMember.extraData, member.extraData); + expect(fetchedMember.custom, member.custom); } // Modifying one of the member and also adding one new diff --git a/packages/stream_chat_persistence/test/src/mapper/channel_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/channel_mapper_test.dart index 4135607c11..f3c4af1e0e 100644 --- a/packages/stream_chat_persistence/test/src/mapper/channel_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/channel_mapper_test.dart @@ -10,6 +10,7 @@ import '../utils/date_matcher.dart'; void main() { group('ChannelEntity', () { final user = User(id: 'testUserId'); + final truncatedBy = User(id: 'truncatorId'); final entity = ChannelEntity( id: 'testId', type: 'testType', @@ -33,12 +34,20 @@ void main() { messageCount: 42, createdById: user.id, filterTags: ['tag1', 'tag2'], - extraData: {'test_extra_data': 'testData'}, + custom: {'test_extra_data': 'testData'}, team: 'testTeam', + disabled: false, + hidden: true, + muted: true, + blocked: false, + truncatedAt: DateTime.now(), + truncatedById: truncatedBy.id, + autoTranslationEnabled: true, + autoTranslationLanguage: 'fr', ); test('toChannelModel should map entity into ChannelModel', () { - final channelModel = entity.toChannelModel(createdBy: user); + final channelModel = entity.toChannelModel(createdBy: user, truncatedBy: truncatedBy); expect(channelModel, isA()); expect(channelModel.id, entity.id); expect(channelModel.ownCapabilities, entity.ownCapabilities); @@ -57,8 +66,16 @@ void main() { expect(channelModel.deletedAt, isSameDateAs(entity.deletedAt)); expect(channelModel.createdBy!.id, entity.createdById); expect(channelModel.filterTags, entity.filterTags); - expect(channelModel.extraData, entity.extraData); + expect(channelModel.custom, entity.custom); expect(channelModel.team, entity.team); + expect(channelModel.disabled, entity.disabled); + expect(channelModel.hidden, entity.hidden); + expect(channelModel.muted, entity.muted); + expect(channelModel.blocked, entity.blocked); + expect(channelModel.truncatedAt, isSameDateAs(entity.truncatedAt)); + expect(channelModel.truncatedBy!.id, entity.truncatedById); + expect(channelModel.autoTranslationEnabled, entity.autoTranslationEnabled); + expect(channelModel.autoTranslationLanguage, entity.autoTranslationLanguage); }); test('toChannelState should map entity into ChannelState ', () { @@ -75,6 +92,7 @@ void main() { final channelState = entity.toChannelState( createdBy: user, + truncatedBy: truncatedBy, members: members, reads: reads, messages: messages, @@ -104,8 +122,16 @@ void main() { expect(channelModel.lastMessageAt, isSameDateAs(entity.lastMessageAt)); expect(channelModel.deletedAt, isSameDateAs(entity.deletedAt)); expect(channelModel.filterTags, entity.filterTags); - expect(channelModel.extraData, entity.extraData); + expect(channelModel.custom, entity.custom); expect(channelModel.createdBy!.id, entity.createdById); + expect(channelModel.disabled, entity.disabled); + expect(channelModel.hidden, entity.hidden); + expect(channelModel.muted, entity.muted); + expect(channelModel.blocked, entity.blocked); + expect(channelModel.truncatedAt, isSameDateAs(entity.truncatedAt)); + expect(channelModel.truncatedBy!.id, entity.truncatedById); + expect(channelModel.autoTranslationEnabled, entity.autoTranslationEnabled); + expect(channelModel.autoTranslationLanguage, entity.autoTranslationLanguage); }); }); @@ -134,8 +160,16 @@ void main() { messageCount: 75, createdBy: createdBy, filterTags: ['tag1', 'tag2'], - extraData: {'test_extra_data': 'testData'}, + custom: {'test_extra_data': 'testData'}, team: 'testTeam', + disabled: false, + hidden: true, + muted: true, + blocked: false, + truncatedAt: DateTime.now(), + truncatedBy: User(id: 'truncatorId'), + autoTranslationEnabled: true, + autoTranslationLanguage: 'fr', ); final channelEntity = model.toEntity(); @@ -164,8 +198,16 @@ void main() { expect(channelEntity.lastMessageAt, isSameDateAs(model.lastMessageAt)); expect(channelEntity.deletedAt, isSameDateAs(model.deletedAt)); expect(channelEntity.filterTags, model.filterTags); - expect(channelEntity.extraData, model.extraData); + expect(channelEntity.custom, model.custom); expect(channelEntity.createdById, model.createdBy!.id); expect(channelEntity.team, model.team); + expect(channelEntity.disabled, model.disabled); + expect(channelEntity.hidden, model.hidden); + expect(channelEntity.muted, model.muted); + expect(channelEntity.blocked, model.blocked); + expect(channelEntity.truncatedAt, isSameDateAs(model.truncatedAt)); + expect(channelEntity.truncatedById, model.truncatedBy!.id); + expect(channelEntity.autoTranslationEnabled, model.autoTranslationEnabled); + expect(channelEntity.autoTranslationLanguage, model.autoTranslationLanguage); }); } diff --git a/packages/stream_chat_persistence/test/src/mapper/member_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/member_mapper_test.dart index 4a0695ba3e..a121bfcec4 100644 --- a/packages/stream_chat_persistence/test/src/mapper/member_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/member_mapper_test.dart @@ -25,8 +25,13 @@ void main() { archivedAt: DateTime.now(), isModerator: math.Random().nextBool(), deletedMessages: ['msg1', 'msg2', 'msg3'], - extraData: {'test_extra_data': 'testData'}, + custom: {'test_extra_data': 'testData'}, banExpires: DateTime.now(), + notificationsMuted: true, + status: 'member', + banFromFutureChannels: true, + futureChannelBanExpires: DateTime.now(), + deletedAt: DateTime.now(), ); final member = entity.toMember(user: user); expect(member, isA()); @@ -43,8 +48,13 @@ void main() { expect(member.archivedAt, isSameDateAs(entity.archivedAt)); expect(member.isModerator, entity.isModerator); expect(member.deletedMessages, entity.deletedMessages); - expect(member.extraData, entity.extraData); + expect(member.custom, entity.custom); expect(member.banExpires, isSameDateAs(entity.banExpires)); + expect(member.notificationsMuted, entity.notificationsMuted); + expect(member.status, entity.status); + expect(member.banFromFutureChannels, entity.banFromFutureChannels); + expect(member.futureChannelBanExpires, isSameDateAs(entity.futureChannelBanExpires)); + expect(member.deletedAt, isSameDateAs(entity.deletedAt)); }); test('toEntity show map member into MemberEntity', () { @@ -64,8 +74,13 @@ void main() { archivedAt: DateTime.now(), isModerator: math.Random().nextBool(), deletedMessages: const ['msg1', 'msg2', 'msg3'], - extraData: const {'test_extra_data': 'testData'}, + custom: const {'test_extra_data': 'testData'}, banExpires: DateTime.now(), + notificationsMuted: true, + status: 'member', + banFromFutureChannels: true, + futureChannelBanExpires: DateTime.now(), + deletedAt: DateTime.now(), ); final entity = member.toEntity(cid: cid); expect(entity, isA()); @@ -83,7 +98,12 @@ void main() { expect(entity.archivedAt, isSameDateAs(member.archivedAt)); expect(entity.isModerator, member.isModerator); expect(entity.deletedMessages, member.deletedMessages); - expect(entity.extraData, member.extraData); + expect(entity.custom, member.custom); expect(entity.banExpires, isSameDateAs(member.banExpires)); + expect(entity.notificationsMuted, member.notificationsMuted); + expect(entity.status, member.status); + expect(entity.banFromFutureChannels, member.banFromFutureChannels); + expect(entity.futureChannelBanExpires, isSameDateAs(member.futureChannelBanExpires)); + expect(entity.deletedAt, isSameDateAs(member.deletedAt)); }); } diff --git a/sample_app/benchmark/main_benchmark.dart b/sample_app/benchmark/main_benchmark.dart index e66e37700d..5f4e7d1dfc 100644 --- a/sample_app/benchmark/main_benchmark.dart +++ b/sample_app/benchmark/main_benchmark.dart @@ -64,7 +64,7 @@ class _BenchmarkLauncherState extends State<_BenchmarkLauncher> { final channel = client.channel( _benchmarkChannelType, id: _benchmarkChannelId, - extraData: const {'name': 'Perf benchmark'}, + custom: const {'name': 'Perf benchmark'}, ); await channel.watch( messagesPagination: const PaginationParams(limit: 25), diff --git a/sample_app/lib/pages/group_chat_details_screen.dart b/sample_app/lib/pages/group_chat_details_screen.dart index 255660f044..33384bd172 100644 --- a/sample_app/lib/pages/group_chat_details_screen.dart +++ b/sample_app/lib/pages/group_chat_details_screen.dart @@ -65,7 +65,7 @@ class _GroupChatDetailsScreenState extends State { final channel = client.channel( 'messaging', id: const Uuid().v4(), - extraData: { + custom: { 'members': [ client.state.currentUser!.id, ...widget.groupChatState.users.map((e) => e.id), diff --git a/sample_app/lib/pages/new_chat_screen.dart b/sample_app/lib/pages/new_chat_screen.dart index 4281dd3901..e771e7a2eb 100644 --- a/sample_app/lib/pages/new_chat_screen.dart +++ b/sample_app/lib/pages/new_chat_screen.dart @@ -106,7 +106,7 @@ class _NewChatScreenState extends State { } else { channel = chatState.client.channel( 'messaging', - extraData: { + custom: { 'members': [ ..._selectedUsers.map((e) => e.id), chatState.currentUser!.id, diff --git a/sample_app/lib/widgets/all_members_sheet.dart b/sample_app/lib/widgets/all_members_sheet.dart index 36f73dabe4..e9dc7cba23 100644 --- a/sample_app/lib/widgets/all_members_sheet.dart +++ b/sample_app/lib/widgets/all_members_sheet.dart @@ -426,7 +426,7 @@ Future _openDirectChannel(BuildContext context, User user) async { } else { channel = chat.client.channel( 'messaging', - extraData: { + custom: { 'members': [currentUser.id, user.id], }, ); diff --git a/sample_app/lib/widgets/edit_group_sheet.dart b/sample_app/lib/widgets/edit_group_sheet.dart index f18f2c8662..90a01d3c13 100644 --- a/sample_app/lib/widgets/edit_group_sheet.dart +++ b/sample_app/lib/widgets/edit_group_sheet.dart @@ -74,7 +74,7 @@ class _EditGroupSheetState extends State { final List _trackedUploads = []; String get _name => _nameController.text.trim(); - String get _initialName => (_channel.extraData['name'] as String?) ?? ''; + String get _initialName => (_channel.custom['name'] as String?) ?? ''; bool get _isDirty { if (_name != _initialName) return true; From 92675e608f7f6d8ad45674e564b7b44682743662 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Wed, 14 Oct 2026 19:30:13 +0200 Subject: [PATCH 27/32] refactor(llc): apply the polls review suggestions Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 3 +- STYLE_GUIDE.md | 14 +- migrations/v11-migration.md | 4 +- packages/stream_chat/CHANGELOG.md | 2 +- .../lib/src/client/channel/channel.dart | 30 +- .../stream_chat/lib/src/client/client.dart | 58 +- .../models/converters/v1_json_converters.dart | 13 +- .../stream_chat/lib/src/core/models/poll.dart | 12 +- .../lib/src/core/models/poll_option.dart | 3 +- .../lib/src/core/models/poll_vote.dart | 6 +- .../src/core/models/voting_visibility.dart | 11 +- .../src/repository/mapper/filter_mapper.dart | 7 + .../src/repository/mapper/polls_mapper.dart | 9 +- .../src/repository/mapper/sort_mapper.dart | 9 +- .../lib/src/repository/polls_repository.dart | 32 +- .../test/src/client/client_polls_test.dart | 642 ++++++++++++++++++ .../test/src/client/client_test.dart | 624 ----------------- .../models/converters/poll_v1_json_test.dart | 37 +- .../src/core/models/poll_option_test.dart | 2 +- .../test/src/core/models/poll_test.dart | 6 +- .../lib/src/stream_poll_controller.dart | 5 +- .../src/stream_poll_vote_list_controller.dart | 4 +- ...stream_poll_vote_list_controller_test.dart | 4 +- packages/stream_chat_persistence/CHANGELOG.md | 1 - .../voting_visibility_converter.dart | 2 +- 25 files changed, 822 insertions(+), 718 deletions(-) create mode 100644 packages/stream_chat/lib/src/repository/mapper/filter_mapper.dart create mode 100644 packages/stream_chat/test/src/client/client_polls_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index 89a1d5d91f..9f82fac6d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,7 +120,8 @@ Optional local persistence using Drift (SQLite). Implements `ChatPersistenceClie ## Code Style -- Line length: **120 characters** (configured in `analysis_options.yaml`) +- Line length: **120 characters** for code (configured in `analysis_options.yaml`); wrap + docs and comments at **80** - Imports: inside a package's own `lib/`, use relative imports (`prefer_relative_imports`); use `package:` imports for anything from another package - All public APIs **must** have doc comments (`public_member_api_docs`) — see diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 628dc64f2e..5a9ceb0f9c 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -60,8 +60,8 @@ document; the section link is provided. **Code** -- Line width: **120 characters** (configured in `analysis_options.yaml`). Comments and - docs follow the same limit. +- Line width: **120 characters** for code (configured in `analysis_options.yaml`). Wrap + comments and docs at **80**. - Single quotes, relative imports within a package's own `lib/`, trailing commas preserved, `const` wherever possible, `final` for locals that aren't reassigned. These are enforced by the linter — do not disable them. @@ -1377,10 +1377,12 @@ melos run lint:all # analyze + format, run this before opening a PR checked with the same settings. Do not invoke `dart format` on a single file with ad-hoc flags — the workspace-level config (line length, trailing commas) applies uniformly. -Line length is **120 characters** for both code and comments, configured in -`analysis_options.yaml`. Trailing commas are preserved rather than automatically added, -so include a trailing comma anywhere you want the formatter to break the argument list -onto multiple lines. +Line length is **120 characters** for code, configured in `analysis_options.yaml`. +Comments and docs wrap at **80**: prose that wide is easier to read in IDE hovers and +side-by-side diffs, and the formatter never reflows it, so wrap it by hand. + +Trailing commas are preserved rather than automatically added, so include a trailing +comma anywhere you want the formatter to break the argument list onto multiple lines. ### Constructors come first diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index bc70cd623d..f691a00331 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -229,7 +229,7 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `Poll.custom` / `PollOption.custom` entry named like one of the model's own fields | the same entry, as custom data | `retyped` | Sent and returned as custom data; v10's request body let it overwrite the field | | `StreamPollController.addOption(extraData:)` / `.updateOption(extraData:)` (`stream_chat_flutter_core`) | `custom:` | `renamed` | Follows `PollOption.custom` | | `Poll.topLevelFields` / `PollOption.topLevelFields` | — | `removed` | They listed the JSON keys of models that no longer have JSON | -| `VotingVisibility` (enum) | `VotingVisibility` (extension type over `String`) | `retyped` | `VotingVisibility.public` and `.anonymous` are unchanged; a `switch` needs a default arm, and `.name` / `.values` are gone — read `.rawType` | +| `VotingVisibility` (enum) | `VotingVisibility` (extension type over `String`) | `retyped` | `VotingVisibility.public` and `.anonymous` are unchanged; a `switch` needs a default arm, and `.name` / `.values` are gone — it is a `String`, so use it directly | | `Poll.fromJson` / `.toJson`, `PollOption.fromJson` / `.toJson`, `PollVote.fromJson` / `.toJson` | — | `removed` | The models are plain classes; construct them directly. `Message.poll` and the poll events still decode from the same keys | | — | `PollOption.fromData` / `.toData` | `added` | Read and write only the format the offline database stores; not a codec for API payloads | | `CreatePollResponse()..poll = …` and the other response setters | `PollResponse(duration: …, poll: …)` | `retyped` | The responses are plain classes with a const constructor and final fields | @@ -813,7 +813,7 @@ await channel.queryPollVotes(pollId, limit: 10, next: cursor); **`VotingVisibility` is an extension type over its wire string.** `VotingVisibility.public` and `VotingVisibility.anonymous` read the same, and a visibility the SDK does not name is kept rather than rejected. -A `switch` over it needs a default arm, and `.name` / `.values` are gone — read `.rawType`. +A `switch` over it needs a default arm, and `.name` / `.values` are gone — it is a `String`, so use it directly. **`Poll`, `PollOption`, `PollVote` and the responses no longer decode JSON.** Build them with their constructors — `PollResponse(duration: '0ms', poll: poll)` where v10 wrote `CreatePollResponse()..poll = poll`. `Message.poll` and diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 13d276ac60..a31e8f652a 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -79,7 +79,7 @@ - `StreamChatApi.general.getAppSettings` is removed; call `StreamChatClient.getAppSettings` instead. - `StreamChatApi.guest` and `ConnectGuestUserResponse` are removed; call `StreamChatClient.connectGuestUser` instead. - The poll calls on `StreamChatClient` and `Channel` return a `Result` instead of throwing. -- `CreatePollResponse`, `GetPollResponse` and `UpdatePollResponse` are replaced by `PollResponse`; `CreatePollOptionResponse`, `GetPollOptionResponse` and `UpdatePollOptionResponse` by `PollOptionResponse`; and `CastPollVoteResponse` and `RemovePollVoteResponse` by `PollVoteResponse`. +- The eight poll response types are replaced by `PollResponse`, `PollOptionResponse` and `PollVoteResponse`. - `queryPolls` and `queryPollVotes` take `limit` (10 when omitted) and `next` instead of `PaginationParams`. - `Channel.castPollVote` and `removePollVote` return a failure instead of throwing an `ArgumentError` for an option or vote without an id. - `VotingVisibility` is an extension type over its wire string instead of an enum. diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index 00aea3c934..62c7ac30dc 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -1433,8 +1433,9 @@ class Channel { /// /// The message carries [messageText] as its text, if given. /// - /// If the poll cannot be created, no message is sent and the failure is returned. If the message cannot be sent, the - /// poll stays created and the failure is returned. + /// If the poll cannot be created, no message is sent and the failure is + /// returned. If the message cannot be sent, the poll stays created and the + /// failure is returned. Future> sendPoll( Poll poll, { String? messageText, @@ -1444,6 +1445,7 @@ class Channel { return created.flatMapAsync((response) => _sendPollMessage(response.poll, messageText)); } + // TODO(openapi-migration): remove once sendMessage returns a Result Future> _sendPollMessage(Poll poll, String? messageText) async { try { final message = Message(text: messageText, poll: poll, pollId: poll.id); @@ -1458,8 +1460,11 @@ class Channel { /// Updates [poll] to match the settings and options it carries. /// - /// [Poll.options] becomes the poll's full list of options, each matched by its id, so an existing option missing - /// from it is deleted. New options are added through [createPollOption]. + /// [Poll.options] becomes the poll's full list of options, each matched by + /// its id, so an existing option missing from it is deleted. New options are + /// added through [createPollOption]. + /// + /// [Poll.enforceUniqueVote] is left as the poll was created. Future> updatePoll(Poll poll) { _checkInitialized(); return _pollLock.synchronized(() => _client.updatePoll(poll)); @@ -1494,8 +1499,9 @@ class Channel { /// Votes for [option] of [poll], which was sent in [message]. /// - /// If the poll allows only one vote per user, the vote replaces the previous one. The [option] must carry its id; - /// if it does not, the call returns a failure without voting. + /// If the poll allows only one vote per user, the vote replaces the previous + /// one. The [option] must carry its id; if it does not, the call returns a + /// failure without voting. Future> castPollVote( Message message, Poll poll, @@ -1539,7 +1545,8 @@ class Channel { /// Removes [vote], a vote or answer on [poll], which was sent in [message]. /// - /// The [vote] must carry its id; if it does not, the call returns a failure without removing anything. + /// The [vote] must carry its id; if it does not, the call returns a failure + /// without removing anything. Future> removePollVote( Message message, Poll poll, @@ -1561,10 +1568,13 @@ class Channel { ); } - /// Fetches one page of the votes and answers matching [filter] on the poll with the id [pollId], ordered by [sort]. + /// Fetches one page of the votes and answers matching [filter] on the poll + /// with the id [pollId], ordered by [sort]. /// - /// Up to [limit] votes are returned. The next page is fetched by passing the `next` cursor of a response as [next], - /// and the previous page by passing its `prev` cursor as [prev]. At most one of [next] and [prev] may be given. + /// Up to [limit] votes are returned. The next page is fetched by passing a + /// response's [QueryPollVotesResponse.next] as [next], and the previous page + /// by passing its [QueryPollVotesResponse.prev] as [prev]. At most one of + /// [next] and [prev] may be given. Future> queryPollVotes( String pollId, { PollVoteFilter? filter, diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index f79c30b055..ab60fc33b7 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -1644,9 +1644,11 @@ class StreamChatClient { /// Creates [poll] with its settings and options. /// - /// Any ids already set on the options are ignored; every option gets a new one, which the returned poll carries. + /// Any ids already set on the options are ignored; every option gets a new + /// one, which the returned poll carries. /// - /// A poll is shown to the members of a channel once it is sent in a message; [Channel.sendPoll] does both. + /// A poll is shown to the members of a channel once it is sent in a message; + /// [Channel.sendPoll] does both. Future> createPoll(Poll poll) => _pollsRepository.createPoll(poll); /// Fetches the poll with the id [pollId]. @@ -1654,14 +1656,23 @@ class StreamChatClient { /// Updates [poll] to match the settings and options it carries. /// - /// [Poll.options] becomes the poll's full list of options, each matched by its id, so an existing option missing - /// from it is deleted. New options are added through [createPollOption]. + /// [Poll.options] becomes the poll's full list of options, each matched by + /// its id, so an existing option missing from it is deleted. New options are + /// added through [createPollOption]. + /// + /// [Poll.enforceUniqueVote] is left as the poll was created. Future> updatePoll(Poll poll) => _pollsRepository.updatePoll(poll); - /// Changes some of the settings of the poll with the id [pollId], leaving the others as they are. + /// Changes some of the settings of the poll with the id [pollId], leaving the + /// others as they are. + /// + /// Only [Poll.name], [Poll.description], [Poll.votingVisibility], + /// [Poll.maxVotesAllowed], [Poll.allowAnswers] and [Poll.isClosed] can be + /// changed through [set]. Any other key, including those of the other + /// settings, is stored in the poll's custom data. /// - /// The fields in [set] take their new values and the fields named in [unset] are cleared. A key that is not a - /// setting of the poll is stored in its custom data. + /// The keys named in [unset] are removed from the custom data only. To remove + /// the vote limit, set it to null instead. Future> partialUpdatePoll( String pollId, { Map? set, @@ -1671,7 +1682,8 @@ class StreamChatClient { /// Deletes the poll with the id [pollId]. Future> deletePoll(String pollId) => _pollsRepository.deletePoll(pollId); - /// Closes the poll with the id [pollId], so it accepts no more votes or answers. + /// Closes the poll with the id [pollId], so it accepts no more votes or + /// answers. Future> closePoll(String pollId) => partialUpdatePoll(pollId, set: {'is_closed': true}); /// Adds [option] to the poll with the id [pollId]. @@ -1688,7 +1700,8 @@ class StreamChatClient { String optionId, ) => _pollsRepository.getPollOption(pollId, optionId); - /// Replaces the text and custom data of [option], an existing option of the poll with the id [pollId]. + /// Replaces the text and custom data of [option], an existing option of the + /// poll with the id [pollId]. /// /// The [option] is found by its id. Future> updatePollOption( @@ -1702,16 +1715,19 @@ class StreamChatClient { String optionId, ) => _pollsRepository.deletePollOption(pollId, optionId); - /// Votes for an option of a poll sent in a message, identified by [optionId], [pollId] and [messageId]. + /// Votes for an option of a poll sent in a message, identified by [optionId], + /// [pollId] and [messageId]. /// - /// If the poll allows only one vote per user, the vote replaces the previous one. + /// If the poll allows only one vote per user, the vote replaces the previous + /// one. Future> castPollVote( String messageId, String pollId, { required String optionId, }) => _pollsRepository.castPollVote(messageId, pollId, optionId: optionId); - /// Adds [answerText] as an answer to a poll sent in a message, identified by [pollId] and [messageId]. + /// Adds [answerText] as an answer to a poll sent in a message, identified by + /// [pollId] and [messageId]. /// /// Each user has one answer per poll; a new one replaces the previous one. Future> addPollAnswer( @@ -1720,7 +1736,8 @@ class StreamChatClient { required String answerText, }) => _pollsRepository.addPollAnswer(messageId, pollId, answerText: answerText); - /// Removes a vote or answer from a poll sent in a message, identified by [voteId], [pollId] and [messageId]. + /// Removes a vote or answer from a poll sent in a message, identified by + /// [voteId], [pollId] and [messageId]. Future> removePollVote( String messageId, String pollId, @@ -1729,8 +1746,10 @@ class StreamChatClient { /// Fetches one page of the polls matching [filter], ordered by [sort]. /// - /// Up to [limit] polls are returned. The next page is fetched by passing the `next` cursor of a response as [next], - /// and the previous page by passing its `prev` cursor as [prev]. At most one of [next] and [prev] may be given. + /// Up to [limit] polls are returned. The next page is fetched by passing a + /// response's [QueryPollsResponse.next] as [next], and the previous page by + /// passing its [QueryPollsResponse.prev] as [prev]. At most one of [next] and + /// [prev] may be given. Future> queryPolls({ PollFilter? filter, List? sort, @@ -1739,10 +1758,13 @@ class StreamChatClient { String? prev, }) => _pollsRepository.queryPolls(filter: filter, sort: sort, limit: limit, next: next, prev: prev); - /// Fetches one page of the votes and answers matching [filter] on the poll with the id [pollId], ordered by [sort]. + /// Fetches one page of the votes and answers matching [filter] on the poll + /// with the id [pollId], ordered by [sort]. /// - /// Up to [limit] votes are returned. The next page is fetched by passing the `next` cursor of a response as [next], - /// and the previous page by passing its `prev` cursor as [prev]. At most one of [next] and [prev] may be given. + /// Up to [limit] votes are returned. The next page is fetched by passing a + /// response's [QueryPollVotesResponse.next] as [next], and the previous page + /// by passing its [QueryPollVotesResponse.prev] as [prev]. At most one of + /// [next] and [prev] may be given. Future> queryPollVotes( String pollId, { PollVoteFilter? filter, diff --git a/packages/stream_chat/lib/src/core/models/converters/v1_json_converters.dart b/packages/stream_chat/lib/src/core/models/converters/v1_json_converters.dart index d0bacf32c3..5fce137bd7 100644 --- a/packages/stream_chat/lib/src/core/models/converters/v1_json_converters.dart +++ b/packages/stream_chat/lib/src/core/models/converters/v1_json_converters.dart @@ -75,7 +75,7 @@ UserGroupMember _userGroupMemberFromV1Json(Map json) => UserGro /// Converts a [Poll] to and from its v1 keys. /// /// Custom data sits at the root of the v1 map, beside the known keys. -// TODO(openapi-migration): remove once WebSocket events and messages decode v2 payloads +// TODO(openapi-migration): remove once events and messages decode v2 payloads @internal class PollV1JsonConverter implements JsonConverter> { /// Creates a new [PollV1JsonConverter]. @@ -124,8 +124,9 @@ class PollV1JsonConverter implements JsonConverter> { custom: _customData(json, _pollV1Keys), ); - // Writes the keys the poll settings use, with custom data at the root; the vote summary, the translations and a - // custom entry named like one of the keys are not written. + // Writes the keys the poll settings use, with custom data at the root; the + // vote summary, the translations and a custom entry named like one of the + // keys are not written. @override Map toJson(Poll poll) => { ..._customDataAtRoot(poll.custom, _pollV1Keys), @@ -133,7 +134,7 @@ class PollV1JsonConverter implements JsonConverter> { 'name': poll.name, 'description': poll.description, 'options': poll.options.map(_pollOptionToV1Json).toList(), - 'voting_visibility': poll.votingVisibility.rawType, + 'voting_visibility': poll.votingVisibility, 'enforce_unique_vote': poll.enforceUniqueVote, 'max_votes_allowed': poll.maxVotesAllowed, 'allow_user_suggested_options': poll.allowUserSuggestedOptions, @@ -236,7 +237,7 @@ const _pollOptionV1Keys = [ Map _customData(Map json, List knownKeys) => Serializer.moveToExtraDataFromRoot(json, knownKeys)['extra_data'] as Map; -// The custom data a v1 map carries at its root: an entry named like one of the known keys would overwrite the key, -// so it is left out. +// The custom data a v1 map carries at its root: an entry named like one of the +// known keys would overwrite the key, so it is left out. Map _customDataAtRoot(Map custom, List knownKeys) => {...custom}..removeWhere((key, _) => knownKeys.contains(key)); diff --git a/packages/stream_chat/lib/src/core/models/poll.dart b/packages/stream_chat/lib/src/core/models/poll.dart index 5d15534f49..bc9105107c 100644 --- a/packages/stream_chat/lib/src/core/models/poll.dart +++ b/packages/stream_chat/lib/src/core/models/poll.dart @@ -78,7 +78,7 @@ class Poll with _$Poll { @override final List options; - /// Who can see which option each user voted for. + /// The rule for who can see which option each user voted for. /// /// Defaults to [VotingVisibility.public]. @override @@ -92,6 +92,8 @@ class Poll with _$Poll { /// The maximum number of options each user may vote for, or null for no /// limit. + /// + /// Applies only when [enforceUniqueVote] is false. @override final int? maxVotesAllowed; @@ -101,7 +103,7 @@ class Poll with _$Poll { @override final bool allowUserSuggestedOptions; - /// Whether users may leave a free-form answer. + /// Whether users may add a free-form answer. /// /// Defaults to false. @override @@ -111,7 +113,7 @@ class Poll with _$Poll { @override final bool isClosed; - /// The total number of answers left on this poll. + /// The total number of answers added to this poll. @override final int answersCount; @@ -131,7 +133,7 @@ class Poll with _$Poll { @override late final List latestVotes = [...latestVotesByOption.values.flattened]; - /// The most recent answers left on this poll. + /// The most recent answers added to this poll. @override final List latestAnswers; @@ -287,7 +289,7 @@ class PollFilterField extends FilterField { /// **Supported operators:** `$eq` static final votingVisibility = PollFilterField( 'voting_visibility', - (it) => it.votingVisibility.rawType, + (it) => it.votingVisibility, ); /// Filters polls by their creation date. diff --git a/packages/stream_chat/lib/src/core/models/poll_option.dart b/packages/stream_chat/lib/src/core/models/poll_option.dart index 2d56d047b4..1914207e11 100644 --- a/packages/stream_chat/lib/src/core/models/poll_option.dart +++ b/packages/stream_chat/lib/src/core/models/poll_option.dart @@ -21,7 +21,8 @@ class PollOption with _$PollOption { this.custom = const {}, }); - /// Creates a [PollOption] from the offline-database format written by [toData]. + /// Creates a [PollOption] from the offline-database format written by + /// [toData]. /// /// It is not a codec for API payloads. factory PollOption.fromData(Map json) => _$PollOptionFromJson(json); diff --git a/packages/stream_chat/lib/src/core/models/poll_vote.dart b/packages/stream_chat/lib/src/core/models/poll_vote.dart index 9c1f62a886..3bc3f2fe45 100644 --- a/packages/stream_chat/lib/src/core/models/poll_vote.dart +++ b/packages/stream_chat/lib/src/core/models/poll_vote.dart @@ -42,7 +42,7 @@ class PollVote with _$PollVote { /// The unique identifier of the option this vote selects. /// - /// Empty for an answer, which selects no option. + /// An empty string for an answer, which selects no option. @override final String? optionId; @@ -68,13 +68,13 @@ class PollVote with _$PollVote { /// The unique identifier of the user who cast this vote. /// - /// Null on someone else's vote in an anonymous poll. + /// May be null in an anonymous poll, even on the current user's own vote. @override final String? userId; /// The user who cast this vote. /// - /// Null on someone else's vote in an anonymous poll. + /// May be null in an anonymous poll, even on the current user's own vote. @override final User? user; diff --git a/packages/stream_chat/lib/src/core/models/voting_visibility.dart b/packages/stream_chat/lib/src/core/models/voting_visibility.dart index bea1b6ec21..fec15626b0 100644 --- a/packages/stream_chat/lib/src/core/models/voting_visibility.dart +++ b/packages/stream_chat/lib/src/core/models/voting_visibility.dart @@ -1,11 +1,12 @@ -/// Who can see which option each user voted for in a poll. +/// The rule for who can see which option each user voted for in a poll. /// /// A visibility without a constant here can still be named by wrapping its -/// value: `VotingVisibility('public')`. -extension type const VotingVisibility(String rawType) implements String { - /// Votes are counted, but who cast them is hidden from other users. +/// value: `VotingVisibility('members_only')`. +extension type const VotingVisibility(String _) implements String { + /// A visibility where votes are counted, but who cast them is hidden from + /// other users. static const anonymous = VotingVisibility('anonymous'); - /// Everyone can see who voted for which option. + /// A visibility where everyone can see who voted for which option. static const public = VotingVisibility('public'); } diff --git a/packages/stream_chat/lib/src/repository/mapper/filter_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/filter_mapper.dart new file mode 100644 index 0000000000..5961fc7150 --- /dev/null +++ b/packages/stream_chat/lib/src/repository/mapper/filter_mapper.dart @@ -0,0 +1,7 @@ +import 'package:stream_core/stream_core.dart' show Filter; + +/// Maps a [Filter] to the filter of a generated request. +extension FilterRequestMapper on Filter { + /// Converts this filter into the map a generated request takes. + Map toRequest() => toJson(); +} diff --git a/packages/stream_chat/lib/src/repository/mapper/polls_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/polls_mapper.dart index b5f6c06059..ff36b3be76 100644 --- a/packages/stream_chat/lib/src/repository/mapper/polls_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/polls_mapper.dart @@ -116,7 +116,8 @@ extension PollVotesResponseMapper on api.PollVotesResponse { extension PollRequestMapper on Poll { /// Converts this poll into an [api.CreatePollRequest]. /// - /// The ids of the [options] are left out: every option gets a new one when the poll is created. + /// The ids of the [options] are left out: every option gets a new one when + /// the poll is created. api.CreatePollRequest toCreatePollRequest() => api.CreatePollRequest( id: id, name: name, @@ -149,10 +150,12 @@ extension PollRequestMapper on Poll { /// Maps a [PollOption] to the generated requests that create and update it. extension PollOptionRequestMapper on PollOption { - /// Converts this option into an [api.PollOptionInput], an option of a poll being created. + /// Converts this option into an [api.PollOptionInput], an option of a poll + /// being created. api.PollOptionInput toPollOptionInput() => api.PollOptionInput(text: text, custom: custom); - /// Converts this option into an [api.PollOptionRequest], an option of a poll being updated. + /// Converts this option into an [api.PollOptionRequest], an option of a poll + /// being updated. /// /// An option without an [id] is sent with an empty one. api.PollOptionRequest toPollOptionRequest() => api.PollOptionRequest(id: id ?? '', text: text, custom: custom); diff --git a/packages/stream_chat/lib/src/repository/mapper/sort_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/sort_mapper.dart index e821224f31..86d0fdd050 100644 --- a/packages/stream_chat/lib/src/repository/mapper/sort_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/sort_mapper.dart @@ -3,7 +3,12 @@ import 'package:stream_core/stream_core.dart' show Sort; import '../../../open_api/api.dart' as api; /// Maps a [Sort] to the generated [api.SortParamRequest]. -extension SortRequestMapper on Sort { +extension SortRequestMapper on Sort { /// Converts this sort into an [api.SortParamRequest]. - api.SortParamRequest toRequest() => api.SortParamRequest(field: field.remote, direction: direction.value); + api.SortParamRequest toRequest() { + return api.SortParamRequest( + field: field.remote, + direction: direction.value, + ); + } } diff --git a/packages/stream_chat/lib/src/repository/polls_repository.dart b/packages/stream_chat/lib/src/repository/polls_repository.dart index 077ebbf499..5ae1780946 100644 --- a/packages/stream_chat/lib/src/repository/polls_repository.dart +++ b/packages/stream_chat/lib/src/repository/polls_repository.dart @@ -9,6 +9,7 @@ import '../core/models/response/poll_response.dart'; 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 'mapper/filter_mapper.dart'; import 'mapper/polls_mapper.dart'; import 'mapper/result_mapper.dart'; import 'mapper/sort_mapper.dart'; @@ -36,14 +37,16 @@ class PollsRepository { /// Updates [poll] to match the settings and options it carries. /// - /// [Poll.options] becomes the poll's full list of options, each matched by its id. + /// [Poll.options] becomes the poll's full list of options, each matched by + /// its id. Future> updatePoll(Poll poll) async { final result = await _api.updatePoll(updatePollRequest: poll.toUpdatePollRequest()); return result.map((response) => response.toModel()); } - /// Sets the fields in [set] and removes the fields named in [unset] on the poll with the id [pollId]. + /// Sets the fields in [set] and removes the fields named in [unset] on the + /// poll with the id [pollId]. Future> partialUpdatePoll( String pollId, { Map? set, @@ -81,7 +84,8 @@ class PollsRepository { return result.map((response) => response.toModel()); } - /// Replaces the text and custom data of [option] in the poll with the id [pollId]. + /// Replaces the text and custom data of [option] in the poll with the id + /// [pollId]. /// /// The [option] is matched by its id. Future> updatePollOption(String pollId, PollOption option) async { @@ -100,14 +104,16 @@ class PollsRepository { return result.ignoreValue(); } - /// Casts a vote for an option of a poll sent in a message, identified by [optionId], [pollId] and [messageId]. + /// Casts a vote for an option of a poll sent in a message, identified by + /// [optionId], [pollId] and [messageId]. Future> castPollVote( String messageId, String pollId, { required String optionId, }) => _castPollVote(messageId, pollId, api.VoteData(optionId: optionId)); - /// Adds [answerText] as an answer to a poll sent in a message, identified by [pollId] and [messageId]. + /// Adds [answerText] as an answer to a poll sent in a message, identified by + /// [pollId] and [messageId]. Future> addPollAnswer( String messageId, String pollId, { @@ -124,7 +130,8 @@ class PollsRepository { return result.map((response) => response.toModel()); } - /// Removes a vote or answer from a poll sent in a message, identified by [voteId], [pollId] and [messageId]. + /// Removes a vote or answer from a poll sent in a message, identified by + /// [voteId], [pollId] and [messageId]. Future> removePollVote(String messageId, String pollId, String voteId) async { final result = await _api.deletePollVote(messageId: messageId, pollId: pollId, voteId: voteId); @@ -133,7 +140,8 @@ class PollsRepository { /// Fetches one page of the polls matching [filter], ordered by [sort]. /// - /// [next] and [prev] are the cursors a previous page returned; at most one of them may be given. + /// [next] and [prev] are the cursors a previous page returned; at most one of + /// them may be given. Future> queryPolls({ PollFilter? filter, List? sort, @@ -143,7 +151,7 @@ class PollsRepository { }) async { final result = await _api.queryPolls( queryPollsRequest: api.QueryPollsRequest( - filter: filter?.toJson(), + filter: filter?.toRequest(), sort: sort?.map((it) => it.toRequest()).toList(), limit: limit, next: next, @@ -154,9 +162,11 @@ class PollsRepository { return result.map((response) => response.toModel()); } - /// Fetches one page of the votes and answers matching [filter] on the poll with the id [pollId], ordered by [sort]. + /// Fetches one page of the votes and answers matching [filter] on the poll + /// with the id [pollId], ordered by [sort]. /// - /// [next] and [prev] are the cursors a previous page returned; at most one of them may be given. + /// [next] and [prev] are the cursors a previous page returned; at most one of + /// them may be given. Future> queryPollVotes( String pollId, { PollVoteFilter? filter, @@ -168,7 +178,7 @@ class PollsRepository { final result = await _api.queryPollVotes( pollId: pollId, queryPollVotesRequest: api.QueryPollVotesRequest( - filter: filter?.toJson(), + filter: filter?.toRequest(), sort: sort?.map((it) => it.toRequest()).toList(), limit: limit, next: next, diff --git a/packages/stream_chat/test/src/client/client_polls_test.dart b/packages/stream_chat/test/src/client/client_polls_test.dart new file mode 100644 index 0000000000..db3f1add98 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_polls_test.dart @@ -0,0 +1,642 @@ +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 'poll_fixtures.dart'; + +void main() { + late MockDefaultApi defaultApi; + late StreamChatClient client; + + setUpAll(() { + registerFallbackValue(const api.CreatePollRequest(name: 'fallback')); + registerFallbackValue(const api.UpdatePollRequest(id: 'fallback', name: 'fallback')); + registerFallbackValue(const api.CreatePollOptionRequest(text: 'fallback')); + registerFallbackValue(const api.UpdatePollOptionRequest(id: 'fallback', text: 'fallback')); + }); + + setUp(() { + defaultApi = MockDefaultApi(); + client = StreamChatClient('test-api-key', defaultApi: defaultApi); + }); + + test('StreamChatClient.createPoll sends the poll settings and returns the created poll', () async { + when( + () => defaultApi.createPoll(createPollRequest: any(named: 'createPollRequest')), + ).thenAnswer((_) async => Result.success(generatedPollResponse)); + + final result = await client.createPoll(_newPoll()); + + final request = verify( + () => defaultApi.createPoll(createPollRequest: captureAny(named: 'createPollRequest')), + ).captured.single; + expect( + request, + const api.CreatePollRequest( + id: 'poll-id', + name: 'Lunch?', + description: 'Pick one', + options: [ + api.PollOptionInput(text: 'Pizza', custom: {'color': 'red', 'text': 'Pasta'}), + api.PollOptionInput(text: 'Sushi', custom: {}), + ], + votingVisibility: api.CreatePollRequestVotingVisibility.anonymous, + enforceUniqueVote: false, + maxVotesAllowed: 2, + allowAnswers: true, + allowUserSuggestedOptions: true, + isClosed: false, + custom: {'topic': 'food', 'name': 'Dinner?'}, + ), + ); + expect(result, Result.success(pollResponse)); + }); + + test('StreamChatClient.createPoll returns the failure without throwing', () async { + when( + () => defaultApi.createPoll(createPollRequest: any(named: 'createPollRequest')), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.createPoll(_newPoll()); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.getPoll sends the poll id and returns the poll', () async { + when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(generatedPollResponse)); + + final result = await client.getPoll('poll-id'); + + expect(result, Result.success(pollResponse)); + }); + + test('StreamChatClient.getPoll returns the failure without throwing', () async { + when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.getPoll('poll-id'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test( + "StreamChatClient.getPoll keeps a custom field named like one of the poll's own fields", + () async { + final response = api.PollResponse( + duration: '4.21ms', + poll: generatedPoll.copyWith(custom: const {'topic': 'food', 'name': 'custom-name', 'own_votes': 'custom'}), + ); + when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(response)); + + final result = await client.getPoll('poll-id'); + + expect(result.getOrNull()?.poll.custom, const { + 'topic': 'food', + 'name': 'custom-name', + 'own_votes': 'custom', + }); + }, + ); + + test('StreamChatClient.getPoll keeps a voting visibility the SDK does not name', () async { + final response = api.PollResponse( + duration: '4.21ms', + poll: generatedPoll.copyWith(votingVisibility: api.PollResponseDataVotingVisibility.fromJson('members_only')), + ); + when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(response)); + + final result = await client.getPoll('poll-id'); + + expect(result.getOrNull()?.poll.votingVisibility, const VotingVisibility('members_only')); + }); + + test("StreamChatClient.getPoll reads someone else's vote in an anonymous poll without a user", () async { + final response = api.PollResponse( + duration: '4.21ms', + poll: generatedPoll.copyWith( + latestVotesByOption: { + 'pizza': [generatedVote.copyWith(userId: null, user: null)], + }, + ), + ); + when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(response)); + + final result = await client.getPoll('poll-id'); + + final pizzaVote = result.getOrNull()?.poll.latestVotesByOption['pizza']?.single; + expect((pizzaVote?.userId, pizzaVote?.user), (null, null)); + }); + + test('StreamChatClient.getPoll reads a poll that does not say whether it is closed as open', () async { + final response = api.PollResponse(duration: '4.21ms', poll: generatedPoll.copyWith(isClosed: null)); + when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(response)); + + final result = await client.getPoll('poll-id'); + + expect(result.getOrNull()?.poll.isClosed, isFalse); + }); + + test('StreamChatClient.updatePoll sends the poll settings and options and returns the updated poll', () async { + when( + () => defaultApi.updatePoll(updatePollRequest: any(named: 'updatePollRequest')), + ).thenAnswer((_) async => Result.success(generatedPollResponse)); + + final result = await client.updatePoll( + _newPoll().copyWith( + options: const [ + PollOption(id: 'pizza', text: 'Pizza', custom: {'color': 'red', 'id': 'pasta'}), + PollOption(id: 'sushi', text: 'Sushi'), + ], + votingVisibility: const VotingVisibility('members_only'), + isClosed: true, + ), + ); + + final request = verify( + () => defaultApi.updatePoll(updatePollRequest: captureAny(named: 'updatePollRequest')), + ).captured.single; + expect( + request, + api.UpdatePollRequest( + id: 'poll-id', + name: 'Lunch?', + description: 'Pick one', + options: const [ + api.PollOptionRequest(id: 'pizza', text: 'Pizza', custom: {'color': 'red', 'id': 'pasta'}), + api.PollOptionRequest(id: 'sushi', text: 'Sushi', custom: {}), + ], + votingVisibility: api.UpdatePollRequestVotingVisibility.fromJson('members_only'), + enforceUniqueVote: false, + maxVotesAllowed: 2, + allowAnswers: true, + allowUserSuggestedOptions: true, + isClosed: true, + custom: const {'topic': 'food', 'name': 'Dinner?'}, + ), + ); + expect(result, Result.success(pollResponse)); + }); + + test('StreamChatClient.updatePoll sends an option without an id with an empty id', () async { + when( + () => defaultApi.updatePoll(updatePollRequest: any(named: 'updatePollRequest')), + ).thenAnswer((_) async => Result.success(generatedPollResponse)); + + await client.updatePoll(_newPoll().copyWith(options: const [PollOption(text: 'Pizza')])); + + final request = + verify( + () => defaultApi.updatePoll(updatePollRequest: captureAny(named: 'updatePollRequest')), + ).captured.single + as api.UpdatePollRequest; + expect(request.options, const [api.PollOptionRequest(id: '', text: 'Pizza', custom: {})]); + }); + + test('StreamChatClient.updatePoll returns the failure without throwing', () async { + when( + () => defaultApi.updatePoll(updatePollRequest: any(named: 'updatePollRequest')), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.updatePoll(poll); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.partialUpdatePoll sends the fields to set and unset and returns the updated poll', () async { + when( + () => defaultApi.updatePollPartial( + pollId: 'poll-id', + updatePollPartialRequest: const api.UpdatePollPartialRequest( + set: {'name': 'Dinner?'}, + unset: ['description'], + ), + ), + ).thenAnswer((_) async => Result.success(generatedPollResponse)); + + final result = await client.partialUpdatePoll('poll-id', set: {'name': 'Dinner?'}, unset: ['description']); + + expect(result, Result.success(pollResponse)); + }); + + test('StreamChatClient.partialUpdatePoll returns the failure without throwing', () async { + when( + () => defaultApi.updatePollPartial( + pollId: 'poll-id', + updatePollPartialRequest: const api.UpdatePollPartialRequest(set: {'name': 'Dinner?'}), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.partialUpdatePoll('poll-id', set: {'name': 'Dinner?'}); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.closePoll marks the poll closed and returns the closed poll', () async { + when( + () => defaultApi.updatePollPartial( + pollId: 'poll-id', + updatePollPartialRequest: const api.UpdatePollPartialRequest(set: {'is_closed': true}), + ), + ).thenAnswer((_) async => Result.success(generatedPollResponse)); + + final result = await client.closePoll('poll-id'); + + expect(result, Result.success(pollResponse)); + }); + + test('StreamChatClient.closePoll returns the failure without throwing', () async { + when( + () => defaultApi.updatePollPartial( + pollId: 'poll-id', + updatePollPartialRequest: const api.UpdatePollPartialRequest(set: {'is_closed': true}), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.closePoll('poll-id'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.deletePoll sends the poll id and returns a success', () async { + when( + () => defaultApi.deletePoll(pollId: 'poll-id'), + ).thenAnswer((_) async => const Result.success(api.DurationResponse(duration: '4.21ms'))); + + final result = await client.deletePoll('poll-id'); + + expect(result, const Result.success(null)); + }); + + test('StreamChatClient.deletePoll returns the failure without throwing', () async { + when(() => defaultApi.deletePoll(pollId: 'poll-id')).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.deletePoll('poll-id'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.queryPolls sends the filter, sort and cursor and returns the matching polls', () async { + when( + () => defaultApi.queryPolls( + queryPollsRequest: const api.QueryPollsRequest( + filter: { + 'is_closed': {r'$eq': true}, + }, + sort: [api.SortParamRequest(field: 'created_at', direction: -1)], + limit: 10, + next: 'next-cursor', + ), + ), + ).thenAnswer( + (_) async => Result.success( + api.QueryPollsResponse(duration: '4.21ms', polls: [generatedPoll], next: 'after', prev: 'before'), + ), + ); + + final result = await client.queryPolls( + filter: Filter.equal(PollFilterField.isClosed, true), + sort: [PollSort.desc(PollSortField.createdAt)], + limit: 10, + next: 'next-cursor', + ); + + expect( + result, + Result.success(QueryPollsResponse(duration: '4.21ms', polls: [poll], next: 'after', prev: 'before')), + ); + }); + + test('StreamChatClient.queryPolls sends the previous-page cursor and ten as the default limit', () async { + when( + () => defaultApi.queryPolls(queryPollsRequest: const api.QueryPollsRequest(limit: 10, prev: 'prev-cursor')), + ).thenAnswer((_) async => const Result.success(api.QueryPollsResponse(duration: '4.21ms', polls: []))); + + final result = await client.queryPolls(prev: 'prev-cursor'); + + expect(result, const Result.success(QueryPollsResponse(duration: '4.21ms', polls: []))); + }); + + test('StreamChatClient.queryPolls returns the failure without throwing', () async { + when( + () => defaultApi.queryPolls(queryPollsRequest: const api.QueryPollsRequest(limit: 10)), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.queryPolls(); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test( + 'StreamChatClient.createPollOption sends the option text and custom data and returns the created option', + () async { + when( + () => defaultApi.createPollOption( + pollId: 'poll-id', + createPollOptionRequest: const api.CreatePollOptionRequest( + text: 'Pizza', + custom: {'color': 'red', 'text': 'Pasta'}, + ), + ), + ).thenAnswer((_) async => const Result.success(_generatedPizzaResponse)); + + final result = await client.createPollOption( + 'poll-id', + const PollOption(text: 'Pizza', custom: {'color': 'red', 'text': 'Pasta'}), + ); + + expect(result, const Result.success(_pizzaResponse)); + }, + ); + + test('StreamChatClient.createPollOption returns the failure without throwing', () async { + when( + () => defaultApi.createPollOption( + pollId: 'poll-id', + createPollOptionRequest: any(named: 'createPollOptionRequest'), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.createPollOption('poll-id', const PollOption(text: 'Pizza')); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.getPollOption sends the poll and option ids and returns the option', () async { + when( + () => defaultApi.getPollOption(pollId: 'poll-id', optionId: 'pizza'), + ).thenAnswer((_) async => const Result.success(_generatedPizzaResponse)); + + final result = await client.getPollOption('poll-id', 'pizza'); + + expect(result, const Result.success(_pizzaResponse)); + }); + + test('StreamChatClient.getPollOption returns the failure without throwing', () async { + when( + () => defaultApi.getPollOption(pollId: 'poll-id', optionId: 'pizza'), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.getPollOption('poll-id', 'pizza'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test( + "StreamChatClient.getPollOption keeps a custom field named like one of the option's own fields", + () async { + final response = api.PollOptionResponse( + duration: '4.21ms', + pollOption: generatedPizza.copyWith(custom: const {'color': 'red', 'text': 'custom-text'}), + ); + when( + () => defaultApi.getPollOption(pollId: 'poll-id', optionId: 'pizza'), + ).thenAnswer((_) async => Result.success(response)); + + final result = await client.getPollOption('poll-id', 'pizza'); + + expect(result.getOrNull()?.pollOption.custom, const {'color': 'red', 'text': 'custom-text'}); + }, + ); + + test('StreamChatClient.updatePollOption sends the option and returns the updated option', () async { + when( + () => defaultApi.updatePollOption( + pollId: 'poll-id', + updatePollOptionRequest: const api.UpdatePollOptionRequest( + id: 'pizza', + text: 'Pizza', + custom: {'color': 'red'}, + ), + ), + ).thenAnswer((_) async => const Result.success(_generatedPizzaResponse)); + + final result = await client.updatePollOption('poll-id', pizza); + + expect(result, const Result.success(_pizzaResponse)); + }); + + test('StreamChatClient.updatePollOption sends an option without an id with an empty id', () async { + when( + () => defaultApi.updatePollOption( + pollId: 'poll-id', + updatePollOptionRequest: const api.UpdatePollOptionRequest(id: '', text: 'Pizza', custom: {}), + ), + ).thenAnswer((_) async => const Result.success(_generatedPizzaResponse)); + + final result = await client.updatePollOption('poll-id', const PollOption(text: 'Pizza')); + + expect(result, const Result.success(_pizzaResponse)); + }); + + test('StreamChatClient.updatePollOption returns the failure without throwing', () async { + when( + () => defaultApi.updatePollOption( + pollId: 'poll-id', + updatePollOptionRequest: any(named: 'updatePollOptionRequest'), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.updatePollOption('poll-id', pizza); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.deletePollOption sends the poll and option ids and returns a success', () async { + when( + () => defaultApi.deletePollOption(pollId: 'poll-id', optionId: 'pizza'), + ).thenAnswer((_) async => const Result.success(api.DurationResponse(duration: '4.21ms'))); + + final result = await client.deletePollOption('poll-id', 'pizza'); + + expect(result, const Result.success(null)); + }); + + test('StreamChatClient.deletePollOption returns the failure without throwing', () async { + when( + () => defaultApi.deletePollOption(pollId: 'poll-id', optionId: 'pizza'), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.deletePollOption('poll-id', 'pizza'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.castPollVote sends the selected option and returns the cast vote', () async { + when( + () => defaultApi.castPollVote( + messageId: 'message-id', + pollId: 'poll-id', + castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(optionId: 'pizza')), + ), + ).thenAnswer( + (_) async => Result.success(api.PollVoteResponse(duration: '4.21ms', poll: generatedPoll, vote: generatedVote)), + ); + + final result = await client.castPollVote('message-id', 'poll-id', optionId: 'pizza'); + + expect(result, Result.success(PollVoteResponse(duration: '4.21ms', vote: vote))); + }); + + test('StreamChatClient.castPollVote returns the failure without throwing', () async { + when( + () => defaultApi.castPollVote( + messageId: 'message-id', + pollId: 'poll-id', + castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(optionId: 'pizza')), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.castPollVote('message-id', 'poll-id', optionId: 'pizza'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.castPollVote returns no vote when the response carries none', () async { + when( + () => defaultApi.castPollVote( + messageId: 'message-id', + pollId: 'poll-id', + castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(optionId: 'pizza')), + ), + ).thenAnswer((_) async => const Result.success(api.PollVoteResponse(duration: '4.21ms'))); + + final result = await client.castPollVote('message-id', 'poll-id', optionId: 'pizza'); + + expect(result, const Result.success(PollVoteResponse(duration: '4.21ms'))); + }); + + test('StreamChatClient.addPollAnswer sends the answer text and returns the answer', () async { + when( + () => defaultApi.castPollVote( + messageId: 'message-id', + pollId: 'poll-id', + castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(answerText: 'Anything')), + ), + ).thenAnswer( + (_) async => Result.success(api.PollVoteResponse(duration: '4.21ms', poll: generatedPoll, vote: generatedAnswer)), + ); + + final result = await client.addPollAnswer('message-id', 'poll-id', answerText: 'Anything'); + + expect(result, Result.success(PollVoteResponse(duration: '4.21ms', vote: answer))); + }); + + test('StreamChatClient.addPollAnswer returns the failure without throwing', () async { + when( + () => defaultApi.castPollVote( + messageId: 'message-id', + pollId: 'poll-id', + castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(answerText: 'Anything')), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.addPollAnswer('message-id', 'poll-id', answerText: 'Anything'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.removePollVote sends the vote id and returns the removed vote', () async { + when( + () => defaultApi.deletePollVote(messageId: 'message-id', pollId: 'poll-id', voteId: 'vote-id'), + ).thenAnswer( + (_) async => Result.success(api.PollVoteResponse(duration: '4.21ms', poll: generatedPoll, vote: generatedVote)), + ); + + final result = await client.removePollVote('message-id', 'poll-id', 'vote-id'); + + expect(result, Result.success(PollVoteResponse(duration: '4.21ms', vote: vote))); + }); + + test('StreamChatClient.removePollVote returns the failure without throwing', () async { + when( + () => defaultApi.deletePollVote(messageId: 'message-id', pollId: 'poll-id', voteId: 'vote-id'), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.removePollVote('message-id', 'poll-id', 'vote-id'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.queryPollVotes sends the filter, sort and cursor and returns the matching votes', () async { + when( + () => defaultApi.queryPollVotes( + pollId: 'poll-id', + queryPollVotesRequest: const api.QueryPollVotesRequest( + filter: { + 'is_answer': {r'$eq': true}, + }, + sort: [api.SortParamRequest(field: 'created_at', direction: 1)], + limit: 25, + next: 'next-cursor', + ), + ), + ).thenAnswer( + (_) async => Result.success( + api.PollVotesResponse(duration: '4.21ms', votes: [generatedVote, generatedAnswer], next: 'after', prev: 'x'), + ), + ); + + final result = await client.queryPollVotes( + 'poll-id', + filter: Filter.equal(PollVoteFilterField.isAnswer, true), + sort: [PollVoteSort.asc(PollVoteSortField.createdAt)], + limit: 25, + next: 'next-cursor', + ); + + expect( + result, + Result.success(QueryPollVotesResponse(duration: '4.21ms', votes: [vote, answer], next: 'after', prev: 'x')), + ); + }); + + test('StreamChatClient.queryPollVotes returns the failure without throwing', () async { + when( + () => defaultApi.queryPollVotes( + pollId: 'poll-id', + queryPollVotesRequest: const api.QueryPollVotesRequest(limit: 10), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.queryPollVotes('poll-id'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.queryPollVotes sends the previous-page cursor and ten as the default limit', () async { + when( + () => defaultApi.queryPollVotes( + pollId: 'poll-id', + queryPollVotesRequest: const api.QueryPollVotesRequest(limit: 10, prev: 'prev-cursor'), + ), + ).thenAnswer((_) async => const Result.success(api.PollVotesResponse(duration: '4.21ms', votes: []))); + + final result = await client.queryPollVotes('poll-id', prev: 'prev-cursor'); + + expect(result, const Result.success(QueryPollVotesResponse(duration: '4.21ms', votes: []))); + }); +} + +// A poll as a caller builds it: options without ids and the vote summary at its defaults. +Poll _newPoll() => Poll( + id: 'poll-id', + name: 'Lunch?', + description: 'Pick one', + options: const [ + PollOption(text: 'Pizza', custom: {'color': 'red', 'text': 'Pasta'}), + PollOption(text: 'Sushi'), + ], + votingVisibility: VotingVisibility.anonymous, + enforceUniqueVote: false, + maxVotesAllowed: 2, + allowAnswers: true, + allowUserSuggestedOptions: true, + voteCount: 7, + // Custom data named like one of the poll's own fields is sent as custom data. + custom: const {'topic': 'food', 'name': 'Dinner?'}, +); + +const _generatedPizzaResponse = api.PollOptionResponse(duration: '4.21ms', pollOption: generatedPizza); + +const _pizzaResponse = PollOptionResponse(duration: '4.21ms', pollOption: pizza); diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index bcb44d9a1a..8233e17d88 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -13,7 +13,6 @@ import '../matchers.dart'; import '../mocks.dart'; import '../utils.dart'; import '../ws/fake_chat_server.dart'; -import 'poll_fixtures.dart'; void main() { group('Fake web-socket connection functions', () { @@ -1120,10 +1119,6 @@ void main() { registerFallbackValue(FakeDraftMessage()); registerFallbackValue(FakePollVote()); registerFallbackValue(const PaginationParams()); - registerFallbackValue(const api.CreatePollRequest(name: 'fallback')); - registerFallbackValue(const api.UpdatePollRequest(id: 'fallback', name: 'fallback')); - registerFallbackValue(const api.CreatePollOptionRequest(text: 'fallback')); - registerFallbackValue(const api.UpdatePollOptionRequest(id: 'fallback', text: 'fallback')); }); setUp(() async { @@ -2526,602 +2521,6 @@ void main() { expect(res.getOrNull()?.app.fileUploadConfig.sizeLimit, UploadConfig.defaultSizeLimit); }); - test('StreamChatClient.createPoll sends the poll settings and returns the created poll', () async { - when( - () => defaultApi.createPoll(createPollRequest: any(named: 'createPollRequest')), - ).thenAnswer((_) async => Result.success(generatedPollResponse)); - - final result = await client.createPoll(_newPoll()); - - final request = verify( - () => defaultApi.createPoll(createPollRequest: captureAny(named: 'createPollRequest')), - ).captured.single; - expect( - request, - const api.CreatePollRequest( - id: 'poll-id', - name: 'Lunch?', - description: 'Pick one', - options: [ - api.PollOptionInput(text: 'Pizza', custom: {'color': 'red', 'text': 'Pasta'}), - api.PollOptionInput(text: 'Sushi', custom: {}), - ], - votingVisibility: api.CreatePollRequestVotingVisibility.anonymous, - enforceUniqueVote: false, - maxVotesAllowed: 2, - allowAnswers: true, - allowUserSuggestedOptions: true, - isClosed: false, - custom: {'topic': 'food', 'name': 'Dinner?'}, - ), - ); - expect(result, Result.success(pollResponse)); - }); - - test('StreamChatClient.createPoll returns the failure without throwing', () async { - when( - () => defaultApi.createPoll(createPollRequest: any(named: 'createPollRequest')), - ).thenAnswer((_) async => const Result.failure(pollsApiError)); - - final result = await client.createPoll(_newPoll()); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test('StreamChatClient.getPoll sends the poll id and returns the poll', () async { - when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(generatedPollResponse)); - - final result = await client.getPoll('poll-id'); - - expect(result, Result.success(pollResponse)); - }); - - test('StreamChatClient.getPoll returns the failure without throwing', () async { - when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => const Result.failure(pollsApiError)); - - final result = await client.getPoll('poll-id'); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test( - "StreamChatClient.getPoll keeps a custom field named like one of the poll's own fields", - () async { - final response = api.PollResponse( - duration: '4.21ms', - poll: generatedPoll.copyWith(custom: const {'topic': 'food', 'name': 'custom-name', 'own_votes': 'custom'}), - ); - when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(response)); - - final result = await client.getPoll('poll-id'); - - expect(result.getOrNull()?.poll.custom, const { - 'topic': 'food', - 'name': 'custom-name', - 'own_votes': 'custom', - }); - }, - ); - - test('StreamChatClient.getPoll keeps a voting visibility the SDK does not name', () async { - final response = api.PollResponse( - duration: '4.21ms', - poll: generatedPoll.copyWith(votingVisibility: api.PollResponseDataVotingVisibility.fromJson('members_only')), - ); - when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(response)); - - final result = await client.getPoll('poll-id'); - - expect(result.getOrNull()?.poll.votingVisibility, const VotingVisibility('members_only')); - }); - - test("StreamChatClient.getPoll reads someone else's vote in an anonymous poll without a user", () async { - final response = api.PollResponse( - duration: '4.21ms', - poll: generatedPoll.copyWith( - latestVotesByOption: { - 'pizza': [generatedVote.copyWith(userId: null, user: null)], - }, - ), - ); - when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(response)); - - final result = await client.getPoll('poll-id'); - - final pizzaVote = result.getOrNull()?.poll.latestVotesByOption['pizza']?.single; - expect((pizzaVote?.userId, pizzaVote?.user), (null, null)); - }); - - test('StreamChatClient.getPoll reads a poll that does not say whether it is closed as open', () async { - final response = api.PollResponse(duration: '4.21ms', poll: generatedPoll.copyWith(isClosed: null)); - when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(response)); - - final result = await client.getPoll('poll-id'); - - expect(result.getOrNull()?.poll.isClosed, isFalse); - }); - - test('StreamChatClient.updatePoll sends the poll settings and options and returns the updated poll', () async { - when( - () => defaultApi.updatePoll(updatePollRequest: any(named: 'updatePollRequest')), - ).thenAnswer((_) async => Result.success(generatedPollResponse)); - - final result = await client.updatePoll( - _newPoll().copyWith( - options: const [ - PollOption(id: 'pizza', text: 'Pizza', custom: {'color': 'red', 'id': 'pasta'}), - PollOption(id: 'sushi', text: 'Sushi'), - ], - votingVisibility: const VotingVisibility('members_only'), - isClosed: true, - ), - ); - - final request = verify( - () => defaultApi.updatePoll(updatePollRequest: captureAny(named: 'updatePollRequest')), - ).captured.single; - expect( - request, - api.UpdatePollRequest( - id: 'poll-id', - name: 'Lunch?', - description: 'Pick one', - options: const [ - api.PollOptionRequest(id: 'pizza', text: 'Pizza', custom: {'color': 'red', 'id': 'pasta'}), - api.PollOptionRequest(id: 'sushi', text: 'Sushi', custom: {}), - ], - votingVisibility: api.UpdatePollRequestVotingVisibility.fromJson('members_only'), - enforceUniqueVote: false, - maxVotesAllowed: 2, - allowAnswers: true, - allowUserSuggestedOptions: true, - isClosed: true, - custom: const {'topic': 'food', 'name': 'Dinner?'}, - ), - ); - expect(result, Result.success(pollResponse)); - }); - - test('StreamChatClient.updatePoll sends an option without an id with an empty id', () async { - when( - () => defaultApi.updatePoll(updatePollRequest: any(named: 'updatePollRequest')), - ).thenAnswer((_) async => Result.success(generatedPollResponse)); - - await client.updatePoll(_newPoll().copyWith(options: const [PollOption(text: 'Pizza')])); - - final request = - verify( - () => defaultApi.updatePoll(updatePollRequest: captureAny(named: 'updatePollRequest')), - ).captured.single - as api.UpdatePollRequest; - expect(request.options, const [api.PollOptionRequest(id: '', text: 'Pizza', custom: {})]); - }); - - test('StreamChatClient.updatePoll returns the failure without throwing', () async { - when( - () => defaultApi.updatePoll(updatePollRequest: any(named: 'updatePollRequest')), - ).thenAnswer((_) async => const Result.failure(pollsApiError)); - - final result = await client.updatePoll(poll); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test('StreamChatClient.partialUpdatePoll sends the fields to set and unset and returns the updated poll', () async { - when( - () => defaultApi.updatePollPartial( - pollId: 'poll-id', - updatePollPartialRequest: const api.UpdatePollPartialRequest( - set: {'name': 'Dinner?'}, - unset: ['description'], - ), - ), - ).thenAnswer((_) async => Result.success(generatedPollResponse)); - - final result = await client.partialUpdatePoll('poll-id', set: {'name': 'Dinner?'}, unset: ['description']); - - expect(result, Result.success(pollResponse)); - }); - - test('StreamChatClient.partialUpdatePoll returns the failure without throwing', () async { - when( - () => defaultApi.updatePollPartial( - pollId: 'poll-id', - updatePollPartialRequest: const api.UpdatePollPartialRequest(set: {'name': 'Dinner?'}), - ), - ).thenAnswer((_) async => const Result.failure(pollsApiError)); - - final result = await client.partialUpdatePoll('poll-id', set: {'name': 'Dinner?'}); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test('StreamChatClient.closePoll marks the poll closed and returns the closed poll', () async { - when( - () => defaultApi.updatePollPartial( - pollId: 'poll-id', - updatePollPartialRequest: const api.UpdatePollPartialRequest(set: {'is_closed': true}), - ), - ).thenAnswer((_) async => Result.success(generatedPollResponse)); - - final result = await client.closePoll('poll-id'); - - expect(result, Result.success(pollResponse)); - }); - - test('StreamChatClient.closePoll returns the failure without throwing', () async { - when( - () => defaultApi.updatePollPartial( - pollId: 'poll-id', - updatePollPartialRequest: const api.UpdatePollPartialRequest(set: {'is_closed': true}), - ), - ).thenAnswer((_) async => const Result.failure(pollsApiError)); - - final result = await client.closePoll('poll-id'); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test('StreamChatClient.deletePoll sends the poll id and returns a success', () async { - when( - () => defaultApi.deletePoll(pollId: 'poll-id'), - ).thenAnswer((_) async => const Result.success(api.DurationResponse(duration: '4.21ms'))); - - final result = await client.deletePoll('poll-id'); - - expect(result, const Result.success(null)); - }); - - test('StreamChatClient.deletePoll returns the failure without throwing', () async { - when(() => defaultApi.deletePoll(pollId: 'poll-id')).thenAnswer((_) async => const Result.failure(pollsApiError)); - - final result = await client.deletePoll('poll-id'); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test('StreamChatClient.queryPolls sends the filter, sort and cursor and returns the matching polls', () async { - when( - () => defaultApi.queryPolls( - queryPollsRequest: const api.QueryPollsRequest( - filter: { - 'is_closed': {r'$eq': true}, - }, - sort: [api.SortParamRequest(field: 'created_at', direction: -1)], - limit: 10, - next: 'next-cursor', - ), - ), - ).thenAnswer( - (_) async => Result.success( - api.QueryPollsResponse(duration: '4.21ms', polls: [generatedPoll], next: 'after', prev: 'before'), - ), - ); - - final result = await client.queryPolls( - filter: Filter.equal(PollFilterField.isClosed, true), - sort: [PollSort.desc(PollSortField.createdAt)], - limit: 10, - next: 'next-cursor', - ); - - expect( - result, - Result.success(QueryPollsResponse(duration: '4.21ms', polls: [poll], next: 'after', prev: 'before')), - ); - }); - - test('StreamChatClient.queryPolls sends the previous-page cursor and ten as the default limit', () async { - when( - () => defaultApi.queryPolls(queryPollsRequest: const api.QueryPollsRequest(limit: 10, prev: 'prev-cursor')), - ).thenAnswer((_) async => const Result.success(api.QueryPollsResponse(duration: '4.21ms', polls: []))); - - final result = await client.queryPolls(prev: 'prev-cursor'); - - expect(result, const Result.success(QueryPollsResponse(duration: '4.21ms', polls: []))); - }); - - test('StreamChatClient.queryPolls returns the failure without throwing', () async { - when( - () => defaultApi.queryPolls(queryPollsRequest: const api.QueryPollsRequest(limit: 10)), - ).thenAnswer((_) async => const Result.failure(pollsApiError)); - - final result = await client.queryPolls(); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test( - 'StreamChatClient.createPollOption sends the option text and custom data and returns the created option', - () async { - when( - () => defaultApi.createPollOption( - pollId: 'poll-id', - createPollOptionRequest: const api.CreatePollOptionRequest( - text: 'Pizza', - custom: {'color': 'red', 'text': 'Pasta'}, - ), - ), - ).thenAnswer((_) async => const Result.success(_generatedPizzaResponse)); - - final result = await client.createPollOption( - 'poll-id', - const PollOption(text: 'Pizza', custom: {'color': 'red', 'text': 'Pasta'}), - ); - - expect(result, const Result.success(_pizzaResponse)); - }, - ); - - test('StreamChatClient.createPollOption returns the failure without throwing', () async { - when( - () => defaultApi.createPollOption( - pollId: 'poll-id', - createPollOptionRequest: any(named: 'createPollOptionRequest'), - ), - ).thenAnswer((_) async => const Result.failure(pollsApiError)); - - final result = await client.createPollOption('poll-id', const PollOption(text: 'Pizza')); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test('StreamChatClient.getPollOption sends the poll and option ids and returns the option', () async { - when( - () => defaultApi.getPollOption(pollId: 'poll-id', optionId: 'pizza'), - ).thenAnswer((_) async => const Result.success(_generatedPizzaResponse)); - - final result = await client.getPollOption('poll-id', 'pizza'); - - expect(result, const Result.success(_pizzaResponse)); - }); - - test('StreamChatClient.getPollOption returns the failure without throwing', () async { - when( - () => defaultApi.getPollOption(pollId: 'poll-id', optionId: 'pizza'), - ).thenAnswer((_) async => const Result.failure(pollsApiError)); - - final result = await client.getPollOption('poll-id', 'pizza'); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test( - "StreamChatClient.getPollOption keeps a custom field named like one of the option's own fields", - () async { - final response = api.PollOptionResponse( - duration: '4.21ms', - pollOption: generatedPizza.copyWith(custom: const {'color': 'red', 'text': 'custom-text'}), - ); - when( - () => defaultApi.getPollOption(pollId: 'poll-id', optionId: 'pizza'), - ).thenAnswer((_) async => Result.success(response)); - - final result = await client.getPollOption('poll-id', 'pizza'); - - expect(result.getOrNull()?.pollOption.custom, const {'color': 'red', 'text': 'custom-text'}); - }, - ); - - test('StreamChatClient.updatePollOption sends the option and returns the updated option', () async { - when( - () => defaultApi.updatePollOption( - pollId: 'poll-id', - updatePollOptionRequest: const api.UpdatePollOptionRequest( - id: 'pizza', - text: 'Pizza', - custom: {'color': 'red'}, - ), - ), - ).thenAnswer((_) async => const Result.success(_generatedPizzaResponse)); - - final result = await client.updatePollOption('poll-id', pizza); - - expect(result, const Result.success(_pizzaResponse)); - }); - - test('StreamChatClient.updatePollOption sends an option without an id with an empty id', () async { - when( - () => defaultApi.updatePollOption( - pollId: 'poll-id', - updatePollOptionRequest: const api.UpdatePollOptionRequest(id: '', text: 'Pizza', custom: {}), - ), - ).thenAnswer((_) async => const Result.success(_generatedPizzaResponse)); - - final result = await client.updatePollOption('poll-id', const PollOption(text: 'Pizza')); - - expect(result, const Result.success(_pizzaResponse)); - }); - - test('StreamChatClient.updatePollOption returns the failure without throwing', () async { - when( - () => defaultApi.updatePollOption( - pollId: 'poll-id', - updatePollOptionRequest: any(named: 'updatePollOptionRequest'), - ), - ).thenAnswer((_) async => const Result.failure(pollsApiError)); - - final result = await client.updatePollOption('poll-id', pizza); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test('StreamChatClient.deletePollOption sends the poll and option ids and returns a success', () async { - when( - () => defaultApi.deletePollOption(pollId: 'poll-id', optionId: 'pizza'), - ).thenAnswer((_) async => const Result.success(api.DurationResponse(duration: '4.21ms'))); - - final result = await client.deletePollOption('poll-id', 'pizza'); - - expect(result, const Result.success(null)); - }); - - test('StreamChatClient.deletePollOption returns the failure without throwing', () async { - when( - () => defaultApi.deletePollOption(pollId: 'poll-id', optionId: 'pizza'), - ).thenAnswer((_) async => const Result.failure(pollsApiError)); - - final result = await client.deletePollOption('poll-id', 'pizza'); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test('StreamChatClient.castPollVote sends the selected option and returns the cast vote', () async { - when( - () => defaultApi.castPollVote( - messageId: 'message-id', - pollId: 'poll-id', - castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(optionId: 'pizza')), - ), - ).thenAnswer( - (_) async => Result.success(api.PollVoteResponse(duration: '4.21ms', poll: generatedPoll, vote: generatedVote)), - ); - - final result = await client.castPollVote('message-id', 'poll-id', optionId: 'pizza'); - - expect(result, Result.success(PollVoteResponse(duration: '4.21ms', vote: vote))); - }); - - test('StreamChatClient.castPollVote returns the failure without throwing', () async { - when( - () => defaultApi.castPollVote( - messageId: 'message-id', - pollId: 'poll-id', - castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(optionId: 'pizza')), - ), - ).thenAnswer((_) async => const Result.failure(pollsApiError)); - - final result = await client.castPollVote('message-id', 'poll-id', optionId: 'pizza'); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test('StreamChatClient.castPollVote returns no vote when the response carries none', () async { - when( - () => defaultApi.castPollVote( - messageId: 'message-id', - pollId: 'poll-id', - castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(optionId: 'pizza')), - ), - ).thenAnswer((_) async => const Result.success(api.PollVoteResponse(duration: '4.21ms'))); - - final result = await client.castPollVote('message-id', 'poll-id', optionId: 'pizza'); - - expect(result, const Result.success(PollVoteResponse(duration: '4.21ms'))); - }); - - test('StreamChatClient.addPollAnswer sends the answer text and returns the answer', () async { - when( - () => defaultApi.castPollVote( - messageId: 'message-id', - pollId: 'poll-id', - castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(answerText: 'Anything')), - ), - ).thenAnswer( - (_) async => - Result.success(api.PollVoteResponse(duration: '4.21ms', poll: generatedPoll, vote: generatedAnswer)), - ); - - final result = await client.addPollAnswer('message-id', 'poll-id', answerText: 'Anything'); - - expect(result, Result.success(PollVoteResponse(duration: '4.21ms', vote: answer))); - }); - - test('StreamChatClient.addPollAnswer returns the failure without throwing', () async { - when( - () => defaultApi.castPollVote( - messageId: 'message-id', - pollId: 'poll-id', - castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(answerText: 'Anything')), - ), - ).thenAnswer((_) async => const Result.failure(pollsApiError)); - - final result = await client.addPollAnswer('message-id', 'poll-id', answerText: 'Anything'); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test('StreamChatClient.removePollVote sends the vote id and returns the removed vote', () async { - when( - () => defaultApi.deletePollVote(messageId: 'message-id', pollId: 'poll-id', voteId: 'vote-id'), - ).thenAnswer( - (_) async => Result.success(api.PollVoteResponse(duration: '4.21ms', poll: generatedPoll, vote: generatedVote)), - ); - - final result = await client.removePollVote('message-id', 'poll-id', 'vote-id'); - - expect(result, Result.success(PollVoteResponse(duration: '4.21ms', vote: vote))); - }); - - test('StreamChatClient.removePollVote returns the failure without throwing', () async { - when( - () => defaultApi.deletePollVote(messageId: 'message-id', pollId: 'poll-id', voteId: 'vote-id'), - ).thenAnswer((_) async => const Result.failure(pollsApiError)); - - final result = await client.removePollVote('message-id', 'poll-id', 'vote-id'); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test('StreamChatClient.queryPollVotes sends the filter, sort and cursor and returns the matching votes', () async { - when( - () => defaultApi.queryPollVotes( - pollId: 'poll-id', - queryPollVotesRequest: const api.QueryPollVotesRequest( - filter: { - 'is_answer': {r'$eq': true}, - }, - sort: [api.SortParamRequest(field: 'created_at', direction: 1)], - limit: 25, - next: 'next-cursor', - ), - ), - ).thenAnswer( - (_) async => Result.success( - api.PollVotesResponse(duration: '4.21ms', votes: [generatedVote, generatedAnswer], next: 'after', prev: 'x'), - ), - ); - - final result = await client.queryPollVotes( - 'poll-id', - filter: Filter.equal(PollVoteFilterField.isAnswer, true), - sort: [PollVoteSort.asc(PollVoteSortField.createdAt)], - limit: 25, - next: 'next-cursor', - ); - - expect( - result, - Result.success(QueryPollVotesResponse(duration: '4.21ms', votes: [vote, answer], next: 'after', prev: 'x')), - ); - }); - - test('StreamChatClient.queryPollVotes returns the failure without throwing', () async { - when( - () => defaultApi.queryPollVotes( - pollId: 'poll-id', - queryPollVotesRequest: const api.QueryPollVotesRequest(limit: 10), - ), - ).thenAnswer((_) async => const Result.failure(pollsApiError)); - - final result = await client.queryPollVotes('poll-id'); - - expect(result.exceptionOrNull(), pollsApiError); - }); - - test('StreamChatClient.queryPollVotes sends the previous-page cursor and ten as the default limit', () async { - when( - () => defaultApi.queryPollVotes( - pollId: 'poll-id', - queryPollVotesRequest: const api.QueryPollVotesRequest(limit: 10, prev: 'prev-cursor'), - ), - ).thenAnswer((_) async => const Result.success(api.PollVotesResponse(duration: '4.21ms', votes: []))); - - final result = await client.queryPollVotes('poll-id', prev: 'prev-cursor'); - - expect(result, const Result.success(QueryPollVotesResponse(duration: '4.21ms', votes: []))); - }); - group('`.channel`', () { test('should return back a new channel instance', () { const channelType = 'test-channel-type'; @@ -7198,26 +6597,3 @@ UserGroup _userGroup(String id) { api.DeviceResponse _generatedDevice({required String id, required String pushProvider}) { return api.DeviceResponse(id: id, pushProvider: pushProvider, createdAt: DateTime.utc(2024), userId: 'test-user-id'); } - -// A poll as a caller builds it: options without ids and the vote summary at its defaults. -Poll _newPoll() => Poll( - id: 'poll-id', - name: 'Lunch?', - description: 'Pick one', - options: const [ - PollOption(text: 'Pizza', custom: {'color': 'red', 'text': 'Pasta'}), - PollOption(text: 'Sushi'), - ], - votingVisibility: VotingVisibility.anonymous, - enforceUniqueVote: false, - maxVotesAllowed: 2, - allowAnswers: true, - allowUserSuggestedOptions: true, - voteCount: 7, - // Custom data named like one of the poll's own fields is sent as custom data. - custom: const {'topic': 'food', 'name': 'Dinner?'}, -); - -const _generatedPizzaResponse = api.PollOptionResponse(duration: '4.21ms', pollOption: generatedPizza); - -const _pizzaResponse = PollOptionResponse(duration: '4.21ms', pollOption: pizza); diff --git a/packages/stream_chat/test/src/core/models/converters/poll_v1_json_test.dart b/packages/stream_chat/test/src/core/models/converters/poll_v1_json_test.dart index 1592c04c30..3283f0873e 100644 --- a/packages/stream_chat/test/src/core/models/converters/poll_v1_json_test.dart +++ b/packages/stream_chat/test/src/core/models/converters/poll_v1_json_test.dart @@ -148,8 +148,7 @@ void main() { expect(event.pollVote, _vote); }); - test('Event.toJson writes the poll settings with the custom data beside them, without the translations or a custom ' - 'entry named like a key', () { + test('Event.toJson writes the poll settings with the custom data beside them, without the translations', () { final event = Event( type: EventType.pollUpdated, poll: Poll( @@ -159,10 +158,10 @@ void main() { description: 'Pick one', descriptionI18n: const {'it': 'Scegline uno'}, options: const [ - PollOption(id: 'pizza', text: 'Pizza', textI18n: {'it': 'Pizza'}, custom: {'color': 'red', 'id': 'pasta'}), + PollOption(id: 'pizza', text: 'Pizza', textI18n: {'it': 'Pizza'}, custom: {'color': 'red'}), ], voteCount: 3, - custom: const {'topic': 'food', 'name': 'Dinner?'}, + custom: const {'topic': 'food'}, ), ); @@ -183,6 +182,36 @@ void main() { }); }); + test('Event.toJson leaves out a poll custom entry named like a poll key', () { + final event = Event( + type: EventType.pollUpdated, + poll: Poll(id: 'poll-id', name: 'Lunch?', options: const [], custom: const {'name': 'Dinner?'}), + ); + + final poll = event.toJson()['poll'] as Map; + + expect(poll['name'], 'Lunch?'); + }); + + test('Event.toJson leaves out an option custom entry named like an option key', () { + final event = Event( + type: EventType.pollUpdated, + poll: Poll( + id: 'poll-id', + name: 'Lunch?', + options: const [ + PollOption(id: 'pizza', text: 'Pizza', custom: {'id': 'pasta'}), + ], + ), + ); + + final poll = event.toJson()['poll'] as Map; + + expect(poll['options'], [ + {'id': 'pizza', 'text': 'Pizza'}, + ]); + }); + test('Event.toJson writes only the id, the option and the answer text of a vote', () { final event = Event(type: EventType.pollAnswerCasted, pollVote: _answer); diff --git a/packages/stream_chat/test/src/core/models/poll_option_test.dart b/packages/stream_chat/test/src/core/models/poll_option_test.dart index 81ff44beb1..33b4489b3a 100644 --- a/packages/stream_chat/test/src/core/models/poll_option_test.dart +++ b/packages/stream_chat/test/src/core/models/poll_option_test.dart @@ -1,4 +1,4 @@ -import 'package:stream_chat/src/core/models/poll_option.dart'; +import 'package:stream_chat/stream_chat.dart'; import 'package:test/test.dart'; void main() { diff --git a/packages/stream_chat/test/src/core/models/poll_test.dart b/packages/stream_chat/test/src/core/models/poll_test.dart index 6f5a23b672..f6d68c66b9 100644 --- a/packages/stream_chat/test/src/core/models/poll_test.dart +++ b/packages/stream_chat/test/src/core/models/poll_test.dart @@ -1,8 +1,4 @@ -import 'package:stream_chat/src/core/models/poll.dart'; -import 'package:stream_chat/src/core/models/poll_option.dart'; -import 'package:stream_chat/src/core/models/poll_vote.dart'; -import 'package:stream_chat/src/core/models/user.dart'; -import 'package:stream_chat/src/core/models/voting_visibility.dart'; +import 'package:stream_chat/stream_chat.dart'; import 'package:test/test.dart'; import '../../utils.dart'; diff --git a/packages/stream_chat_flutter_core/lib/src/stream_poll_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_poll_controller.dart index aca9758513..65ae886e9a 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_poll_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_poll_controller.dart @@ -177,7 +177,7 @@ class StreamPollController extends ValueNotifier { return invalidErrors; } - /// Adds a new option with the provided [text] and [custom]. + /// Adds an option with [text] and the custom data [custom]. /// /// The new option will be added to the end of the list of options. void addOption( @@ -189,8 +189,7 @@ class StreamPollController extends ValueNotifier { value = value.copyWith(options: [...options, newOption]); } - /// Updates the option at the provided [index] with the provided [text] and - /// [custom]. + /// Updates the option at [index] to [text] and the custom data [custom]. void updateOption( String text, { required int index, diff --git a/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart index 58caabf503..5e752c6ee8 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart @@ -149,8 +149,8 @@ class StreamPollVoteListController extends PagedValueNotifier ); } - // Queries a page of votes, after [next] when given, turning an error the channel throws (when it is not initialized, - // for example) into a failure, so every outcome reaches the controller's value. + // Turns anything the channel throws, such as on a channel that is not + // initialized, into a failure. Future> _queryPollVotes({required int limit, String? next}) async { try { return await channel.queryPollVotes(pollId, sort: _activeSort, filter: _activeFilter, limit: limit, next: next); diff --git a/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart b/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart index c6804a9c33..e33b7b7e1c 100644 --- a/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart @@ -1,8 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat/stream_chat.dart' hide Success; -import 'package:stream_chat_flutter_core/src/paged_value_notifier.dart'; -import 'package:stream_chat_flutter_core/src/stream_poll_vote_list_controller.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'mocks.dart'; diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md index 443017113a..99d6c23d3e 100644 --- a/packages/stream_chat_persistence/CHANGELOG.md +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -11,7 +11,6 @@ - Every persistence call logged its own name at `info`; that call trace is `verbose` now. - A cached poll with a voting visibility the SDK does not name is read back instead of failing. -- A poll's custom data is stored in a `custom` column, and a poll option's under a `custom` key rather than beside the option's fields; the schema version is bumped for it, so the local cache is rebuilt once on upgrade. 🐞 Fixed diff --git a/packages/stream_chat_persistence/lib/src/converter/voting_visibility_converter.dart b/packages/stream_chat_persistence/lib/src/converter/voting_visibility_converter.dart index 6ac4e93c29..c33a94b221 100644 --- a/packages/stream_chat_persistence/lib/src/converter/voting_visibility_converter.dart +++ b/packages/stream_chat_persistence/lib/src/converter/voting_visibility_converter.dart @@ -10,5 +10,5 @@ class VotingVisibilityConverter extends TypeConverter VotingVisibility fromSql(String fromDb) => VotingVisibility(fromDb); @override - String toSql(VotingVisibility value) => value.rawType; + String toSql(VotingVisibility value) => value; } From ad423207b803db0df526d1f0e60ab8b8cdf66779 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 15 Oct 2026 07:44:12 +0200 Subject: [PATCH 28/32] refactor(llc): apply the polls review fixes to channel partial updates Co-Authored-By: Claude Opus 5.5 --- packages/stream_chat/CHANGELOG.md | 4 +- .../lib/src/client/channel/channel.dart | 12 +-- .../stream_chat/lib/src/client/client.dart | 12 +-- .../lib/src/core/models/channel_model.dart | 16 ++-- .../lib/src/core/models/member.dart | 19 +++-- .../update_channel_partial_response.dart | 5 +- .../update_member_partial_response.dart | 5 +- .../lib/src/core/util/serializer.dart | 5 +- .../src/repository/channels_repository.dart | 9 ++- .../repository/mapper/channels_mapper.dart | 14 ++-- .../src/repository/mapper/devices_mapper.dart | 3 +- .../test/src/core/models/channel_test.dart | 79 +++++++++++-------- packages/stream_chat/test/src/fakes.dart | 6 +- packages/stream_chat_persistence/CHANGELOG.md | 1 - .../lib/src/db/drift_chat_database.g.dart | 4 +- .../lib/src/entity/channels.dart | 2 +- .../lib/src/entity/members.dart | 2 +- 17 files changed, 119 insertions(+), 79 deletions(-) diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index b182edc775..167edbae72 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -96,10 +96,10 @@ - `SendAttachmentResponse`, `SendFileResponse`, `SendImageResponse`, `UploadImageResponse` and `UploadFileResponse` are removed in favor of `UploadedFile`. - `AttachmentFileUploaderProvider` receives the client's `Dio` instead of a `StreamHttpClient`, and `StreamAttachmentFileUploader` is built from one. - `StreamChatApi.fileUploader` and `StreamChatApi`'s `attachmentFileUploaderProvider` are removed; pass `attachmentFileUploaderProvider` to `StreamChatClient` instead. -- `updateChannelPartial`, `enableSlowMode` and `disableSlowMode` on `StreamChatClient`, and `updatePartial`, `updateName`, `updateImage`, `enableSlowMode` and `disableSlowMode` on `Channel`, return a `Result` instead of throwing. +- The calls that partially update a channel, on `StreamChatClient` and `Channel` and slow mode included, return a `Result` instead of throwing. - `PartialUpdateChannelResponse` is renamed `UpdateChannelPartialResponse`, and `PartialUpdateMemberResponse` is renamed `UpdateMemberPartialResponse`. - `StreamChatClient.partialMemberUpdate` is renamed `updateMemberPartial`, and `enableSlowdown` and `disableSlowdown` are renamed `enableSlowMode` and `disableSlowMode`. -- `updateMemberPartial`, `pinChannel`, `unpinChannel`, `archiveChannel` and `unarchiveChannel` on `StreamChatClient`, and `pin`, `unpin`, `archive` and `unarchive` on `Channel`, return a `Result` instead of throwing; the `Channel` methods no longer return the bare `Member`. +- The calls that partially update the current user's membership of a channel, on `StreamChatClient` and `Channel` and pinning and archiving included, return a `Result` instead of throwing; the `Channel` ones no longer return the bare `Member`. - `UpdateChannelPartialResponse` and `UpdateMemberPartialResponse` no longer decode from JSON, are immutable, built through a const constructor, compare by value, gain `copyWith`, and their `duration` is a non-nullable `String`. - `UpdateChannelPartialResponse.channel` and `UpdateMemberPartialResponse.channelMember` are nullable, and `UpdateChannelPartialResponse.members` is a non-nullable list. - `StreamChatApi.channel.updateChannelPartial`, `enableSlowdown`, `disableSlowdown` and `updateMemberPartial` are removed; call them on `StreamChatClient` instead. diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index 97ef49217f..7c57a35a1d 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -65,8 +65,9 @@ const incomingTypingStartEventTimeout = 7; class Channel { /// Class that manages a specific channel. /// - /// Optional [custom] and [image] properties can be provided. The [image] - /// is exposed to easily set a key of 'image' on [custom]. + /// The channel starts with the custom data [custom]; a [name] or [image] + /// is stored in it under the `name` or `image` key, replacing any entry + /// [custom] has for that key. Channel( this._client, this._type, @@ -1742,8 +1743,8 @@ class Channel { ); } - /// Sets the fields in [set] and removes the fields named in [unset] on this channel, leaving every other field - /// as it is. + /// Sets the fields in [set] and removes the fields named in [unset] on this + /// channel, leaving every other field as it is. /// /// At least one of [set] and [unset] is required. /// @@ -1756,7 +1757,8 @@ class Channel { return _client.updateChannelPartial(id!, type, set: set, unset: unset); } - /// Enables slow mode on this channel, so members wait [cooldownInterval] seconds between messages. + /// Enables slow mode on this channel, so members wait [cooldownInterval] + /// seconds between messages. Future> enableSlowMode({ required int cooldownInterval, }) async { diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 64dac6371b..70e266c111 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -1251,8 +1251,8 @@ class StreamChatClient { message: message, ); - /// Partially updates a channel: sets the fields in [set] and removes the fields named in [unset], leaving every - /// other field as it is. + /// Partially updates a channel: sets the fields in [set] and removes the + /// fields named in [unset], leaving every other field as it is. /// /// At least one of [set] and [unset] is required. /// @@ -2165,7 +2165,8 @@ class StreamChatClient { ); } - /// Enables slow mode on a channel, so members wait [cooldown] seconds between messages. + /// Enables slow mode on a channel, so members wait [cooldown] seconds between + /// messages. Future> enableSlowMode( String channelId, String channelType, @@ -2327,8 +2328,9 @@ class StreamChatClient { ); } - /// Partially updates the current user's membership of a channel: sets the fields in [set] and removes the fields - /// named in [unset], leaving every other field as it is. + /// Partially updates the current user's membership of a channel: sets the + /// fields in [set] and removes the fields named in [unset], leaving every + /// other field as it is. /// /// At least one of [set] and [unset] is required. Future> updateMemberPartial({ diff --git a/packages/stream_chat/lib/src/core/models/channel_model.dart b/packages/stream_chat/lib/src/core/models/channel_model.dart index 34f558267e..b182c48095 100644 --- a/packages/stream_chat/lib/src/core/models/channel_model.dart +++ b/packages/stream_chat/lib/src/core/models/channel_model.dart @@ -163,11 +163,14 @@ class ChannelModel { @JsonKey(includeIfNull: false) final bool? autoTranslationEnabled; - /// The language messages in this channel are translated into automatically. + /// The language this channel's messages are translated into automatically. @JsonKey(includeIfNull: false) final String? autoTranslationLanguage; - /// This channel's custom data: every key not in [topLevelFields]. + /// This channel's custom data. + /// + /// It may hold a key that is also in [topLevelFields]; [toJson] leaves such + /// an entry out so it never overwrites the field. @JsonKey(name: 'extra_data') final Map custom; @@ -196,7 +199,8 @@ class ChannelModel { @JsonKey(includeToJson: false) final List? filterTags; - /// The JSON keys [ChannelModel.fromJson] reads into named fields; every other key is kept in [custom]. + /// The JSON keys [ChannelModel.fromJson] reads into named fields; every other + /// key is kept in [custom]. static const topLevelFields = [ 'id', 'type', @@ -227,7 +231,8 @@ class ChannelModel { /// Serialize to json /// - /// Writes the entries of [custom] at the root, leaving out any named like one of [topLevelFields]. + /// Writes the entries of [custom] at the root, leaving out any named like one + /// of [topLevelFields]. Map toJson() => Serializer.moveFromExtraDataToRoot( _$ChannelModelToJson(this), topLevelFields: topLevelFields, @@ -319,7 +324,8 @@ class ChannelModel { messageCount: other.messageCount, filterTags: other.filterTags, truncatedBy: other.truncatedBy, - // A channel only carries its auto-translation flag while auto translation is on, so its absence turns it off. + // A channel only carries its auto-translation flag while auto translation + // is on, so its absence turns it off. autoTranslationEnabled: other.autoTranslationEnabled ?? false, autoTranslationLanguage: other.autoTranslationLanguage, ); diff --git a/packages/stream_chat/lib/src/core/models/member.dart b/packages/stream_chat/lib/src/core/models/member.dart index 6095afd3d9..0a2990c992 100644 --- a/packages/stream_chat/lib/src/core/models/member.dart +++ b/packages/stream_chat/lib/src/core/models/member.dart @@ -46,7 +46,8 @@ class Member extends Equatable { Serializer.moveToExtraDataFromRoot(json, topLevelFields), ); - /// The JSON keys [Member.fromJson] reads into named fields, or leaves out; every other key is kept in [custom]. + /// The JSON keys [Member.fromJson] reads into named fields, or leaves out; + /// every other key is kept in [custom]. static const topLevelFields = [ 'user', 'invite_accepted_at', @@ -68,7 +69,8 @@ class Member extends Equatable { 'ban_from_future_channels', 'future_channel_ban_expires', 'deleted_at', - // Listed so a `role` key never lands in [custom]; [channelRole] holds the member's role. + // Listed so a `role` key never lands in [custom]; [channelRole] holds the + // member's role. 'role', ]; @@ -120,7 +122,10 @@ class Member extends Equatable { /// visible to other channel members. final List deletedMessages; - /// This member's custom data: every key not in [topLevelFields]. + /// This member's custom data. + /// + /// It may hold a key that is also in [topLevelFields]; [toJson] leaves such + /// an entry out so it never overwrites the field. @JsonKey(name: 'extra_data') final Map custom; @@ -128,7 +133,8 @@ class Member extends Equatable { @JsonKey(includeIfNull: false) final bool? notificationsMuted; - /// The status of this membership, such as `member`, or `pending` while an invite is outstanding. + /// The status of this membership, such as `member`, or `pending` while an + /// invite is outstanding. @JsonKey(includeIfNull: false) final String? status; @@ -138,7 +144,7 @@ class Member extends Equatable { /// The date at which this member's ban from future channels expires. /// - /// Null when that ban has no expiry. + /// Null when there is no such ban or it has no expiry. @JsonKey(includeIfNull: false) final DateTime? futureChannelBanExpires; @@ -198,7 +204,8 @@ class Member extends Equatable { /// Serialize to json /// - /// Writes the entries of [custom] at the root, leaving out any named like one of [topLevelFields]. + /// Writes the entries of [custom] at the root, leaving out any named like one + /// of [topLevelFields]. Map toJson() => Serializer.moveFromExtraDataToRoot( _$MemberToJson(this), topLevelFields: topLevelFields, diff --git a/packages/stream_chat/lib/src/core/models/response/update_channel_partial_response.dart b/packages/stream_chat/lib/src/core/models/response/update_channel_partial_response.dart index fbca6035b2..edb9d68ebd 100644 --- a/packages/stream_chat/lib/src/core/models/response/update_channel_partial_response.dart +++ b/packages/stream_chat/lib/src/core/models/response/update_channel_partial_response.dart @@ -5,8 +5,9 @@ import '../member.dart'; part 'update_channel_partial_response.freezed.dart'; -/// A channel after some of its fields were set or unset, returned by [StreamChatClient.updateChannelPartial] and -/// [Channel.updatePartial]. +/// A channel after some of its fields were set or unset, returned by +/// [StreamChatClient.updateChannelPartial] and the calls built on it, such as +/// [Channel.updatePartial] and [Channel.enableSlowMode]. @freezed class UpdateChannelPartialResponse with _$UpdateChannelPartialResponse { /// Creates a new [UpdateChannelPartialResponse]. diff --git a/packages/stream_chat/lib/src/core/models/response/update_member_partial_response.dart b/packages/stream_chat/lib/src/core/models/response/update_member_partial_response.dart index c44e5f771d..df3e97931d 100644 --- a/packages/stream_chat/lib/src/core/models/response/update_member_partial_response.dart +++ b/packages/stream_chat/lib/src/core/models/response/update_member_partial_response.dart @@ -4,8 +4,9 @@ import '../member.dart'; part 'update_member_partial_response.freezed.dart'; -/// The current user's membership of a channel after some of its fields were set or unset, returned by -/// [StreamChatClient.updateMemberPartial] and [Channel.pin]. +/// The current user's membership of a channel after some of its fields were +/// set or unset, returned by [StreamChatClient.updateMemberPartial] and the +/// calls built on it, such as [Channel.pin] and [Channel.archive]. @freezed class UpdateMemberPartialResponse with _$UpdateMemberPartialResponse { /// Creates a new [UpdateMemberPartialResponse]. diff --git a/packages/stream_chat/lib/src/core/util/serializer.dart b/packages/stream_chat/lib/src/core/util/serializer.dart index eb4d817e8c..b0f8b02de1 100644 --- a/packages/stream_chat/lib/src/core/util/serializer.dart +++ b/packages/stream_chat/lib/src/core/util/serializer.dart @@ -42,8 +42,9 @@ class Serializer { /// A copy of [json] with the entries of `extra_data` moved to the root. /// - /// An entry in `extra_data` replaces a root entry with the same key, unless the key is one of [topLevelFields]: - /// such an entry is left out, so custom data cannot overwrite a field. + /// An entry in `extra_data` replaces a root entry with the same key, unless + /// the key is one of [topLevelFields]: such an entry is left out, so custom + /// data cannot overwrite a field. /// /// ```dart /// Serializer.moveFromExtraDataToRoot({ diff --git a/packages/stream_chat/lib/src/repository/channels_repository.dart b/packages/stream_chat/lib/src/repository/channels_repository.dart index 2d0e995089..c55d8836a4 100644 --- a/packages/stream_chat/lib/src/repository/channels_repository.dart +++ b/packages/stream_chat/lib/src/repository/channels_repository.dart @@ -12,8 +12,8 @@ class ChannelsRepository { final api.DefaultApi _api; - /// Partially updates a channel: sets the fields in [set] and removes the fields named in [unset], leaving every - /// other field as it is. + /// Partially updates a channel: sets the fields in [set] and removes the + /// fields named in [unset], leaving every other field as it is. /// /// At least one of [set] and [unset] is required. Future> updateChannelPartial( @@ -31,8 +31,9 @@ class ChannelsRepository { return result.map((response) => response.toModel()); } - /// Partially updates the current user's membership of a channel: sets the fields in [set] and removes the fields - /// named in [unset], leaving every other field as it is. + /// Partially updates the current user's membership of a channel: sets the + /// fields in [set] and removes the fields named in [unset], leaving every + /// other field as it is. /// /// At least one of [set] and [unset] is required. Future> updateMemberPartial( 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 305c247633..85e2475d2d 100644 --- a/packages/stream_chat/lib/src/repository/mapper/channels_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/channels_mapper.dart @@ -48,9 +48,11 @@ extension ChannelResponseMapper on api.ChannelResponse { /// Maps a generated [api.ChannelConfigWithInfo] to a [ChannelConfig]. extension ChannelConfigWithInfoMapper on api.ChannelConfigWithInfo { - // TODO(openapi-migration): revisit exposing the config fields ChannelConfig does not model yet: - // allowedFlagReasons, automodBehavior, automodThresholds, blocklist, blocklistBehavior, blocklists, - // countMessages, customEvents, grants, name, partitionSize, partitionTtl, quotes and reminders. + // TODO(openapi-migration): revisit exposing the config fields ChannelConfig + // does not model yet: allowedFlagReasons, automodBehavior, + // automodThresholds, blocklist, blocklistBehavior, blocklists, + // countMessages, customEvents, grants, name, partitionSize, partitionTtl, + // quotes and reminders. /// Converts this configuration into a [ChannelConfig]. ChannelConfig toModel() => ChannelConfig( @@ -134,7 +136,8 @@ extension ChannelMemberResponseMapper on api.ChannelMemberResponse { ); } -/// Maps a generated [api.UpdateChannelPartialResponse] to an [UpdateChannelPartialResponse]. +/// Maps a generated [api.UpdateChannelPartialResponse] to an +/// [UpdateChannelPartialResponse]. extension UpdateChannelPartialResponseMapper on api.UpdateChannelPartialResponse { /// Converts this response into an [UpdateChannelPartialResponse]. UpdateChannelPartialResponse toModel() => UpdateChannelPartialResponse( @@ -144,7 +147,8 @@ extension UpdateChannelPartialResponseMapper on api.UpdateChannelPartialResponse ); } -/// Maps a generated [api.UpdateMemberPartialResponse] to an [UpdateMemberPartialResponse]. +/// Maps a generated [api.UpdateMemberPartialResponse] to an +/// [UpdateMemberPartialResponse]. extension UpdateMemberPartialResponseMapper on api.UpdateMemberPartialResponse { /// Converts this response into an [UpdateMemberPartialResponse]. UpdateMemberPartialResponse toModel() => UpdateMemberPartialResponse( diff --git a/packages/stream_chat/lib/src/repository/mapper/devices_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/devices_mapper.dart index fb340e5c80..b0edf4bc49 100644 --- a/packages/stream_chat/lib/src/repository/mapper/devices_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/devices_mapper.dart @@ -18,7 +18,8 @@ extension ListDevicesResponseMapper on api.ListDevicesResponse { ); } -/// Maps a [PushProvider] to the generated [api.CreateDeviceRequestPushProvider]. +/// Maps a [PushProvider] to the generated +/// [api.CreateDeviceRequestPushProvider]. extension PushProviderRequestMapper on PushProvider { /// Converts this provider into an [api.CreateDeviceRequestPushProvider]. api.CreateDeviceRequestPushProvider toRequest() => api.CreateDeviceRequestPushProvider.fromJson(rawType); diff --git a/packages/stream_chat/test/src/core/models/channel_test.dart b/packages/stream_chat/test/src/core/models/channel_test.dart index d494bf8845..e6488447b5 100644 --- a/packages/stream_chat/test/src/core/models/channel_test.dart +++ b/packages/stream_chat/test/src/core/models/channel_test.dart @@ -1,7 +1,6 @@ // ignore_for_file: avoid_redundant_argument_values -import 'package:stream_chat/src/core/models/channel_model.dart'; -import 'package:stream_chat/src/core/models/user.dart'; +import 'package:stream_chat/stream_chat.dart'; import 'package:test/test.dart'; import '../../utils.dart'; @@ -63,38 +62,14 @@ void main() { }); }); - test('ChannelModel writes its flags to JSON and reads them back', () { - final truncatedAt = DateTime.utc(2026, 3, 2); - final channel = ChannelModel( - cid: 'test:cid', - disabled: false, - hidden: true, - muted: true, - blocked: false, - truncatedAt: truncatedAt, - truncatedBy: User(id: 'truncator'), - autoTranslationEnabled: true, - autoTranslationLanguage: 'fr', - custom: const {'color': 'red'}, - ); - final json = { - 'id': 'cid', - 'type': 'test', - 'frozen': false, - 'cooldown': 0, - 'disabled': false, - 'hidden': true, - 'muted': true, - 'blocked': false, - 'truncated_at': truncatedAt.toIso8601String(), - 'truncated_by': User(id: 'truncator').toJson(), - 'auto_translation_enabled': true, - 'auto_translation_language': 'fr', - 'color': 'red', - }; + test('ChannelModel.toJson writes its flags at the top level', () { + expect(_flaggedChannel().toJson(), _flaggedChannelJson()); + }); + + test('ChannelModel.fromJson reads back the flags toJson writes', () { + final json = _flaggedChannel().toJson(); - expect(channel.toJson(), json); - expect(ChannelModel.fromJson(channel.toJson()).toJson(), json); + expect(ChannelModel.fromJson(json).toJson(), _flaggedChannelJson()); }); test('ChannelModel keeps custom data named like a field apart from the field', () { @@ -193,6 +168,13 @@ void main() { expect(merged.blocked, isTrue); expect(merged.truncatedAt, DateTime.utc(2026)); expect(merged.truncatedBy?.id, 'truncator'); + }); + + test('ChannelModel.merge takes the custom data from the other channel', () { + final channel = ChannelModel(cid: 'test:cid', custom: const {'color': 'blue'}); + + final merged = channel.merge(ChannelModel(cid: 'test:cid', custom: const {'color': 'red'})); + expect(merged.custom, {'color': 'red'}); }); @@ -298,3 +280,34 @@ void main() { expect(channel.lastUpdatedAt, DateTime(2023, 6, 10)); }); } + +final _truncatedAt = DateTime.utc(2026, 3, 2); + +// A channel with every flag set, and no custom data. +ChannelModel _flaggedChannel() => ChannelModel( + cid: 'test:cid', + disabled: false, + hidden: true, + muted: true, + blocked: false, + truncatedAt: _truncatedAt, + truncatedBy: User(id: 'truncator'), + autoTranslationEnabled: true, + autoTranslationLanguage: 'fr', +); + +// The JSON [_flaggedChannel] writes. +Map _flaggedChannelJson() => { + 'id': 'cid', + 'type': 'test', + 'frozen': false, + 'cooldown': 0, + 'disabled': false, + 'hidden': true, + 'muted': true, + 'blocked': false, + 'truncated_at': _truncatedAt.toIso8601String(), + 'truncated_by': User(id: 'truncator').toJson(), + 'auto_translation_enabled': true, + 'auto_translation_language': 'fr', +}; diff --git a/packages/stream_chat/test/src/fakes.dart b/packages/stream_chat/test/src/fakes.dart index 1fb05629d7..b8a1c1dd28 100644 --- a/packages/stream_chat/test/src/fakes.dart +++ b/packages/stream_chat/test/src/fakes.dart @@ -273,7 +273,8 @@ api.ChannelMemberResponse fakeChannelMemberResponse({Map custom userId: 'member', ); -/// A generated [api.UserResponse] with the id [id] and only the fields it requires. +/// A generated [api.UserResponse] with the id [id] and only the fields it +/// requires. api.UserResponse fakeUserResponse(String id) => api.UserResponse( banned: false, blockedUserIds: const [], @@ -287,7 +288,8 @@ api.UserResponse fakeUserResponse(String id) => api.UserResponse( updatedAt: DateTime.utc(2025), ); -/// The [Member] that [fakeChannelMemberResponse] maps to, with its default custom data. +/// The [Member] that [fakeChannelMemberResponse] maps to, with its default +/// custom data. Member fakeChannelMember() => Member( user: fakeUser('member'), userId: 'member', diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md index bfcc659c9f..d216518c39 100644 --- a/packages/stream_chat_persistence/CHANGELOG.md +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -11,7 +11,6 @@ - Every persistence call logged its own name at `info`; that call trace is `verbose` now. - A cached poll with a voting visibility the SDK does not name is read back instead of failing. -- A channel's and a member's custom data are stored in a `custom` column, and the fields it used to carry in columns of their own; the schema version is bumped for it, so the local cache is rebuilt once on upgrade. 🐞 Fixed diff --git a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart index ce1fb22ed2..00ac17f110 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 @@ -653,7 +653,7 @@ class ChannelEntity extends DataClass implements Insertable { /// Whether messages in the channel are translated automatically. final bool? autoTranslationEnabled; - /// The language messages in the channel are translated into automatically. + /// The language the channel's messages are translated into automatically. final String? autoTranslationLanguage; /// Custom data attached to the channel. @@ -10542,7 +10542,7 @@ class MemberEntity extends DataClass implements Insertable { /// The date at which the ban will expire. /// - /// Null when the ban has no expiry. + /// Null when the member is not banned or the ban has no expiry. final DateTime? banExpires; /// True if the member is shadow banned from the channel diff --git a/packages/stream_chat_persistence/lib/src/entity/channels.dart b/packages/stream_chat_persistence/lib/src/entity/channels.dart index 4d1be31002..d511731722 100644 --- a/packages/stream_chat_persistence/lib/src/entity/channels.dart +++ b/packages/stream_chat_persistence/lib/src/entity/channels.dart @@ -71,7 +71,7 @@ class Channels extends Table { /// Whether messages in the channel are translated automatically. BoolColumn get autoTranslationEnabled => boolean().nullable()(); - /// The language messages in the channel are translated into automatically. + /// The language the channel's messages are translated into automatically. TextColumn get autoTranslationLanguage => text().nullable()(); /// Custom data attached to the channel. diff --git a/packages/stream_chat_persistence/lib/src/entity/members.dart b/packages/stream_chat_persistence/lib/src/entity/members.dart index 672fa05310..946aaf6b6a 100644 --- a/packages/stream_chat_persistence/lib/src/entity/members.dart +++ b/packages/stream_chat_persistence/lib/src/entity/members.dart @@ -30,7 +30,7 @@ class Members extends Table { /// The date at which the ban will expire. /// - /// Null when the ban has no expiry. + /// Null when the member is not banned or the ban has no expiry. DateTimeColumn get banExpires => dateTime().nullable()(); /// True if the member is shadow banned from the channel From 114b4ad148c8f6a75b41f516ddca664d498ff9b9 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 15 Oct 2026 15:29:54 +0200 Subject: [PATCH 29/32] refactor(llc): address the review on the partial updates and custom data Co-Authored-By: Claude Opus 5.5 --- openapi-migration/15-partial-updates.md | 5 ++-- openapi-migration/README.md | 3 ++- openapi-migration/tool/generate_plan.py | 8 +++--- packages/stream_chat/CHANGELOG.md | 8 +++--- .../lib/src/client/channel/channel.dart | 13 ++++----- .../lib/src/core/models/channel_model.dart | 27 ++++++------------- .../lib/src/core/models/channel_model.g.dart | 4 +-- .../lib/src/core/models/member.dart | 16 +++++------ .../test/src/client/channel/channel_test.dart | 26 +++++++++++++----- .../client_update_channel_partial_test.dart | 15 ++++++----- .../test/src/core/models/channel_test.dart | 18 ------------- .../test/src/core/models/member_test.dart | 3 +-- .../test/src/channel/channel_image_test.dart | 2 +- 13 files changed, 67 insertions(+), 81 deletions(-) diff --git a/openapi-migration/15-partial-updates.md b/openapi-migration/15-partial-updates.md index de2ed60f2b..61c8393dc2 100644 --- a/openapi-migration/15-partial-updates.md +++ b/openapi-migration/15-partial-updates.md @@ -37,7 +37,8 @@ - **`enableSlowdown` and `disableSlowdown` move with it, renamed `enableSlowMode` and `disableSlowMode`** to match the `Channel` helpers and `ChannelCapability.slowMode`. Both are `updateChannelPartial` with a fixed `cooldown`, so they call the repository's one method rather than - an operation of their own. + an operation of their own. `disableSlowMode` sets `cooldown` to 0: unsetting it is refused on v1 and + v2, as it is a reserved field. - **The v2 route is the v1 handler.** `lib/chat/routes.go` mounts `UpdateChannelPartial` in the shared `coreRoutes`; it is neither gated nor in beta. On v2 it also accepts `custom.` paths in `set` and `unset`, and still takes flat keys, so `{'name': …}` updates the same field. @@ -81,7 +82,7 @@ ## Risks -- `disableSlowMode` unsets `cooldown`, which the server rejects on both v1 and v2 as a reserved field. It fails before and after this group; the fix is tracked separately, so the request is unchanged. +- Until [11](11-channels-and-members.md) moves `updateChannel`, the partial and full updates report failures differently: a caller using both reads a `Result` from one and catches an exception from the other. ## Definition of done diff --git a/openapi-migration/README.md b/openapi-migration/README.md index 823b54d3bb..9e1a6eb0e4 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -160,7 +160,8 @@ includes a named response that carries only `duration` today, such as `HideChann 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 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. + `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. ### Temporary adapters diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 146141434e..8276da959c 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -855,7 +855,8 @@ def match(verb, path): - **`enableSlowdown` and `disableSlowdown` move with it, renamed `enableSlowMode` and `disableSlowMode`** to match the `Channel` helpers and `ChannelCapability.slowMode`. Both are `updateChannelPartial` with a fixed `cooldown`, so they call the repository's one method rather than - an operation of their own. + an operation of their own. `disableSlowMode` sets `cooldown` to 0: unsetting it is refused on v1 and + v2, as it is a reserved field. - **The v2 route is the v1 handler.** `lib/chat/routes.go` mounts `UpdateChannelPartial` in the shared `coreRoutes`; it is neither gated nor in beta. On v2 it also accepts `custom.` paths in `set` and `unset`, and still takes flat keys, so `{'name': …}` updates the same field. @@ -898,8 +899,9 @@ def match(verb, path): on the backend leaving the keys out when false or empty. """), risks=[ - '`disableSlowMode` unsets `cooldown`, which the server rejects on both v1 and v2 as a reserved field. ' - 'It fails before and after this group; the fix is tracked separately, so the request is unchanged.', + 'Until [11](11-channels-and-members.md) moves `updateChannel`, the partial and full updates report ' + 'failures differently: a caller using both reads a `Result` from one and catches an exception from the ' + 'other.', ], done=DONE.replace('- [ ]', '- [x]'), ), diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 167edbae72..b6b9c340d9 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -96,16 +96,16 @@ - `SendAttachmentResponse`, `SendFileResponse`, `SendImageResponse`, `UploadImageResponse` and `UploadFileResponse` are removed in favor of `UploadedFile`. - `AttachmentFileUploaderProvider` receives the client's `Dio` instead of a `StreamHttpClient`, and `StreamAttachmentFileUploader` is built from one. - `StreamChatApi.fileUploader` and `StreamChatApi`'s `attachmentFileUploaderProvider` are removed; pass `attachmentFileUploaderProvider` to `StreamChatClient` instead. -- The calls that partially update a channel, on `StreamChatClient` and `Channel` and slow mode included, return a `Result` instead of throwing. +- The partial channel updates on `StreamChatClient` and `Channel`, slow mode included, return a `Result` instead of throwing. - `PartialUpdateChannelResponse` is renamed `UpdateChannelPartialResponse`, and `PartialUpdateMemberResponse` is renamed `UpdateMemberPartialResponse`. - `StreamChatClient.partialMemberUpdate` is renamed `updateMemberPartial`, and `enableSlowdown` and `disableSlowdown` are renamed `enableSlowMode` and `disableSlowMode`. -- The calls that partially update the current user's membership of a channel, on `StreamChatClient` and `Channel` and pinning and archiving included, return a `Result` instead of throwing; the `Channel` ones no longer return the bare `Member`. +- The partial membership updates on `StreamChatClient` and `Channel`, pinning and archiving included, return a `Result` instead of throwing; the `Channel` ones no longer return the bare `Member`. - `UpdateChannelPartialResponse` and `UpdateMemberPartialResponse` no longer decode from JSON, are immutable, built through a const constructor, compare by value, gain `copyWith`, and their `duration` is a non-nullable `String`. - `UpdateChannelPartialResponse.channel` and `UpdateMemberPartialResponse.channelMember` are nullable, and `UpdateChannelPartialResponse.members` is a non-nullable list. - `StreamChatApi.channel.updateChannelPartial`, `enableSlowdown`, `disableSlowdown` and `updateMemberPartial` are removed; call them on `StreamChatClient` instead. - `MemberUpdatePayload` and `MemberUpdateType` are removed; pass `set: {'pinned': true}` or `unset: ['pinned']` (and `archived`) to `updateMemberPartial`, or call `pinChannel`, `archiveChannel` and their counterparts. - `ChannelModel.extraData` and `Member.extraData` are renamed `custom`, and so are `Channel.extraData`, `Channel.extraDataStream` (now `customStream`), and the `extraData` argument of the `Channel` constructor and of `StreamChatClient.channel`. -- `ChannelModel.disabled`, `hidden`, `muted`, `blocked`, `truncatedAt`, `truncatedBy` and the auto-translation settings, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, `futureChannelBanExpires` and `deletedAt`, are fields instead of entries in the custom data, so `custom` no longer holds `hidden`, `status` and the rest, and an entry with that name in `custom` or in `copyWith(custom:)` no longer sets the field. +- Several channel and member values the custom data used to hold, such as `ChannelModel.hidden`, `ChannelModel.truncatedAt` and `Member.status`, are fields instead: `custom` no longer holds them, and an entry with one of their names in `custom` or in `copyWith(custom:)` no longer sets the field. - `ChannelModel.custom` and `Member.custom` hold the custom data exactly as the API returns it, and `toJson` leaves out an entry named like one of the model's own fields. - `Member.custom` no longer holds `role`; read `channelRole`. @@ -137,7 +137,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 `ChannelModel.muted`, `blocked`, `truncatedBy`, `autoTranslationEnabled` and `autoTranslationLanguage`, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, `futureChannelBanExpires`, `deletedAt` and `topLevelFields`; the constructors and `copyWith` accept the new fields. +- Added `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. 🔒 Security diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index 7c57a35a1d..56358aa786 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -525,14 +525,11 @@ class Channel { return state!.channelState.channel?.team; } - /// Channel custom data. - Map get custom { - var data = state?.channelState.channel?.custom; - if (data == null || data.isEmpty) { - data = _custom; - } - return data; - } + /// This channel's custom data. + /// + /// Before the channel is initialized, this is the custom data it was created + /// with, updated by any assignment to [name], [image] or [custom]. + Map get custom => state?.channelState.channel?.custom ?? _custom; /// List of user permissions on this channel List get ownCapabilities => state?.channelState.channel?.ownCapabilities ?? []; diff --git a/packages/stream_chat/lib/src/core/models/channel_model.dart b/packages/stream_chat/lib/src/core/models/channel_model.dart index b182c48095..08e542c79d 100644 --- a/packages/stream_chat/lib/src/core/models/channel_model.dart +++ b/packages/stream_chat/lib/src/core/models/channel_model.dart @@ -125,40 +125,30 @@ class ChannelModel { @JsonKey(includeIfNull: false) final int cooldown; - /// True if the channel is disabled + /// Whether this channel is disabled. @JsonKey(includeIfNull: false) final bool? disabled; - /// True if the channel is hidden + /// Whether this channel is hidden for the current user. @JsonKey(includeIfNull: false) final bool? hidden; - /// True if the channel is muted by the current user + /// Whether this channel is muted for the current user. @JsonKey(includeIfNull: false) final bool? muted; - /// True if the channel is blocked by the current user + /// Whether this channel is blocked by the current user. @JsonKey(includeIfNull: false) final bool? blocked; - /// The date of the last time channel got truncated - @JsonKey(includeIfNull: false, fromJson: _dateFromJson) + /// The date this channel was last truncated. + @JsonKey(includeIfNull: false) final DateTime? truncatedAt; - static DateTime? _dateFromJson(Object? value) => switch (value) { - final String date when date.isNotEmpty => DateTime.parse(date), - _ => null, - }; - /// The user that last truncated this channel. - @JsonKey(includeIfNull: false, fromJson: _userFromJson) + @JsonKey(includeIfNull: false) final User? truncatedBy; - static User? _userFromJson(Object? value) => switch (value) { - final Map json when json['id'] is String => User.fromJson(json), - _ => null, - }; - /// Whether messages in this channel are translated automatically. @JsonKey(includeIfNull: false) final bool? autoTranslationEnabled; @@ -324,8 +314,7 @@ class ChannelModel { messageCount: other.messageCount, filterTags: other.filterTags, truncatedBy: other.truncatedBy, - // A channel only carries its auto-translation flag while auto translation - // is on, so its absence turns it off. + // Unlike the other flags, a missing auto-translation flag turns it off. autoTranslationEnabled: other.autoTranslationEnabled ?? false, autoTranslationLanguage: other.autoTranslationLanguage, ); diff --git a/packages/stream_chat/lib/src/core/models/channel_model.g.dart b/packages/stream_chat/lib/src/core/models/channel_model.g.dart index 9cc0438ddf..a1ebbd3c05 100644 --- a/packages/stream_chat/lib/src/core/models/channel_model.g.dart +++ b/packages/stream_chat/lib/src/core/models/channel_model.g.dart @@ -27,10 +27,10 @@ ChannelModel _$ChannelModelFromJson(Map json) => ChannelModel( hidden: json['hidden'] as bool?, muted: json['muted'] as bool?, blocked: json['blocked'] as bool?, - truncatedAt: ChannelModel._dateFromJson(json['truncated_at']), + truncatedAt: json['truncated_at'] == null ? null : DateTime.parse(json['truncated_at'] as String), messageCount: (json['message_count'] as num?)?.toInt(), filterTags: (json['filter_tags'] as List?)?.map((e) => e as String).toList(), - truncatedBy: ChannelModel._userFromJson(json['truncated_by']), + truncatedBy: json['truncated_by'] == null ? null : User.fromJson(json['truncated_by'] as Map), autoTranslationEnabled: json['auto_translation_enabled'] as bool?, autoTranslationLanguage: json['auto_translation_language'] as String?, ); diff --git a/packages/stream_chat/lib/src/core/models/member.dart b/packages/stream_chat/lib/src/core/models/member.dart index 0a2990c992..bc864d41f9 100644 --- a/packages/stream_chat/lib/src/core/models/member.dart +++ b/packages/stream_chat/lib/src/core/models/member.dart @@ -43,12 +43,12 @@ class Member extends Equatable { /// Create a new instance from a json factory Member.fromJson(Map json) => _$MemberFromJson( - Serializer.moveToExtraDataFromRoot(json, topLevelFields), + Serializer.moveToExtraDataFromRoot(json, _topLevelFields), ); - /// The JSON keys [Member.fromJson] reads into named fields, or leaves out; - /// every other key is kept in [custom]. - static const topLevelFields = [ + // The JSON keys [Member.fromJson] reads into named fields, or leaves out; + // every other key is kept in [custom]. + static const _topLevelFields = [ 'user', 'invite_accepted_at', 'invite_rejected_at', @@ -124,8 +124,8 @@ class Member extends Equatable { /// This member's custom data. /// - /// It may hold a key that is also in [topLevelFields]; [toJson] leaves such - /// an entry out so it never overwrites the field. + /// It may hold a key named like one of the member's own fields; [toJson] + /// leaves such an entry out so it never overwrites the field. @JsonKey(name: 'extra_data') final Map custom; @@ -205,10 +205,10 @@ class Member extends Equatable { /// Serialize to json /// /// Writes the entries of [custom] at the root, leaving out any named like one - /// of [topLevelFields]. + /// of the member's own fields. Map toJson() => Serializer.moveFromExtraDataToRoot( _$MemberToJson(this), - topLevelFields: topLevelFields, + topLevelFields: _topLevelFields, ); @override diff --git a/packages/stream_chat/test/src/client/channel/channel_test.dart b/packages/stream_chat/test/src/client/channel/channel_test.dart index 6ad86432f6..4fa59a965d 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -236,6 +236,20 @@ void main() { expect(() => channel.custom = {'name': 'test-channel-name'}, throwsA(isA())); }); + test('Channel.custom reads the current channel, even when its custom data is empty', () { + final channel = Channel.fromState( + client, + ChannelState( + channel: ChannelModel(cid: channelCid, custom: const {'name': 'Old name'}), + ), + ); + addTearDown(channel.dispose); + + channel.state!.updateChannelState(ChannelState(channel: ChannelModel(cid: channelCid))); + + expect(channel.custom, isEmpty); + }); + test('should throw if trying to set `image`', () { expect(() => channel.image = 'https://stream.io/some-image', throwsA(isA())); }); @@ -4683,7 +4697,7 @@ void main() { ).called(1); }); - test("Channel.enableSlowMode returns the client's enableSlowMode response", () async { + test('Channel.enableSlowMode enables slow mode on this channel and returns the updated channel', () async { const cooldown = 10; final channelModel = ChannelModel( @@ -4714,7 +4728,7 @@ void main() { ).called(1); }); - test("Channel.disableSlowMode returns the client's disableSlowMode response", () async { + test('Channel.disableSlowMode disables slow mode on this channel and returns the updated channel', () async { final channelModel = ChannelModel( cid: channelCid, ); @@ -4850,7 +4864,7 @@ void main() { verify(() => client.showChannel(channelId, channelType)).called(1); }); - test("Channel.archive returns the client's archiveChannel response", () async { + test('Channel.archive archives this channel and returns the updated membership', () async { final response = UpdateMemberPartialResponse( duration: '0.01ms', channelMember: Member(userId: 'test-user-id'), @@ -4866,7 +4880,7 @@ void main() { verify(() => client.archiveChannel(channelId: channelId, channelType: channelType)).called(1); }); - test("Channel.unarchive returns the client's unarchiveChannel response", () async { + test('Channel.unarchive unarchives this channel and returns the updated membership', () async { final response = UpdateMemberPartialResponse( duration: '0.01ms', channelMember: Member(userId: 'test-user-id'), @@ -4882,7 +4896,7 @@ void main() { verify(() => client.unarchiveChannel(channelId: channelId, channelType: channelType)).called(1); }); - test("Channel.pin returns the client's pinChannel response", () async { + test('Channel.pin pins this channel and returns the updated membership', () async { final response = UpdateMemberPartialResponse( duration: '0.01ms', channelMember: Member(userId: 'test-user-id'), @@ -4900,7 +4914,7 @@ void main() { verify(() => client.pinChannel(channelId: channelId, channelType: channelType)).called(1); }); - test("Channel.unpin returns the client's unpinChannel response", () async { + test('Channel.unpin unpins this channel and returns the updated membership', () async { final response = UpdateMemberPartialResponse( duration: '0.01ms', channelMember: Member(userId: 'test-user-id'), diff --git a/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart b/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart index 7b3ca1591e..6e1f477aff 100644 --- a/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart +++ b/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart @@ -265,18 +265,18 @@ void main() { expect(res.exceptionOrNull(), error); }); - test('StreamChatClient.enableSlowMode sends the cooldown and returns a success', () async { + test('StreamChatClient.enableSlowMode sends the cooldown and returns the updated channel', () async { const request = api.UpdateChannelPartialRequest(set: {'cooldown': 30}); final defaultApi = MockDefaultApi(); when( () => defaultApi.updateChannelPartial(type: 'messaging', id: 'general', updateChannelPartialRequest: request), - ).thenAnswer((_) async => Result.success(_updateChannelPartialResponse())); + ).thenAnswer((_) async => Result.success(_updateChannelPartialResponse(channel: _channelResponse(cooldown: 30)))); final client = _client(defaultApi); final res = await client.enableSlowMode('general', 'messaging', 30); - expect(res.isSuccess, isTrue); + expect(res.getOrNull()!.channel!.cooldown, 30); verify( () => defaultApi.updateChannelPartial(type: 'messaging', id: 'general', updateChannelPartialRequest: request), @@ -301,18 +301,18 @@ void main() { expect(res.exceptionOrNull(), error); }); - test('StreamChatClient.disableSlowMode sends the cooldown set to 0 and returns a success', () async { + test('StreamChatClient.disableSlowMode sends the cooldown set to 0 and returns the updated channel', () async { const request = api.UpdateChannelPartialRequest(set: {'cooldown': 0}); final defaultApi = MockDefaultApi(); when( () => defaultApi.updateChannelPartial(type: 'messaging', id: 'general', updateChannelPartialRequest: request), - ).thenAnswer((_) async => Result.success(_updateChannelPartialResponse())); + ).thenAnswer((_) async => Result.success(_updateChannelPartialResponse(channel: _channelResponse(cooldown: 0)))); final client = _client(defaultApi); final res = await client.disableSlowMode('general', 'messaging'); - expect(res.isSuccess, isTrue); + expect(res.getOrNull()!.channel!.cooldown, 0); verify( () => defaultApi.updateChannelPartial(type: 'messaging', id: 'general', updateChannelPartialRequest: request), @@ -354,6 +354,7 @@ api.UpdateChannelPartialResponse _updateChannelPartialResponse({api.ChannelRespo // A channel with every field set. api.ChannelResponse _channelResponse({ + int cooldown = 30, Map custom = const { 'name': 'General', 'image': 'https://example.com/general.png', @@ -422,7 +423,7 @@ api.ChannelResponse _channelResponse({ urlEnrichment: true, userMessageReminders: true, ), - cooldown: 30, + cooldown: cooldown, createdAt: DateTime.utc(2026), createdBy: fakeUserResponse('creator'), custom: custom, diff --git a/packages/stream_chat/test/src/core/models/channel_test.dart b/packages/stream_chat/test/src/core/models/channel_test.dart index e6488447b5..34b5468531 100644 --- a/packages/stream_chat/test/src/core/models/channel_test.dart +++ b/packages/stream_chat/test/src/core/models/channel_test.dart @@ -186,24 +186,6 @@ void main() { expect(merged.autoTranslationEnabled, isFalse); }); - test('ChannelModel.fromJson reads no truncatedBy from a value that is not a user with an id', () { - final noIdChannel = ChannelModel.fromJson(const { - 'id': 'channel', - 'type': 'test', - 'truncated_by': {'name': 'No id'}, - }); - final textChannel = ChannelModel.fromJson(const {'id': 'channel', 'type': 'test', 'truncated_by': 'someone'}); - - expect(noIdChannel.truncatedBy, isNull); - expect(textChannel.truncatedBy, isNull); - }); - - test('ChannelModel.fromJson reads no truncatedAt from an empty string', () { - final channel = ChannelModel.fromJson(const {'id': 'channel', 'type': 'test', 'truncated_at': ''}); - - expect(channel.truncatedAt, isNull); - }); - test('.name should fetch from custom if available', () { final channel = ChannelModel( cid: 'test:channel', diff --git a/packages/stream_chat/test/src/core/models/member_test.dart b/packages/stream_chat/test/src/core/models/member_test.dart index 74376bb55d..13fd630862 100644 --- a/packages/stream_chat/test/src/core/models/member_test.dart +++ b/packages/stream_chat/test/src/core/models/member_test.dart @@ -1,5 +1,4 @@ -import 'package:stream_chat/src/core/models/member.dart'; -import 'package:stream_chat/src/core/models/user.dart'; +import 'package:stream_chat/stream_chat.dart'; import 'package:test/test.dart'; import '../../utils.dart'; diff --git a/packages/stream_chat_flutter/test/src/channel/channel_image_test.dart b/packages/stream_chat_flutter/test/src/channel/channel_image_test.dart index 763981b093..0b74cf6a46 100644 --- a/packages/stream_chat_flutter/test/src/channel/channel_image_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/channel_image_test.dart @@ -7,7 +7,7 @@ import '../mocks.dart'; void main() { testWidgets( - 'it should show the image in channel.extraData', + 'StreamChannelAvatar shows the channel image', (tester) async { final client = MockClient(); final clientState = MockClientState(); From 653838d445a580c7d6a9bf8364e89e4f4d069e97 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 15 Oct 2026 19:41:44 +0200 Subject: [PATCH 30/32] fix(llc): clear the auto-translation language in ChannelModel.merge and tighten the partial update fixtures Co-Authored-By: Claude Opus 5.5 --- openapi-migration/11-channels-and-members.md | 6 +-- openapi-migration/tool/generate_plan.py | 6 +-- packages/stream_chat/CHANGELOG.md | 2 +- .../lib/src/client/channel/channel.dart | 6 +-- .../lib/src/core/models/channel_model.dart | 38 ++++++++++--------- .../update_channel_partial_response.dart | 2 +- .../update_member_partial_response.dart | 6 +-- .../client_update_channel_partial_test.dart | 36 +++++++++--------- .../test/src/core/models/channel_test.dart | 1 + packages/stream_chat/test/src/fakes.dart | 12 +++--- 10 files changed, 59 insertions(+), 56 deletions(-) diff --git a/openapi-migration/11-channels-and-members.md b/openapi-migration/11-channels-and-members.md index bffb0763b4..ea221647a0 100644 --- a/openapi-migration/11-channels-and-members.md +++ b/openapi-migration/11-channels-and-members.md @@ -82,9 +82,9 @@ restructures those classes and re-points the mapper. - **Every field `ChannelResponse` and `ChannelMemberResponse` declare is reachable on ours,** as a field: [15](15-partial-updates.md) promoted the ones v10 parked in `extraData` and renamed it `custom`. - Three are left out: the channel's `hide_messages_before` and `mute_expires_at`, which the server - never fills on the channel (a mute's expiry is `ChannelMute.expires`), and the member's deprecated - `role`. The nested config types drop `ChatPreferences.distinctChannelMessages` and `Command`'s + Three are left out: the channel's `hide_messages_before`, which the server returns on the + channel-state entry rather than on the channel, its `mute_expires_at`, since a mute's expiry is + read from `ChannelMute.expires`, and the member's deprecated `role`. The nested config types drop `ChatPreferences.distinctChannelMessages` and `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 one way, for group 09 to settle: `truncatedBy`, `createdBy` and each member's user go diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 8276da959c..99a56b36ae 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -717,9 +717,9 @@ def match(verb, path): restructures those classes and re-points the mapper. - **Every field `ChannelResponse` and `ChannelMemberResponse` declare is reachable on ours,** as a field: [15](15-partial-updates.md) promoted the ones v10 parked in `extraData` and renamed it `custom`. - Three are left out: the channel's `hide_messages_before` and `mute_expires_at`, which the server - never fills on the channel (a mute's expiry is `ChannelMute.expires`), and the member's deprecated - `role`. The nested config types drop `ChatPreferences.distinctChannelMessages` and `Command`'s + Three are left out: the channel's `hide_messages_before`, which the server returns on the + channel-state entry rather than on the channel, its `mute_expires_at`, since a mute's expiry is + read from `ChannelMute.expires`, and the member's deprecated `role`. The nested config types drop `ChatPreferences.distinctChannelMessages` and `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 one way, for group 09 to settle: `truncatedBy`, `createdBy` and each member's user go diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index b6b9c340d9..f065d667af 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -102,7 +102,7 @@ - The partial membership updates on `StreamChatClient` and `Channel`, pinning and archiving included, return a `Result` instead of throwing; the `Channel` ones no longer return the bare `Member`. - `UpdateChannelPartialResponse` and `UpdateMemberPartialResponse` no longer decode from JSON, are immutable, built through a const constructor, compare by value, gain `copyWith`, and their `duration` is a non-nullable `String`. - `UpdateChannelPartialResponse.channel` and `UpdateMemberPartialResponse.channelMember` are nullable, and `UpdateChannelPartialResponse.members` is a non-nullable list. -- `StreamChatApi.channel.updateChannelPartial`, `enableSlowdown`, `disableSlowdown` and `updateMemberPartial` are removed; call them on `StreamChatClient` instead. +- `StreamChatApi.channel`'s partial update and slow mode methods are removed; use the `StreamChatClient` methods instead. - `MemberUpdatePayload` and `MemberUpdateType` are removed; pass `set: {'pinned': true}` or `unset: ['pinned']` (and `archived`) to `updateMemberPartial`, or call `pinChannel`, `archiveChannel` and their counterparts. - `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`. - Several channel and member values the custom data used to hold, such as `ChannelModel.hidden`, `ChannelModel.truncatedAt` and `Member.status`, are fields instead: `custom` no longer holds them, and an entry with one of their names in `custom` or in `copyWith(custom:)` no longer sets the field. diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index 56358aa786..6ce116e076 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -65,9 +65,9 @@ const incomingTypingStartEventTimeout = 7; class Channel { /// Class that manages a specific channel. /// - /// The channel starts with the custom data [custom]; a [name] or [image] - /// is stored in it under the `name` or `image` key, replacing any entry - /// [custom] has for that key. + /// The channel starts with the custom data [custom]; a [name] or [image] is + /// stored in it under the `name` or `image` key, replacing any entry [custom] + /// has for that key. Channel( this._client, this._type, diff --git a/packages/stream_chat/lib/src/core/models/channel_model.dart b/packages/stream_chat/lib/src/core/models/channel_model.dart index 08e542c79d..4414699622 100644 --- a/packages/stream_chat/lib/src/core/models/channel_model.dart +++ b/packages/stream_chat/lib/src/core/models/channel_model.dart @@ -285,36 +285,38 @@ class ChannelModel { autoTranslationLanguage: autoTranslationLanguage ?? this.autoTranslationLanguage, ); - /// Returns a new [ChannelModel] that is a combination of this channelModel - /// and the given [other] channelModel. + /// Returns a copy of this channel updated with the fields [other] carries. + /// + /// A field [other] leaves out keeps its value here, except the + /// auto-translation settings: when [other] leaves them out, auto translation + /// is off. ChannelModel merge(ChannelModel? other) { if (other == null) return this; - return copyWith( + return ChannelModel( id: other.id, type: other.type, cid: other.cid, - ownCapabilities: other.ownCapabilities, + ownCapabilities: other.ownCapabilities ?? ownCapabilities, config: other.config, - createdBy: other.createdBy, + createdBy: other.createdBy ?? createdBy, frozen: other.frozen, - lastMessageAt: other.lastMessageAt, + lastMessageAt: other.lastMessageAt ?? lastMessageAt, createdAt: other.createdAt, updatedAt: other.updatedAt, - deletedAt: other.deletedAt, + deletedAt: other.deletedAt ?? deletedAt, memberCount: other.memberCount, - members: other.members, + members: other.members ?? members, custom: other.custom, - team: other.team, + team: other.team ?? team, cooldown: other.cooldown, - disabled: other.disabled, - hidden: other.hidden, - muted: other.muted, - blocked: other.blocked, - truncatedAt: other.truncatedAt, - messageCount: other.messageCount, - filterTags: other.filterTags, - truncatedBy: other.truncatedBy, - // Unlike the other flags, a missing auto-translation flag turns it off. + disabled: other.disabled ?? disabled, + hidden: other.hidden ?? hidden, + muted: other.muted ?? muted, + blocked: other.blocked ?? blocked, + truncatedAt: other.truncatedAt ?? truncatedAt, + messageCount: other.messageCount ?? messageCount, + filterTags: other.filterTags ?? filterTags, + truncatedBy: other.truncatedBy ?? truncatedBy, autoTranslationEnabled: other.autoTranslationEnabled ?? false, autoTranslationLanguage: other.autoTranslationLanguage, ); diff --git a/packages/stream_chat/lib/src/core/models/response/update_channel_partial_response.dart b/packages/stream_chat/lib/src/core/models/response/update_channel_partial_response.dart index edb9d68ebd..72f9e32127 100644 --- a/packages/stream_chat/lib/src/core/models/response/update_channel_partial_response.dart +++ b/packages/stream_chat/lib/src/core/models/response/update_channel_partial_response.dart @@ -5,7 +5,7 @@ import '../member.dart'; part 'update_channel_partial_response.freezed.dart'; -/// A channel after some of its fields were set or unset, returned by +/// The result of partially updating a channel, returned by /// [StreamChatClient.updateChannelPartial] and the calls built on it, such as /// [Channel.updatePartial] and [Channel.enableSlowMode]. @freezed diff --git a/packages/stream_chat/lib/src/core/models/response/update_member_partial_response.dart b/packages/stream_chat/lib/src/core/models/response/update_member_partial_response.dart index df3e97931d..be6f90d9d7 100644 --- a/packages/stream_chat/lib/src/core/models/response/update_member_partial_response.dart +++ b/packages/stream_chat/lib/src/core/models/response/update_member_partial_response.dart @@ -4,9 +4,9 @@ import '../member.dart'; part 'update_member_partial_response.freezed.dart'; -/// The current user's membership of a channel after some of its fields were -/// set or unset, returned by [StreamChatClient.updateMemberPartial] and the -/// calls built on it, such as [Channel.pin] and [Channel.archive]. +/// The result of partially updating the current user's membership of a channel, +/// returned by [StreamChatClient.updateMemberPartial] and the calls built on +/// it, such as [Channel.pin] and [Channel.archive]. @freezed class UpdateMemberPartialResponse with _$UpdateMemberPartialResponse { /// Creates a new [UpdateMemberPartialResponse]. diff --git a/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart b/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart index 6e1f477aff..71cff03dc9 100644 --- a/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart +++ b/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart @@ -64,8 +64,8 @@ void main() { expect(channel.cooldown, 30); expect(channel.disabled, isFalse); expect(channel.hidden, isTrue); - expect(channel.muted, isTrue); - expect(channel.blocked, isFalse); + expect(channel.muted, isFalse); + expect(channel.blocked, isTrue); expect(channel.truncatedAt, DateTime.utc(2026, 3, 2)); expect(channel.truncatedBy, fakeUser('truncator')); expect(channel.autoTranslationEnabled, isTrue); @@ -203,11 +203,11 @@ void main() { expect(config.updatedAt, DateTime.utc(2025, 2)); expect(config.maxMessageLength, 5000); expect(config.messageRetention, 'infinite'); - expect(config.mutes, isTrue); + expect(config.mutes, isFalse); expect(config.reactions, isTrue); - expect(config.readEvents, isTrue); + expect(config.readEvents, isFalse); expect(config.replies, isTrue); - expect(config.search, isTrue); + expect(config.search, isFalse); expect(config.polls, isTrue); expect(config.pushLevel, PushLevel.directMentions); expect(config.pushNotifications, isFalse); @@ -224,13 +224,13 @@ void main() { ), ); expect(config.typingEvents, isTrue); - expect(config.uploads, isTrue); + expect(config.uploads, isFalse); expect(config.urlEnrichment, isTrue); - expect(config.skipLastMsgUpdateForSystemMsgs, isTrue); + expect(config.skipLastMsgUpdateForSystemMsgs, isFalse); expect(config.userMessageReminders, isTrue); - expect(config.markMessagesPending, isTrue); + expect(config.markMessagesPending, isFalse); expect(config.deliveryEvents, isTrue); - expect(config.sharedLocations, isTrue); + expect(config.sharedLocations, isFalse); }); test('StreamChatClient.updateChannelPartial returns a null channel when the response has none', () async { @@ -363,7 +363,7 @@ api.ChannelResponse _channelResponse({ }) => api.ChannelResponse( autoTranslationEnabled: true, autoTranslationLanguage: 'fr', - blocked: false, + blocked: true, cid: 'messaging:general', config: api.ChannelConfigWithInfo( allowedFlagReasons: const ['spam'], @@ -399,10 +399,10 @@ api.ChannelResponse _channelResponse({ grants: const { 'user': ['read-channel'], }, - markMessagesPending: true, + markMessagesPending: false, maxMessageLength: 5000, messageRetention: 'infinite', - mutes: true, + mutes: false, name: 'messaging', partitionSize: 10, partitionTtl: '24h', @@ -411,15 +411,15 @@ api.ChannelResponse _channelResponse({ pushNotifications: false, quotes: true, reactions: true, - readEvents: true, + readEvents: false, reminders: true, replies: true, - search: true, - sharedLocations: true, - skipLastMsgUpdateForSystemMsgs: true, + search: false, + sharedLocations: false, + skipLastMsgUpdateForSystemMsgs: false, typingEvents: true, updatedAt: DateTime.utc(2025, 2), - uploads: true, + uploads: false, urlEnrichment: true, userMessageReminders: true, ), @@ -439,7 +439,7 @@ api.ChannelResponse _channelResponse({ members: [fakeChannelMemberResponse()], messageCount: 42, muteExpiresAt: DateTime.utc(2026, 5), - muted: true, + muted: false, ownCapabilities: const [api.ChannelOwnCapability.sendMessage, api.ChannelOwnCapability.readEvents], team: 'blue', truncatedAt: DateTime.utc(2026, 3, 2), diff --git a/packages/stream_chat/test/src/core/models/channel_test.dart b/packages/stream_chat/test/src/core/models/channel_test.dart index 34b5468531..7ab998ff8c 100644 --- a/packages/stream_chat/test/src/core/models/channel_test.dart +++ b/packages/stream_chat/test/src/core/models/channel_test.dart @@ -184,6 +184,7 @@ void main() { final merged = channel.merge(ChannelModel(cid: 'test:cid')); expect(merged.autoTranslationEnabled, isFalse); + expect(merged.autoTranslationLanguage, isNull); }); test('.name should fetch from custom if available', () { diff --git a/packages/stream_chat/test/src/fakes.dart b/packages/stream_chat/test/src/fakes.dart index b8a1c1dd28..b9e329d2a7 100644 --- a/packages/stream_chat/test/src/fakes.dart +++ b/packages/stream_chat/test/src/fakes.dart @@ -262,11 +262,11 @@ api.ChannelMemberResponse fakeChannelMemberResponse({Map custom inviteAcceptedAt: DateTime.utc(2026, 1, 2), inviteRejectedAt: DateTime.utc(2026, 1, 3), invited: true, - isModerator: true, - notificationsMuted: true, + isModerator: false, + notificationsMuted: false, pinnedAt: DateTime.utc(2026, 1, 4), role: 'user', - shadowBanned: true, + shadowBanned: false, status: 'member', updatedAt: DateTime.utc(2026, 2), user: fakeUserResponse('member'), @@ -297,17 +297,17 @@ Member fakeChannelMember() => Member( inviteRejectedAt: DateTime.utc(2026, 1, 3), invited: true, channelRole: 'channel_moderator', - isModerator: true, + isModerator: false, createdAt: DateTime.utc(2026), updatedAt: DateTime.utc(2026, 2), banned: true, banExpires: DateTime.utc(2026, 6), - shadowBanned: true, + shadowBanned: false, pinnedAt: DateTime.utc(2026, 1, 4), archivedAt: DateTime.utc(2026, 1, 5), deletedMessages: const ['message-1'], custom: const {'nickname': 'Mo'}, - notificationsMuted: true, + notificationsMuted: false, status: 'member', banFromFutureChannels: true, futureChannelBanExpires: DateTime.utc(2026, 7), From bc1efef66a1ee95ccdf96eef90c5c4f531347b27 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 15 Oct 2026 20:27:13 +0200 Subject: [PATCH 31/32] refactor(llc)!: leave the channel and member fields out of toJson and keep role in custom Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 9 +-- openapi-migration/11-channels-and-members.md | 10 ++-- openapi-migration/15-partial-updates.md | 7 ++- openapi-migration/tool/generate_plan.py | 17 +++--- packages/stream_chat/CHANGELOG.md | 3 +- .../lib/src/core/models/channel_model.dart | 16 ++--- .../lib/src/core/models/channel_model.g.dart | 8 --- .../lib/src/core/models/member.dart | 17 +++--- .../lib/src/core/models/member.g.dart | 5 -- .../stream_chat/test/fixtures/channel.json | 5 ++ .../client_update_channel_partial_test.dart | 59 ------------------ .../client_update_member_partial_test.dart | 50 ---------------- .../test/src/core/models/channel_test.dart | 60 ++----------------- .../test/src/core/models/member_test.dart | 41 ++----------- 14 files changed, 56 insertions(+), 251 deletions(-) diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index 02ba07ee2b..6e703d207a 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -264,10 +264,9 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `MemberUpdatePayload(pinned: true).toJson()` / `MemberUpdateType.pinned.name` | `{'pinned': true}` / `'pinned'` | `removed` | Same for `archived`. Or call `pinChannel` / `unpinChannel` / `archiveChannel` / `unarchiveChannel` | | `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 | -| `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 | +| `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 | -| `member.extraData['role']` | `Member.channelRole` | `removed` | The server deprecated `role` for `channel_role` | | _(more added per feature as PRs land)_ | | | | --- @@ -755,9 +754,11 @@ final hidden = channel.hidden; final shown = model.copyWith(hidden: false); ``` +**`ChannelModel.toJson` and `Member.toJson` no longer write these fields**, where v10 wrote them at the root with the +rest of the custom data. Read them from the model rather than from its JSON. + **`custom` is the custom data exactly as the API returns it.** An entry named like one of the model's own fields stays in `custom` and leaves the field alone; `toJson` leaves it out, so it cannot overwrite the field. -`Member.custom` no longer holds `role`, which the server deprecated: read `channelRole`. **Filter and sort on a flag through its declared field,** such as `ChannelFilterField.hidden`, rather than `.custom('hidden')`, which reads `custom` locally and finds nothing there. diff --git a/openapi-migration/11-channels-and-members.md b/openapi-migration/11-channels-and-members.md index ea221647a0..8bce522fd4 100644 --- a/openapi-migration/11-channels-and-members.md +++ b/openapi-migration/11-channels-and-members.md @@ -84,11 +84,13 @@ field: [15](15-partial-updates.md) promoted the ones v10 parked in `extraData` and renamed it `custom`. Three are left out: the channel's `hide_messages_before`, which the server returns on the channel-state entry rather than on the channel, its `mute_expires_at`, since a mute's expiry is - read from `ChannelMute.expires`, and the member's deprecated `role`. The nested config types drop `ChatPreferences.distinctChannelMessages` and `Command`'s - `createdAt` / `updatedAt`, which ours do not model. + read from `ChannelMute.expires`, and the member's `role`, which neither the iOS nor the Android + SDK models. The nested config types drop `ChatPreferences.distinctChannelMessages` and + `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 one way, for group 09 to settle: `truncatedBy`, `createdBy` and each member's user go - through `user_mapper.dart`, which drops the user fields `User` does not model. + 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 + 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/15-partial-updates.md b/openapi-migration/15-partial-updates.md index 61c8393dc2..668ce08446 100644 --- a/openapi-migration/15-partial-updates.md +++ b/openapi-migration/15-partial-updates.md @@ -69,10 +69,11 @@ `muted`, `blocked`, `truncatedAt`, `truncatedBy`, `autoTranslationEnabled` and `autoTranslationLanguage`, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, `futureChannelBanExpires` and `deletedAt`, are fields with persistence columns; `truncatedBy` is - read back through the users table. `Channel.extraData`, `extraDataStream`, and the `extraData` - argument of the `Channel` constructor and of `StreamChatClient.channel` are renamed with it. + read back through the users table. `toJson` leaves them out: nothing in the SDK sends a channel + or member as JSON. `Channel.extraData`, `extraDataStream`, and the `extraData` argument of the + `Channel` constructor and of `StreamChatClient.channel` are renamed with it. - **`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. `Member` leaves the deprecated `role` out of `custom`. + so it cannot overwrite the field. - **`ChannelModel.merge` keeps a flag the incoming channel leaves out, and turns auto translation off when it is missing,** as before. Checked live on v1 and v2: queries always send `hidden` and `blocked`; events leave them out, except the `channel.hidden` a block sends; `muted` is never sent on diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 99a56b36ae..d50328bd14 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -719,11 +719,13 @@ def match(verb, path): field: [15](15-partial-updates.md) promoted the ones v10 parked in `extraData` and renamed it `custom`. Three are left out: the channel's `hide_messages_before`, which the server returns on the channel-state entry rather than on the channel, its `mute_expires_at`, since a mute's expiry is - read from `ChannelMute.expires`, and the member's deprecated `role`. The nested config types drop `ChatPreferences.distinctChannelMessages` and `Command`'s - `createdAt` / `updatedAt`, which ours do not model. + read from `ChannelMute.expires`, and the member's `role`, which neither the iOS nor the Android + SDK models. The nested config types drop `ChatPreferences.distinctChannelMessages` and + `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 one way, for group 09 to settle: `truncatedBy`, `createdBy` and each member's user go - through `user_mapper.dart`, which drops the user fields `User` does not model. + 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 + 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. """), @@ -887,10 +889,11 @@ def match(verb, path): `muted`, `blocked`, `truncatedAt`, `truncatedBy`, `autoTranslationEnabled` and `autoTranslationLanguage`, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, `futureChannelBanExpires` and `deletedAt`, are fields with persistence columns; `truncatedBy` is - read back through the users table. `Channel.extraData`, `extraDataStream`, and the `extraData` - argument of the `Channel` constructor and of `StreamChatClient.channel` are renamed with it. + read back through the users table. `toJson` leaves them out: nothing in the SDK sends a channel + or member as JSON. `Channel.extraData`, `extraDataStream`, and the `extraData` argument of the + `Channel` constructor and of `StreamChatClient.channel` are renamed with it. - **`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. `Member` leaves the deprecated `role` out of `custom`. + so it cannot overwrite the field. - **`ChannelModel.merge` keeps a flag the incoming channel leaves out, and turns auto translation off when it is missing,** as before. Checked live on v1 and v2: queries always send `hidden` and `blocked`; events leave them out, except the `channel.hidden` a block sends; `muted` is never sent on diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index f065d667af..d71658ade0 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -105,9 +105,8 @@ - `StreamChatApi.channel`'s partial update and slow mode methods are removed; use the `StreamChatClient` methods instead. - `MemberUpdatePayload` and `MemberUpdateType` are removed; pass `set: {'pinned': true}` or `unset: ['pinned']` (and `archived`) to `updateMemberPartial`, or call `pinChannel`, `archiveChannel` and their counterparts. - `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`. -- Several channel and member values the custom data used to hold, such as `ChannelModel.hidden`, `ChannelModel.truncatedAt` and `Member.status`, are fields instead: `custom` no longer holds them, and an entry with one of their names in `custom` or in `copyWith(custom:)` no longer sets the field. +- Several channel and member values the custom data used to hold, such as `ChannelModel.hidden`, `ChannelModel.truncatedAt` and `Member.status`, are fields instead: `custom` no longer holds them, `toJson` no longer writes them, and an entry with one of their names in `custom` or in `copyWith(custom:)` no longer sets the field. - `ChannelModel.custom` and `Member.custom` hold the custom data exactly as the API returns it, and `toJson` leaves out an entry named like one of the model's own fields. -- `Member.custom` no longer holds `role`; read `channelRole`. 🐞 Fixed diff --git a/packages/stream_chat/lib/src/core/models/channel_model.dart b/packages/stream_chat/lib/src/core/models/channel_model.dart index 4414699622..d4015ad88c 100644 --- a/packages/stream_chat/lib/src/core/models/channel_model.dart +++ b/packages/stream_chat/lib/src/core/models/channel_model.dart @@ -126,35 +126,35 @@ class ChannelModel { final int cooldown; /// Whether this channel is disabled. - @JsonKey(includeIfNull: false) + @JsonKey(includeToJson: false) final bool? disabled; /// Whether this channel is hidden for the current user. - @JsonKey(includeIfNull: false) + @JsonKey(includeToJson: false) final bool? hidden; /// Whether this channel is muted for the current user. - @JsonKey(includeIfNull: false) + @JsonKey(includeToJson: false) final bool? muted; /// Whether this channel is blocked by the current user. - @JsonKey(includeIfNull: false) + @JsonKey(includeToJson: false) final bool? blocked; /// The date this channel was last truncated. - @JsonKey(includeIfNull: false) + @JsonKey(includeToJson: false) final DateTime? truncatedAt; /// The user that last truncated this channel. - @JsonKey(includeIfNull: false) + @JsonKey(includeToJson: false) final User? truncatedBy; /// Whether messages in this channel are translated automatically. - @JsonKey(includeIfNull: false) + @JsonKey(includeToJson: false) final bool? autoTranslationEnabled; /// The language this channel's messages are translated into automatically. - @JsonKey(includeIfNull: false) + @JsonKey(includeToJson: false) final String? autoTranslationLanguage; /// This channel's custom data. diff --git a/packages/stream_chat/lib/src/core/models/channel_model.g.dart b/packages/stream_chat/lib/src/core/models/channel_model.g.dart index a1ebbd3c05..7f07d7cb70 100644 --- a/packages/stream_chat/lib/src/core/models/channel_model.g.dart +++ b/packages/stream_chat/lib/src/core/models/channel_model.g.dart @@ -40,13 +40,5 @@ Map _$ChannelModelToJson(ChannelModel instance) => custom; /// Whether this member muted notifications for the channel. - @JsonKey(includeIfNull: false) + @JsonKey(includeToJson: false) final bool? notificationsMuted; /// The status of this membership, such as `member`, or `pending` while an /// invite is outstanding. - @JsonKey(includeIfNull: false) + @JsonKey(includeToJson: false) final String? status; /// Whether this member is banned from future channels. - @JsonKey(includeIfNull: false) + @JsonKey(includeToJson: false) final bool? banFromFutureChannels; /// The date at which this member's ban from future channels expires. /// /// Null when there is no such ban or it has no expiry. - @JsonKey(includeIfNull: false) + @JsonKey(includeToJson: false) final DateTime? futureChannelBanExpires; /// The date at which this membership was deleted. /// /// Null while the member belongs to the channel. - @JsonKey(includeIfNull: false) + @JsonKey(includeToJson: false) final DateTime? deletedAt; /// Creates a copy of [Member] with specified attributes overridden. diff --git a/packages/stream_chat/lib/src/core/models/member.g.dart b/packages/stream_chat/lib/src/core/models/member.g.dart index 93e7ea5aeb..dd0364c3cd 100644 --- a/packages/stream_chat/lib/src/core/models/member.g.dart +++ b/packages/stream_chat/lib/src/core/models/member.g.dart @@ -49,9 +49,4 @@ Map _$MemberToJson(Member instance) => { 'updated_at': instance.updatedAt.toIso8601String(), 'deleted_messages': instance.deletedMessages, 'extra_data': instance.custom, - 'notifications_muted': ?instance.notificationsMuted, - 'status': ?instance.status, - 'ban_from_future_channels': ?instance.banFromFutureChannels, - 'future_channel_ban_expires': ?instance.futureChannelBanExpires?.toIso8601String(), - 'deleted_at': ?instance.deletedAt?.toIso8601String(), }; diff --git a/packages/stream_chat/test/fixtures/channel.json b/packages/stream_chat/test/fixtures/channel.json index 123335e4ef..913c7edcf7 100644 --- a/packages/stream_chat/test/fixtures/channel.json +++ b/packages/stream_chat/test/fixtures/channel.json @@ -2,6 +2,11 @@ "id": "test", "type": "livestream", "cid": "livestream:test", + "disabled": false, + "hidden": true, + "muted": true, + "blocked": false, + "truncated_at": "2020-01-29T03:22:47.636130Z", "truncated_by": { "id": "truncator", "name": "Truncator" diff --git a/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart b/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart index 71cff03dc9..4c3ceefcec 100644 --- a/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart +++ b/packages/stream_chat/test/src/client/client_update_channel_partial_test.dart @@ -92,65 +92,6 @@ void main() { }); }); - test( - 'StreamChatClient.updateChannelPartial keeps channel custom data named like a channel field apart from the field', - () async { - final response = _updateChannelPartialResponse( - channel: api.ChannelResponse( - cid: 'messaging:general', - id: 'general', - type: 'messaging', - createdAt: DateTime.utc(2026), - updatedAt: DateTime.utc(2026, 2), - custom: const { - 'topic': 'anything', - 'cid': 'shadowed', - 'disabled': 'shadowed', - 'hidden': 'shadowed', - 'muted': 'shadowed', - 'blocked': 'shadowed', - 'truncated_at': 'shadowed', - 'truncated_by': 'shadowed', - 'mute_expires_at': 'shadowed', - 'auto_translation_enabled': 'shadowed', - 'auto_translation_language': 'shadowed', - 'hide_messages_before': 'shadowed', - }, - disabled: false, - frozen: false, - ), - ); - const request = api.UpdateChannelPartialRequest(set: {'name': 'General'}); - - final defaultApi = MockDefaultApi(); - when( - () => defaultApi.updateChannelPartial(type: 'messaging', id: 'general', updateChannelPartialRequest: request), - ).thenAnswer((_) async => Result.success(response)); - final client = _client(defaultApi); - - final res = await client.updateChannelPartial('general', 'messaging', set: const {'name': 'General'}); - - final channel = res.getOrNull()!.channel!; - expect(channel.custom, { - 'topic': 'anything', - 'cid': 'shadowed', - 'disabled': 'shadowed', - 'hidden': 'shadowed', - 'muted': 'shadowed', - 'blocked': 'shadowed', - 'truncated_at': 'shadowed', - 'truncated_by': 'shadowed', - 'mute_expires_at': 'shadowed', - 'auto_translation_enabled': 'shadowed', - 'auto_translation_language': 'shadowed', - 'hide_messages_before': 'shadowed', - }); - expect(channel.cid, 'messaging:general'); - expect(channel.disabled, isFalse); - expect(channel.hidden, isNull); - }, - ); - test('StreamChatClient.updateChannelPartial falls back to the defaults for channel fields left out', () async { final response = _updateChannelPartialResponse( channel: api.ChannelResponse( diff --git a/packages/stream_chat/test/src/client/client_update_member_partial_test.dart b/packages/stream_chat/test/src/client/client_update_member_partial_test.dart index ee2f82a1fc..ddb4ff313e 100644 --- a/packages/stream_chat/test/src/client/client_update_member_partial_test.dart +++ b/packages/stream_chat/test/src/client/client_update_member_partial_test.dart @@ -64,56 +64,6 @@ void main() { expect(res.exceptionOrNull(), error); }); - test( - 'StreamChatClient.updateMemberPartial keeps custom data named like a member field apart from the field', - () async { - final response = api.UpdateMemberPartialResponse( - duration: '0.01ms', - channelMember: api.ChannelMemberResponse( - banned: false, - channelRole: 'channel_member', - createdAt: DateTime.utc(2026), - updatedAt: DateTime.utc(2026, 2), - custom: const { - 'nickname': 'Mo', - 'user_id': 'shadowed', - 'notifications_muted': 'shadowed', - 'status': 'shadowed', - 'role': 'shadowed', - 'ban_from_future_channels': 'shadowed', - 'future_channel_ban_expires': 'shadowed', - 'deleted_at': 'shadowed', - }, - notificationsMuted: false, - shadowBanned: false, - ), - ); - const request = api.UpdateMemberPartialRequest(set: {'a': 1}); - - final defaultApi = MockDefaultApi(); - when( - () => defaultApi.updateMemberPartial(type: 'messaging', id: 'general', updateMemberPartialRequest: request), - ).thenAnswer((_) async => Result.success(response)); - final client = _client(defaultApi); - - final res = await client.updateMemberPartial(channelId: 'general', channelType: 'messaging', set: const {'a': 1}); - - final member = res.getOrNull()!.channelMember!; - expect(member.custom, { - 'nickname': 'Mo', - 'user_id': 'shadowed', - 'notifications_muted': 'shadowed', - 'status': 'shadowed', - 'role': 'shadowed', - 'ban_from_future_channels': 'shadowed', - 'future_channel_ban_expires': 'shadowed', - 'deleted_at': 'shadowed', - }); - expect(member.notificationsMuted, isFalse); - expect(member.status, isNull); - }, - ); - test('StreamChatClient.updateMemberPartial falls back to the defaults for member fields left out', () async { final response = api.UpdateMemberPartialResponse( duration: '0.01ms', diff --git a/packages/stream_chat/test/src/core/models/channel_test.dart b/packages/stream_chat/test/src/core/models/channel_test.dart index 7ab998ff8c..37dd08af1c 100644 --- a/packages/stream_chat/test/src/core/models/channel_test.dart +++ b/packages/stream_chat/test/src/core/models/channel_test.dart @@ -15,6 +15,11 @@ void main() { expect(channel.custom['cats'], equals(true)); expect(channel.custom['fruit'], equals(['bananas', 'apples'])); expect(channel.cooldown, equals(0)); + expect(channel.disabled, isFalse); + expect(channel.hidden, isTrue); + expect(channel.muted, isTrue); + expect(channel.blocked, isFalse); + expect(channel.truncatedAt, DateTime.parse('2020-01-29T03:22:47.636130Z')); expect(channel.truncatedBy?.id, 'truncator'); expect(channel.truncatedBy?.name, 'Truncator'); expect(channel.autoTranslationEnabled, isTrue); @@ -62,27 +67,6 @@ void main() { }); }); - test('ChannelModel.toJson writes its flags at the top level', () { - expect(_flaggedChannel().toJson(), _flaggedChannelJson()); - }); - - test('ChannelModel.fromJson reads back the flags toJson writes', () { - final json = _flaggedChannel().toJson(); - - expect(ChannelModel.fromJson(json).toJson(), _flaggedChannelJson()); - }); - - test('ChannelModel keeps custom data named like a field apart from the field', () { - final channel = ChannelModel( - cid: 'test:cid', - hidden: true, - custom: const {'hidden': 'custom', 'id': 'custom', 'color': 'red'}, - ); - - expect(channel.hidden, isTrue); - expect(channel.custom, {'hidden': 'custom', 'id': 'custom', 'color': 'red'}); - }); - test('ChannelModel.toJson leaves out custom data named like a field', () { final channel = ChannelModel( cid: 'test:cid', @@ -95,7 +79,6 @@ void main() { 'type': 'test', 'frozen': false, 'cooldown': 0, - 'hidden': true, 'color': 'red', }); }); @@ -109,7 +92,7 @@ void main() { autoTranslationLanguage: 'fr', ); - final newChannel = channel.copyWith(custom: const {'hidden': false}); + final newChannel = channel.copyWith(team: 'blue'); expect(newChannel.hidden, isTrue); expect(newChannel.truncatedBy?.id, 'truncator'); @@ -263,34 +246,3 @@ void main() { expect(channel.lastUpdatedAt, DateTime(2023, 6, 10)); }); } - -final _truncatedAt = DateTime.utc(2026, 3, 2); - -// A channel with every flag set, and no custom data. -ChannelModel _flaggedChannel() => ChannelModel( - cid: 'test:cid', - disabled: false, - hidden: true, - muted: true, - blocked: false, - truncatedAt: _truncatedAt, - truncatedBy: User(id: 'truncator'), - autoTranslationEnabled: true, - autoTranslationLanguage: 'fr', -); - -// The JSON [_flaggedChannel] writes. -Map _flaggedChannelJson() => { - 'id': 'cid', - 'type': 'test', - 'frozen': false, - 'cooldown': 0, - 'disabled': false, - 'hidden': true, - 'muted': true, - 'blocked': false, - 'truncated_at': _truncatedAt.toIso8601String(), - 'truncated_by': User(id: 'truncator').toJson(), - 'auto_translation_enabled': true, - 'auto_translation_language': 'fr', -}; diff --git a/packages/stream_chat/test/src/core/models/member_test.dart b/packages/stream_chat/test/src/core/models/member_test.dart index 13fd630862..2b240ad6ef 100644 --- a/packages/stream_chat/test/src/core/models/member_test.dart +++ b/packages/stream_chat/test/src/core/models/member_test.dart @@ -20,50 +20,17 @@ void main() { expect(member.deletedAt, DateTime.parse('2020-01-29T22:17:30.95443Z')); }); - test('Member writes its fields to JSON and reads them back', () { - final member = Member( - userId: 'user', - createdAt: DateTime.utc(2020), - updatedAt: DateTime.utc(2020), - notificationsMuted: true, - status: 'member', - banFromFutureChannels: true, - futureChannelBanExpires: DateTime.utc(2021), - deletedAt: DateTime.utc(2020, 2), - custom: const {'color': 'red'}, - ); - - expect(Member.fromJson(member.toJson()), member); - }); - - test('Member keeps custom data named like a field apart from the field', () { - final member = Member( - userId: 'user', - status: 'member', - custom: const {'status': 'custom', 'color': 'red'}, - ); - - expect(member.status, 'member'); - expect(member.custom, {'status': 'custom', 'color': 'red'}); - }); - test('Member.toJson leaves out custom data named like a field', () { final member = Member( userId: 'user', - status: 'member', - custom: const {'status': 'custom', 'color': 'red'}, + channelRole: 'channel_member', + custom: const {'channel_role': 'custom', 'color': 'red'}, ); - expect(member.toJson()['status'], 'member'); + expect(member.toJson()['channel_role'], 'channel_member'); expect(member.toJson()['color'], 'red'); }); - test('Member.toJson leaves out a custom role', () { - final member = Member(userId: 'user', custom: const {'role': 'custom'}); - - expect(member.toJson().containsKey('role'), isFalse); - }); - test('Member.copyWith replaces the fields it is given', () { final member = Member( userId: 'user', @@ -89,7 +56,7 @@ void main() { expect(newMember.deletedAt, DateTime.utc(2023)); }); - test('Members that differ only in a promoted field are not equal', () { + test('Member equality includes notificationsMuted, status, the future channel ban and deletedAt', () { final createdAt = DateTime.utc(2020); Member member({ bool? notificationsMuted, From e742e0308c09b64273522ae1ee36249e1f73feed Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 15 Oct 2026 20:45:53 +0200 Subject: [PATCH 32/32] docs(llc): re-wrap the Member key list comment at 80 Co-Authored-By: Claude Opus 5.5 --- packages/stream_chat/lib/src/core/models/member.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stream_chat/lib/src/core/models/member.dart b/packages/stream_chat/lib/src/core/models/member.dart index 0605db31c7..a3b1006baf 100644 --- a/packages/stream_chat/lib/src/core/models/member.dart +++ b/packages/stream_chat/lib/src/core/models/member.dart @@ -46,8 +46,8 @@ class Member extends Equatable { Serializer.moveToExtraDataFromRoot(json, _topLevelFields), ); - // The JSON keys [Member.fromJson] reads into named fields; every other key - // is kept in [custom]. + // The JSON keys [Member.fromJson] reads into named fields; every other key is + // kept in [custom]. static const _topLevelFields = [ 'user', 'invite_accepted_at',