From 1433b257120cc247be63fd6ff95db1f42678a7c8 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 29 Sep 2026 17:31:41 +0200 Subject: [PATCH 01/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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 bf016c042c46b8651325fb8673a1c84e87cf89b3 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 9 Oct 2026 16:41:40 +0200 Subject: [PATCH 11/17] 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 12/17] 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 086657e0765fd6649e249704c4acb5e2e24b1c66 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 13 Oct 2026 15:29:17 +0200 Subject: [PATCH 13/17] 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 14/17] 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 15/17] 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 16/17] 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 92675e608f7f6d8ad45674e564b7b44682743662 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Wed, 14 Oct 2026 19:30:13 +0200 Subject: [PATCH 17/17] 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; }