From 1433b257120cc247be63fd6ff95db1f42678a7c8 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 29 Sep 2026 17:31:41 +0200 Subject: [PATCH 01/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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 e1ec831de89c2756162a532fa0c13230fe43169f Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 6 Oct 2026 10:41:57 +0200 Subject: [PATCH 11/19] refactor(llc)!: make Moderation a plain model Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 21 +++ openapi-migration/10-messages.md | 14 ++ openapi-migration/README.md | 1 + openapi-migration/tool/generate_plan.py | 13 ++ packages/stream_chat/CHANGELOG.md | 1 + .../models/converters/v1_json_converters.dart | 19 +++ .../lib/src/core/models/message.dart | 2 +- .../lib/src/core/models/message.g.dart | 10 +- .../lib/src/core/models/moderation.dart | 45 ++---- .../src/core/models/moderation.freezed.dart | 132 ++++++++++++++++++ .../lib/src/core/models/moderation.g.dart | 27 ---- .../converters/v1_json_converters_test.dart | 56 ++++++++ .../test/src/core/models/moderation_test.dart | 44 ------ 13 files changed, 274 insertions(+), 111 deletions(-) create mode 100644 packages/stream_chat/lib/src/core/models/moderation.freezed.dart delete mode 100644 packages/stream_chat/lib/src/core/models/moderation.g.dart diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index 022dbf1998..2101a0d31a 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -31,6 +31,7 @@ onto Stream's OpenAPI-generated API client. - [App Settings](#app-settings) - [Guest Users](#guest-users) - [File Upload](#file-upload) + - [Messages](#messages) - [User Groups](#user-groups) - [Link Previews](#link-previews) - [Migration Checklist](#migration-checklist) @@ -78,6 +79,7 @@ from the spec, so don't subclass them or depend on their private constructors. | [**App Settings**](#app-settings) | `getAppSettings` returns a `Result` instead of throwing; `GetAppSettingsResponse` is renamed `AppSettingsResponse` | | [**Guest Users**](#guest-users) | `connectGuestUser` is unchanged; `StreamChatApi.guest` and `ConnectGuestUserResponse` are removed | | [**File Upload**](#file-upload) | Uploads return a `Result` and deletes a `Result` instead of throwing; `AttachmentFileUploaderProvider` receives a `Dio` | +| [**Messages**](#messages) | `Moderation` no longer decodes from or encodes to JSON, and is no longer an `Equatable` | | _(filled in per feature as PRs land)_ | | --- @@ -219,6 +221,8 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `SendAttachmentResponse`, `SendFileResponse`, `SendImageResponse`, `UploadImageResponse`, `UploadFileResponse` | `UploadedFile` (`stream_core`) | `removed` | `.file` becomes `.fileUrl`; `.thumbUrl` is unchanged; `duration` is gone | | `AttachmentFileUploaderProvider` = `AttachmentFileUploader Function(StreamHttpClient)` | `AttachmentFileUploader Function(Dio)` | `retyped` | Receives the client's `Dio`; `StreamAttachmentFileUploader(dio)` builds the default | | `StreamChatApi.fileUploader`, `StreamChatApi(attachmentFileUploaderProvider:)` | `StreamChatClient(attachmentFileUploaderProvider:)` | `removed` | The uploader belongs to `StreamChatClient` | +| `Moderation.fromJson` / `toJson` | — | `removed` | A plain class; construct it directly. `Message.fromJson` still reads a message's moderation | +| `Moderation extends Equatable`, `Moderation.props` | `Moderation` (value `==`, `copyWith`) | `removed` | Equality is unchanged; `props` is gone and `Moderation` is no longer an `Equatable` | | _(more added per feature as PRs land)_ | | | | --- @@ -776,6 +780,23 @@ StreamChatClient(apiKey, attachmentFileUploaderProvider: (httpClient) => MyUploa StreamChatClient(apiKey, attachmentFileUploaderProvider: (dio) => MyUploader(dio)); ``` +### Messages + +**`Moderation` is a plain class.** It no longer decodes from or encodes to JSON, and it is no longer an +`Equatable`: equality is unchanged, `props` is gone, and it gains `copyWith`. A message still carries its +moderation outcome in `Message.moderation`, read from the API as before. + +```dart +// v10 +final moderation = Moderation.fromJson(json); + +// v11 +const moderation = Moderation(action: ModerationAction.flag, originalText: 'original text'); +``` + +> **Why:** the public models stop being wire shapes, so the API payload can change without changing the type a +> caller holds. + --- ## Migration Checklist diff --git a/openapi-migration/10-messages.md b/openapi-migration/10-messages.md index 7a5d1ea5cc..452663d72f 100644 --- a/openapi-migration/10-messages.md +++ b/openapi-migration/10-messages.md @@ -52,6 +52,20 @@ - Replace the temporary `@DataSerializable` storage codec (`UserGroup`, `UserGroupMember`): decide between dedicated tables and codecs owned by `stream_chat_persistence` before `Message` and `Attachment` become plain models, then delete the typedef and every `fromData`/`toData` it generates. - `Action` is still the v10 json_serializable class, embedded in `Attachment.actions`, which `Attachment.toJson` writes when sending and `toData` writes to persistence. It becomes a plain model with `Attachment`; group 04 left it alone. +## Decisions taken + +The models `Message` embeds become plain ahead of the endpoints, one PR each, leaves first. None routes +an endpoint, so the definition of done below stays open. + +- **`Moderation` is a plain `@freezed` model.** It loses `fromJson`, `toJson` and `Equatable`; equality + is unchanged. `Message.moderation` decodes through `moderationFromV1Json`, a temporary decode-only + function in `v1_json_converters.dart`: `Message.toJson` never writes the field. It keeps the + `moderation_details` fallback and the legacy `MESSAGE_RESPONSE_ACTION_*` names, and reads a missing + `platform_circumvented` as `false`. `stream_chat_persistence` does not store moderation, so no codec + is needed. `ModerationAction` keeps its `fromJson`/`toJson` statics, as `MessageType` does, until + `Message` stops decoding v1 JSON. The `ModerationV2Response` mapper waits for the first endpoint + that answers a message. + ## Risks - `message_api.dart` also holds the four draft methods, which belong to group 07 — leave them alone here. diff --git a/openapi-migration/README.md b/openapi-migration/README.md index 25bf7b8325..e620cc4f4f 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -142,6 +142,7 @@ 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) | +| `moderationFromV1Json` | `Message.moderation` | [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) | diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 151e2843f3..e7203b1b69 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -563,6 +563,19 @@ def match(path): 'Message send has offline and retry paths through `stream_chat_persistence` that must keep working.', '`MessageDeleteScope` has to be reconciled with `DeleteType`, which [08](08-moderation-and-blocklists.md) added. It is named for the scope of a delete — `deleteForMe` vs `deleteForAll` — but carries a `hard` bool, which is the same axis `DeleteType` models, in the same words, minus `pruning`. `deleteMessage(hard: true)` therefore cannot express a pruning delete at all, and `softDeleteForAll` / `hardDeleteForAll` read as two spellings of `DeleteType.soft` / `DeleteType.hard`. Decide whether the scope keeps a `DeleteType` field or the two stay separate arguments; either way the public type changes, so it belongs in this group rather than a later fix.', ], + taken=textwrap.dedent("""\ + The models `Message` embeds become plain ahead of the endpoints, one PR each, leaves first. None routes + an endpoint, so the definition of done below stays open. + + - **`Moderation` is a plain `@freezed` model.** It loses `fromJson`, `toJson` and `Equatable`; equality + is unchanged. `Message.moderation` decodes through `moderationFromV1Json`, a temporary decode-only + function in `v1_json_converters.dart`: `Message.toJson` never writes the field. It keeps the + `moderation_details` fallback and the legacy `MESSAGE_RESPONSE_ACTION_*` names, and reads a missing + `platform_circumvented` as `false`. `stream_chat_persistence` does not store moderation, so no codec + is needed. `ModerationAction` keeps its `fromJson`/`toJson` statics, as `MessageType` does, until + `Message` stops decoding v1 JSON. The `ModerationV2Response` mapper waits for the first endpoint + that answers a message. + """), ), dict( num='11', slug='channels-and-members', title='Channels, Members & Sync', diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 13601cd1cc..1710a09418 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -83,6 +83,7 @@ - `SendAttachmentResponse`, `SendFileResponse`, `SendImageResponse`, `UploadImageResponse` and `UploadFileResponse` are removed in favor of `UploadedFile`. - `AttachmentFileUploaderProvider` receives the client's `Dio` instead of a `StreamHttpClient`, and `StreamAttachmentFileUploader` is built from one. - `StreamChatApi.fileUploader` and `StreamChatApi`'s `attachmentFileUploaderProvider` are removed; pass `attachmentFileUploaderProvider` to `StreamChatClient` instead. +- `Moderation` no longer decodes from or encodes to JSON and no longer extends `Equatable`, so `props` is removed; it still compares by value and gains `copyWith`. 🐞 Fixed 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..66510447fa 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 @@ -3,6 +3,7 @@ import 'package:meta/meta.dart'; import 'package:stream_core/stream_core.dart' show StreamDateTimeConverter; import '../device.dart'; +import '../moderation.dart'; import '../push_provider.dart'; import '../user_group.dart'; import '../user_group_member.dart'; @@ -41,6 +42,24 @@ List? userGroupsFromV1Json(List? json) { return json?.map((group) => _userGroupFromV1Json(group as Map)).toList(); } +/// Reads the moderation outcome of a message from its v1 keys. +/// +/// Decode-only: [Message.moderation] is never written back to JSON. +// TODO(openapi-migration): remove in group 10 +@internal +Moderation? moderationFromV1Json(Map? json) { + if (json == null) return null; + return Moderation( + action: ModerationAction.fromJson(json['action'] as String), + originalText: json['original_text'] as String, + textHarms: (json['text_harms'] as List?)?.cast(), + imageHarms: (json['image_harms'] as List?)?.cast(), + blocklistMatched: json['blocklist_matched'] as String?, + semanticFilterMatched: json['semantic_filter_matched'] as String?, + platformCircumvented: json['platform_circumvented'] as bool? ?? false, + ); +} + // Dates arrive as ISO-8601 strings on v1 and as epoch nanoseconds on v2; the converter reads both. const _dateTime = StreamDateTimeConverter(); diff --git a/packages/stream_chat/lib/src/core/models/message.dart b/packages/stream_chat/lib/src/core/models/message.dart index 8ca0232d50..7b0652f85a 100644 --- a/packages/stream_chat/lib/src/core/models/message.dart +++ b/packages/stream_chat/lib/src/core/models/message.dart @@ -322,7 +322,7 @@ class Message extends Equatable { } /// The moderation details for this message. - @JsonKey(includeToJson: false, readValue: _moderationReadValue) + @JsonKey(includeToJson: false, readValue: _moderationReadValue, fromJson: moderationFromV1Json) final Moderation? moderation; /// Optional draft message linked to 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..aa291783d4 100644 --- a/packages/stream_chat/lib/src/core/models/message.g.dart +++ b/packages/stream_chat/lib/src/core/models/message.g.dart @@ -58,15 +58,9 @@ Message _$MessageFromJson(Map json) => Message( poll: json['poll'] == null ? null : Poll.fromJson(json['poll'] as Map), 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: moderationFromV1Json(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?, diff --git a/packages/stream_chat/lib/src/core/models/moderation.dart b/packages/stream_chat/lib/src/core/models/moderation.dart index f3133a39a6..a2471d2cb1 100644 --- a/packages/stream_chat/lib/src/core/models/moderation.dart +++ b/packages/stream_chat/lib/src/core/models/moderation.dart @@ -1,14 +1,11 @@ -import 'package:equatable/equatable.dart'; -import 'package:json_annotation/json_annotation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; -part 'moderation.g.dart'; +part 'moderation.freezed.dart'; -/// {@template moderation} -/// Model holding data for a message moderated by Moderation V1-V2. -/// {@endtemplate} -@JsonSerializable() -class Moderation extends Equatable { - /// {@macro moderation} +/// The outcome of running a message through the moderation system. +@freezed +class Moderation with _$Moderation { + /// Creates a new [Moderation]. const Moderation({ required this.action, required this.originalText, @@ -19,47 +16,33 @@ class Moderation extends Equatable { this.platformCircumvented = false, }); - /// Create a new instance from a json - factory Moderation.fromJson(Map json) => _$ModerationFromJson(json); - /// The action taken by the moderation system. - @JsonKey( - toJson: ModerationAction.toJson, - fromJson: ModerationAction.fromJson, - ) + @override final ModerationAction action; /// The original text of the message. + @override final String originalText; /// The list of harmful text detected in the message. + @override final List? textHarms; /// The list of harmful images detected in the message. + @override final List? imageHarms; /// The blocklist matched by the message. + @override final String? blocklistMatched; /// The semantic filter matched by the message. + @override final String? semanticFilterMatched; - /// true/false if the message triggered the platform circumvention model. - final bool platformCircumvented; - - /// Serialize to json - Map toJson() => _$ModerationToJson(this); - + /// Whether the message triggered the platform circumvention model. @override - List get props => [ - action, - originalText, - textHarms, - imageHarms, - blocklistMatched, - semanticFilterMatched, - platformCircumvented, - ]; + final bool platformCircumvented; } /// The moderation action performed over the message. diff --git a/packages/stream_chat/lib/src/core/models/moderation.freezed.dart b/packages/stream_chat/lib/src/core/models/moderation.freezed.dart new file mode 100644 index 0000000000..0cfd83735f --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/moderation.freezed.dart @@ -0,0 +1,132 @@ +// 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 'moderation.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$Moderation { + ModerationAction get action; + String get originalText; + List? get textHarms; + List? get imageHarms; + String? get blocklistMatched; + String? get semanticFilterMatched; + bool get platformCircumvented; + + /// Create a copy of Moderation + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $ModerationCopyWith get copyWith => _$ModerationCopyWithImpl(this as Moderation, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is Moderation && + (identical(other.action, action) || other.action == action) && + (identical(other.originalText, originalText) || other.originalText == originalText) && + const DeepCollectionEquality().equals(other.textHarms, textHarms) && + const DeepCollectionEquality().equals(other.imageHarms, imageHarms) && + (identical(other.blocklistMatched, blocklistMatched) || other.blocklistMatched == blocklistMatched) && + (identical(other.semanticFilterMatched, semanticFilterMatched) || + other.semanticFilterMatched == semanticFilterMatched) && + (identical(other.platformCircumvented, platformCircumvented) || + other.platformCircumvented == platformCircumvented)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + action, + originalText, + const DeepCollectionEquality().hash(textHarms), + const DeepCollectionEquality().hash(imageHarms), + blocklistMatched, + semanticFilterMatched, + platformCircumvented, + ); + + @override + String toString() { + return 'Moderation(action: $action, originalText: $originalText, textHarms: $textHarms, imageHarms: $imageHarms, blocklistMatched: $blocklistMatched, semanticFilterMatched: $semanticFilterMatched, platformCircumvented: $platformCircumvented)'; + } +} + +/// @nodoc +abstract mixin class $ModerationCopyWith<$Res> { + factory $ModerationCopyWith(Moderation value, $Res Function(Moderation) _then) = _$ModerationCopyWithImpl; + @useResult + $Res call({ + ModerationAction action, + String originalText, + List? textHarms, + List? imageHarms, + String? blocklistMatched, + String? semanticFilterMatched, + bool platformCircumvented, + }); +} + +/// @nodoc +class _$ModerationCopyWithImpl<$Res> implements $ModerationCopyWith<$Res> { + _$ModerationCopyWithImpl(this._self, this._then); + + final Moderation _self; + final $Res Function(Moderation) _then; + + /// Create a copy of Moderation + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? action = null, + Object? originalText = null, + Object? textHarms = freezed, + Object? imageHarms = freezed, + Object? blocklistMatched = freezed, + Object? semanticFilterMatched = freezed, + Object? platformCircumvented = null, + }) { + return _then( + Moderation( + action: null == action + ? _self.action + : action // ignore: cast_nullable_to_non_nullable + as ModerationAction, + originalText: null == originalText + ? _self.originalText + : originalText // ignore: cast_nullable_to_non_nullable + as String, + textHarms: freezed == textHarms + ? _self.textHarms + : textHarms // ignore: cast_nullable_to_non_nullable + as List?, + imageHarms: freezed == imageHarms + ? _self.imageHarms + : imageHarms // ignore: cast_nullable_to_non_nullable + as List?, + blocklistMatched: freezed == blocklistMatched + ? _self.blocklistMatched + : blocklistMatched // ignore: cast_nullable_to_non_nullable + as String?, + semanticFilterMatched: freezed == semanticFilterMatched + ? _self.semanticFilterMatched + : semanticFilterMatched // ignore: cast_nullable_to_non_nullable + as String?, + platformCircumvented: null == platformCircumvented + ? _self.platformCircumvented + : platformCircumvented // ignore: cast_nullable_to_non_nullable + as bool, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/moderation.g.dart b/packages/stream_chat/lib/src/core/models/moderation.g.dart deleted file mode 100644 index f287d23d84..0000000000 --- a/packages/stream_chat/lib/src/core/models/moderation.g.dart +++ /dev/null @@ -1,27 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'moderation.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -Moderation _$ModerationFromJson(Map json) => Moderation( - action: ModerationAction.fromJson(json['action'] as String), - originalText: json['original_text'] as String, - textHarms: (json['text_harms'] as List?)?.map((e) => e as String).toList(), - imageHarms: (json['image_harms'] as List?)?.map((e) => e as String).toList(), - blocklistMatched: json['blocklist_matched'] as String?, - semanticFilterMatched: json['semantic_filter_matched'] as String?, - platformCircumvented: json['platform_circumvented'] as bool? ?? false, -); - -Map _$ModerationToJson(Moderation instance) => { - 'action': ModerationAction.toJson(instance.action), - 'original_text': instance.originalText, - 'text_harms': instance.textHarms, - 'image_harms': instance.imageHarms, - 'blocklist_matched': instance.blocklistMatched, - 'semantic_filter_matched': instance.semanticFilterMatched, - 'platform_circumvented': instance.platformCircumvented, -}; diff --git a/packages/stream_chat/test/src/core/models/converters/v1_json_converters_test.dart b/packages/stream_chat/test/src/core/models/converters/v1_json_converters_test.dart index c8354353f7..d01b7a6385 100644 --- a/packages/stream_chat/test/src/core/models/converters/v1_json_converters_test.dart +++ b/packages/stream_chat/test/src/core/models/converters/v1_json_converters_test.dart @@ -1,6 +1,7 @@ import 'package:stream_chat/src/core/models/converters/v1_json_converters.dart'; import 'package:stream_chat/src/core/models/device.dart'; import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/moderation.dart'; import 'package:stream_chat/src/core/models/push_provider.dart'; import 'package:stream_chat/src/core/models/user_group.dart'; import 'package:stream_chat/src/core/models/user_group_member.dart'; @@ -112,4 +113,59 @@ void main() { test('userGroupsFromV1Json returns null when the message carries no groups', () { expect(userGroupsFromV1Json(null), isNull); }); + + test('Message.fromJson reads every moderation field from its v1 keys', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'moderation': { + 'action': 'shadow', + 'original_text': 'original message text', + 'text_harms': ['hate', 'profanity'], + 'image_harms': ['explicit'], + 'blocklist_matched': 'profanity', + 'semantic_filter_matched': 'harassment', + 'platform_circumvented': true, + }, + }); + + expect( + message.moderation, + const Moderation( + action: ModerationAction.shadow, + originalText: 'original message text', + textHarms: ['hate', 'profanity'], + imageHarms: ['explicit'], + blocklistMatched: 'profanity', + semanticFilterMatched: 'harassment', + platformCircumvented: true, + ), + ); + }); + + test('Message.fromJson reads a legacy moderation action as its current name', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'moderation': {'action': 'MESSAGE_RESPONSE_ACTION_BLOCK', 'original_text': 'original message text'}, + }); + + expect(message.moderation!.action, ModerationAction.remove); + }); + + test('Message.fromJson reads a moderation without platform_circumvented as not circumvented', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'moderation': {'action': 'flag', 'original_text': 'original message text'}, + }); + + expect( + message.moderation, + const Moderation(action: ModerationAction.flag, originalText: 'original message text'), + ); + }); + + test('Message.fromJson leaves moderation null when the message carries none', () { + final message = Message.fromJson(const {'id': 'message-id'}); + + expect(message.moderation, isNull); + }); } diff --git a/packages/stream_chat/test/src/core/models/moderation_test.dart b/packages/stream_chat/test/src/core/models/moderation_test.dart index 2405c44a90..ffb49adf64 100644 --- a/packages/stream_chat/test/src/core/models/moderation_test.dart +++ b/packages/stream_chat/test/src/core/models/moderation_test.dart @@ -39,50 +39,6 @@ void main() { expect(moderation.platformCircumvented, isTrue); }); - test('should create from JSON correctly', () { - final json = { - 'action': 'shadow', - 'original_text': 'original message text', - 'text_harms': ['hate', 'profanity'], - 'image_harms': ['explicit'], - 'blocklist_matched': 'profanity', - 'semantic_filter_matched': 'harassment', - 'platform_circumvented': true, - }; - - final moderation = Moderation.fromJson(json); - - expect(moderation.action, ModerationAction.shadow); - expect(moderation.originalText, 'original message text'); - expect(moderation.textHarms, ['hate', 'profanity']); - expect(moderation.imageHarms, ['explicit']); - expect(moderation.blocklistMatched, 'profanity'); - expect(moderation.semanticFilterMatched, 'harassment'); - expect(moderation.platformCircumvented, isTrue); - }); - - test('should serialize to JSON correctly', () { - const moderation = Moderation( - action: ModerationAction.bounce, - originalText: 'original message text', - textHarms: ['hate', 'profanity'], - imageHarms: ['explicit'], - blocklistMatched: 'profanity', - semanticFilterMatched: 'harassment', - platformCircumvented: true, - ); - - final json = moderation.toJson(); - - expect(json['action'], 'bounce'); - expect(json['original_text'], 'original message text'); - expect(json['text_harms'], ['hate', 'profanity']); - expect(json['image_harms'], ['explicit']); - expect(json['blocklist_matched'], 'profanity'); - expect(json['semantic_filter_matched'], 'harassment'); - expect(json['platform_circumvented'], isTrue); - }); - test('equals & hashCode should work correctly', () { const moderation1 = Moderation( action: ModerationAction.remove, From ae62a1b84146a5d7de09fa6e82414ce8e3744c35 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 6 Oct 2026 10:48:14 +0200 Subject: [PATCH 12/19] refactor(llc)!: make ReactionGroup a plain model Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 12 +++-- openapi-migration/10-messages.md | 11 ++++- openapi-migration/README.md | 3 +- openapi-migration/tool/generate_plan.py | 11 ++++- packages/stream_chat/CHANGELOG.md | 1 + .../models/converters/v1_json_converters.dart | 23 +++++++++ .../lib/src/core/models/message.dart | 2 +- .../lib/src/core/models/message.g.dart | 4 +- .../lib/src/core/models/reaction_group.dart | 46 ++++++++++-------- .../core/models/reaction_group.freezed.dart | 39 +++++++++++++++ .../converters/v1_json_converters_test.dart | 47 ++++++++++++++++++ .../src/core/models/reaction_group_test.dart | 48 +++++++++---------- .../converter/reaction_groups_converter.dart | 6 +-- 13 files changed, 196 insertions(+), 57 deletions(-) create mode 100644 packages/stream_chat/lib/src/core/models/reaction_group.freezed.dart diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index 2101a0d31a..d89ded6243 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -79,7 +79,7 @@ from the spec, so don't subclass them or depend on their private constructors. | [**App Settings**](#app-settings) | `getAppSettings` returns a `Result` instead of throwing; `GetAppSettingsResponse` is renamed `AppSettingsResponse` | | [**Guest Users**](#guest-users) | `connectGuestUser` is unchanged; `StreamChatApi.guest` and `ConnectGuestUserResponse` are removed | | [**File Upload**](#file-upload) | Uploads return a `Result` and deletes a `Result` instead of throwing; `AttachmentFileUploaderProvider` receives a `Dio` | -| [**Messages**](#messages) | `Moderation` no longer decodes from or encodes to JSON, and is no longer an `Equatable` | +| [**Messages**](#messages) | `Moderation` and `ReactionGroup` no longer decode from or encode to JSON, and are no longer `Equatable`s | | _(filled in per feature as PRs land)_ | | --- @@ -223,6 +223,8 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `StreamChatApi.fileUploader`, `StreamChatApi(attachmentFileUploaderProvider:)` | `StreamChatClient(attachmentFileUploaderProvider:)` | `removed` | The uploader belongs to `StreamChatClient` | | `Moderation.fromJson` / `toJson` | — | `removed` | A plain class; construct it directly. `Message.fromJson` still reads a message's moderation | | `Moderation extends Equatable`, `Moderation.props` | `Moderation` (value `==`, `copyWith`) | `removed` | Equality is unchanged; `props` is gone and `Moderation` is no longer an `Equatable` | +| `ReactionGroup.fromJson` / `toJson` | — | `removed` | A plain class; construct it directly. `Message.fromJson` still reads a message's reaction groups. `fromData` / `toData` are the offline database's format, not API JSON | +| `ReactionGroup extends Equatable`, `ReactionGroup.props` | `ReactionGroup` (value `==`) | `removed` | Equality and `copyWith` are unchanged; `props` is gone and `ReactionGroup` is no longer an `Equatable` | | _(more added per feature as PRs land)_ | | | | --- @@ -782,9 +784,11 @@ StreamChatClient(apiKey, attachmentFileUploaderProvider: (dio) => MyUploader(dio ### Messages -**`Moderation` is a plain class.** It no longer decodes from or encodes to JSON, and it is no longer an -`Equatable`: equality is unchanged, `props` is gone, and it gains `copyWith`. A message still carries its -moderation outcome in `Message.moderation`, read from the API as before. +**`Moderation` and `ReactionGroup` are plain classes.** Neither decodes from or encodes to JSON any more, and +neither is an `Equatable`: equality is unchanged and `props` is gone. `Moderation` gains `copyWith`. A message +still carries them in `Message.moderation` and `Message.reactionGroups`, read from the API as before. +`ReactionGroup.fromData` and `toData` read and write the offline database's format; they are not a codec for API +JSON. ```dart // v10 diff --git a/openapi-migration/10-messages.md b/openapi-migration/10-messages.md index 452663d72f..ea5f85345f 100644 --- a/openapi-migration/10-messages.md +++ b/openapi-migration/10-messages.md @@ -49,7 +49,7 @@ - `Message` is public, persisted, WebSocket-delivered and the most customised type in the SDK. Keep ours; treat the generated `MessageResponse` as a mapping source only. - `Attachment`: the generated model defines fields our `extraData` currently absorbs. Decide the promotion rules before writing the mapper. -- Replace the temporary `@DataSerializable` storage codec (`UserGroup`, `UserGroupMember`): decide between dedicated tables and codecs owned by `stream_chat_persistence` before `Message` and `Attachment` become plain models, then delete the typedef and every `fromData`/`toData` it generates. +- Replace the temporary `@DataSerializable` storage codec (`UserGroup`, `UserGroupMember`, `ReactionGroup`): decide between dedicated tables and codecs owned by `stream_chat_persistence` before `Message` and `Attachment` become plain models, then delete the typedef and every `fromData`/`toData` it generates. - `Action` is still the v10 json_serializable class, embedded in `Attachment.actions`, which `Attachment.toJson` writes when sending and `toData` writes to persistence. It becomes a plain model with `Attachment`; group 04 left it alone. ## Decisions taken @@ -65,6 +65,15 @@ an endpoint, so the definition of done below stays open. is needed. `ModerationAction` keeps its `fromJson`/`toJson` statics, as `MessageType` does, until `Message` stops decoding v1 JSON. The `ModerationV2Response` mapper waits for the first endpoint that answers a message. +- **`ReactionGroup` is a plain `@freezed` model.** It loses `fromJson`, `toJson` and `Equatable`; + equality is unchanged. Its constructor stays non-const, defaulting both dates to now, and it keeps + v10's hand-written `copyWith` (`@Freezed(copyWith: false)`): freezed's would read a `null` date as + "now" instead of "keep". `Message.reactionGroups` decodes through `reactionGroupsFromV1Json`, + decode-only; `_reactionGroupsReadValue` still builds the groups from `reaction_counts` and + `reaction_scores` when `reaction_groups` is missing. Dates go through `StreamDateTimeConverter`. + `messages.reaction_groups` and `pinned_messages.reaction_groups` store the groups through the + temporary `@DataSerializable` codec, whose output is byte-identical to v10's `toJson`, so no + `schemaVersion` bump. ## Risks diff --git a/openapi-migration/README.md b/openapi-migration/README.md index e620cc4f4f..c9fcc04dc3 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -143,7 +143,8 @@ 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) | | `moderationFromV1Json` | `Message.moderation` | [10](10-messages.md) | -| `DataSerializable` | `UserGroup`, `UserGroupMember` (`fromData`, `toData`) | [10](10-messages.md) | +| `reactionGroupsFromV1Json` | `Message.reactionGroups` | [10](10-messages.md) | +| `DataSerializable` | `UserGroup`, `UserGroupMember`, `ReactionGroup` (`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) | How v1 JSON decodes `User` once it becomes a plain model is decided in [01-foundation](01-foundation.md): until diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index e7203b1b69..4a65377c12 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -549,7 +549,7 @@ def match(path): 'ours; treat the generated `MessageResponse` as a mapping source only.', '`Attachment`: the generated model defines fields our `extraData` currently absorbs. Decide the ' 'promotion rules before writing the mapper.', - 'Replace the temporary `@DataSerializable` storage codec (`UserGroup`, `UserGroupMember`): decide ' + 'Replace the temporary `@DataSerializable` storage codec (`UserGroup`, `UserGroupMember`, `ReactionGroup`): decide ' 'between dedicated tables and codecs owned by `stream_chat_persistence` before `Message` and ' '`Attachment` become plain models, then delete the typedef and every `fromData`/`toData` it generates.', '`Action` is still the v10 json_serializable class, embedded in `Attachment.actions`, which ' @@ -575,6 +575,15 @@ def match(path): is needed. `ModerationAction` keeps its `fromJson`/`toJson` statics, as `MessageType` does, until `Message` stops decoding v1 JSON. The `ModerationV2Response` mapper waits for the first endpoint that answers a message. + - **`ReactionGroup` is a plain `@freezed` model.** It loses `fromJson`, `toJson` and `Equatable`; + equality is unchanged. Its constructor stays non-const, defaulting both dates to now, and it keeps + v10's hand-written `copyWith` (`@Freezed(copyWith: false)`): freezed's would read a `null` date as + "now" instead of "keep". `Message.reactionGroups` decodes through `reactionGroupsFromV1Json`, + decode-only; `_reactionGroupsReadValue` still builds the groups from `reaction_counts` and + `reaction_scores` when `reaction_groups` is missing. Dates go through `StreamDateTimeConverter`. + `messages.reaction_groups` and `pinned_messages.reaction_groups` store the groups through the + temporary `@DataSerializable` codec, whose output is byte-identical to v10's `toJson`, so no + `schemaVersion` bump. """), ), dict( diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 1710a09418..f4998207a7 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -84,6 +84,7 @@ - `AttachmentFileUploaderProvider` receives the client's `Dio` instead of a `StreamHttpClient`, and `StreamAttachmentFileUploader` is built from one. - `StreamChatApi.fileUploader` and `StreamChatApi`'s `attachmentFileUploaderProvider` are removed; pass `attachmentFileUploaderProvider` to `StreamChatClient` instead. - `Moderation` no longer decodes from or encodes to JSON and no longer extends `Equatable`, so `props` is removed; it still compares by value and gains `copyWith`. +- `ReactionGroup` no longer decodes from or encodes to JSON and no longer extends `Equatable`, so `props` is removed; it still compares by value. It gains `fromData` and `toData`, which read and write only the format `stream_chat_persistence` stores it in. 🐞 Fixed 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 66510447fa..6d44c2403e 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 @@ -5,6 +5,7 @@ import 'package:stream_core/stream_core.dart' show StreamDateTimeConverter; import '../device.dart'; import '../moderation.dart'; import '../push_provider.dart'; +import '../reaction_group.dart'; import '../user_group.dart'; import '../user_group_member.dart'; @@ -60,6 +61,28 @@ Moderation? moderationFromV1Json(Map? json) { ); } +/// Reads the reaction groups of a message from their v1 keys. +/// +/// Decode-only: [Message.reactionGroups] is never written back to JSON. +// TODO(openapi-migration): remove in group 10 +@internal +Map? reactionGroupsFromV1Json(Map? json) { + return json?.map((type, group) => MapEntry(type, _reactionGroupFromV1Json(group as Map))); +} + +ReactionGroup _reactionGroupFromV1Json(Map json) => ReactionGroup( + count: (json['count'] as num?)?.toInt() ?? 0, + sumScores: (json['sum_scores'] as num?)?.toInt() ?? 0, + firstReactionAt: switch (json['first_reaction_at']) { + final Object it => _dateTime.fromJson(it), + null => null, + }, + lastReactionAt: switch (json['last_reaction_at']) { + final Object it => _dateTime.fromJson(it), + null => null, + }, +); + // Dates arrive as ISO-8601 strings on v1 and as epoch nanoseconds on v2; the converter reads both. const _dateTime = StreamDateTimeConverter(); diff --git a/packages/stream_chat/lib/src/core/models/message.dart b/packages/stream_chat/lib/src/core/models/message.dart index 7b0652f85a..ea1db4f882 100644 --- a/packages/stream_chat/lib/src/core/models/message.dart +++ b/packages/stream_chat/lib/src/core/models/message.dart @@ -186,7 +186,7 @@ class Message extends Equatable { } /// A map of reaction types and their corresponding reaction groups. - @JsonKey(includeToJson: false, readValue: _reactionGroupsReadValue) + @JsonKey(includeToJson: false, readValue: _reactionGroupsReadValue, fromJson: reactionGroupsFromV1Json) final Map? reactionGroups; /// The latest reactions to the message created by any user. 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 aa291783d4..86edd300f9 100644 --- a/packages/stream_chat/lib/src/core/models/message.g.dart +++ b/packages/stream_chat/lib/src/core/models/message.g.dart @@ -23,8 +23,8 @@ Message _$MessageFromJson(Map json) => Message( const [], silent: json['silent'] as bool? ?? false, shadowed: json['shadowed'] as bool? ?? false, - reactionGroups: (Message._reactionGroupsReadValue(json, 'reaction_groups') as Map?)?.map( - (k, e) => MapEntry(k, ReactionGroup.fromJson(e as Map)), + reactionGroups: reactionGroupsFromV1Json( + Message._reactionGroupsReadValue(json, 'reaction_groups') as Map?, ), latestReactions: (json['latest_reactions'] as List?) ?.map((e) => Reaction.fromJson(e as Map)) diff --git a/packages/stream_chat/lib/src/core/models/reaction_group.dart b/packages/stream_chat/lib/src/core/models/reaction_group.dart index 9be6a5150b..7e7f831dac 100644 --- a/packages/stream_chat/lib/src/core/models/reaction_group.dart +++ b/packages/stream_chat/lib/src/core/models/reaction_group.dart @@ -1,12 +1,18 @@ -import 'package:equatable/equatable.dart'; -import 'package:json_annotation/json_annotation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import '../../db/data_serializable.dart'; + +part 'reaction_group.freezed.dart'; part 'reaction_group.g.dart'; -/// A model class representing a reaction group. -@JsonSerializable() -class ReactionGroup extends Equatable { - /// Create a new instance of [ReactionGroup]. +/// The reactions of one type on a message, counted and scored together. +@Freezed(copyWith: false) +// TODO(openapi-migration): remove in group 10 +@DataSerializable() +class ReactionGroup with _$ReactionGroup { + /// Creates a new [ReactionGroup]. + /// + /// [firstReactionAt] and [lastReactionAt] default to now. ReactionGroup({ this.count = 0, this.sumScores = 0, @@ -15,25 +21,35 @@ class ReactionGroup extends Equatable { }) : firstReactionAt = firstReactionAt ?? DateTime.timestamp(), lastReactionAt = lastReactionAt ?? DateTime.timestamp(); - /// Create a new instance from a json - factory ReactionGroup.fromJson(Map json) => _$ReactionGroupFromJson(json); + /// Creates a [ReactionGroup] from the offline-database format written by [toData]. + /// + /// It is not a codec for API payloads. + factory ReactionGroup.fromData(Map json) => _$ReactionGroupFromJson(json); /// The number of users that reacted with this reaction. + @override final int count; /// The sum of scores of all reactions in this group. + @override final int sumScores; /// The date of the first reaction in this group. + @override final DateTime firstReactionAt; /// The date of the last reaction in this group. + @override final DateTime lastReactionAt; - /// Serialize to json - Map toJson() => _$ReactionGroupToJson(this); + /// Serializes this group to the format `stream_chat_persistence` stores. + /// + /// It is not a codec for API payloads. + Map toData() => _$ReactionGroupToJson(this); - /// Creates a copy of [Reaction] with specified attributes overridden. + /// Creates a copy of this group with the given fields replaced. + /// + /// A field passed as null keeps its current value. ReactionGroup copyWith({ int? count, int? sumScores, @@ -47,14 +63,6 @@ class ReactionGroup extends Equatable { lastReactionAt: lastReactionAt ?? this.lastReactionAt, ); } - - @override - List get props => [ - count, - sumScores, - firstReactionAt, - lastReactionAt, - ]; } /// A group of comparators for sorting [ReactionGroup]s. diff --git a/packages/stream_chat/lib/src/core/models/reaction_group.freezed.dart b/packages/stream_chat/lib/src/core/models/reaction_group.freezed.dart new file mode 100644 index 0000000000..4b508ce1f6 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/reaction_group.freezed.dart @@ -0,0 +1,39 @@ +// 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 'reaction_group.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$ReactionGroup { + int get count; + int get sumScores; + DateTime get firstReactionAt; + DateTime get lastReactionAt; + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is ReactionGroup && + (identical(other.count, count) || other.count == count) && + (identical(other.sumScores, sumScores) || other.sumScores == sumScores) && + (identical(other.firstReactionAt, firstReactionAt) || other.firstReactionAt == firstReactionAt) && + (identical(other.lastReactionAt, lastReactionAt) || other.lastReactionAt == lastReactionAt)); + } + + @override + int get hashCode => Object.hash(runtimeType, count, sumScores, firstReactionAt, lastReactionAt); + + @override + String toString() { + return 'ReactionGroup(count: $count, sumScores: $sumScores, firstReactionAt: $firstReactionAt, lastReactionAt: $lastReactionAt)'; + } +} diff --git a/packages/stream_chat/test/src/core/models/converters/v1_json_converters_test.dart b/packages/stream_chat/test/src/core/models/converters/v1_json_converters_test.dart index d01b7a6385..a9f6b43470 100644 --- a/packages/stream_chat/test/src/core/models/converters/v1_json_converters_test.dart +++ b/packages/stream_chat/test/src/core/models/converters/v1_json_converters_test.dart @@ -3,6 +3,7 @@ import 'package:stream_chat/src/core/models/device.dart'; import 'package:stream_chat/src/core/models/message.dart'; import 'package:stream_chat/src/core/models/moderation.dart'; import 'package:stream_chat/src/core/models/push_provider.dart'; +import 'package:stream_chat/src/core/models/reaction_group.dart'; import 'package:stream_chat/src/core/models/user_group.dart'; import 'package:stream_chat/src/core/models/user_group_member.dart'; import 'package:test/test.dart'; @@ -168,4 +169,50 @@ void main() { expect(message.moderation, isNull); }); + + test('Message.fromJson reads every reaction group field from its v1 keys', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'reaction_groups': { + 'love': { + 'count': 2, + 'sum_scores': 5, + 'first_reaction_at': '2024-01-01T00:00:00.000Z', + 'last_reaction_at': '2024-01-02T00:00:00.000Z', + }, + }, + }); + + expect(message.reactionGroups, { + 'love': ReactionGroup( + count: 2, + sumScores: 5, + firstReactionAt: DateTime.utc(2024, 1, 1), + lastReactionAt: DateTime.utc(2024, 1, 2), + ), + }); + }); + + test('Message.fromJson reads reaction group dates sent as epoch nanoseconds', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'reaction_groups': { + 'love': { + 'count': 1, + 'sum_scores': 1, + 'first_reaction_at': 1704067200123456000, + 'last_reaction_at': 1704153600000000000, + }, + }, + }); + + expect(message.reactionGroups!['love']!.firstReactionAt, DateTime.utc(2024, 1, 1, 0, 0, 0, 123, 456)); + expect(message.reactionGroups!['love']!.lastReactionAt, DateTime.utc(2024, 1, 2)); + }); + + test('Message.fromJson leaves reaction groups null when the message carries no reactions', () { + final message = Message.fromJson(const {'id': 'message-id'}); + + expect(message.reactionGroups, isNull); + }); } diff --git a/packages/stream_chat/test/src/core/models/reaction_group_test.dart b/packages/stream_chat/test/src/core/models/reaction_group_test.dart index 602ba6ee41..7f62a3bd39 100644 --- a/packages/stream_chat/test/src/core/models/reaction_group_test.dart +++ b/packages/stream_chat/test/src/core/models/reaction_group_test.dart @@ -22,18 +22,31 @@ void main() { expect(reactionGroup.lastReactionAt, lastReactionAtDate); }); - test('fromJson creates correct object', () { - final json = { + test('toData writes snake_case keys and ISO-8601 dates', () { + final reactionGroup = ReactionGroup( + count: count, + sumScores: sumScores, + firstReactionAt: firstReactionAtDate, + lastReactionAt: lastReactionAtDate, + ); + + expect(reactionGroup.toData(), { 'count': count, 'sum_scores': sumScores, - 'first_reaction_at': firstReactionAtDate.toIso8601String(), - 'last_reaction_at': lastReactionAtDate.toIso8601String(), - }; - final reactionGroup = ReactionGroup.fromJson(json); - expect(reactionGroup.count, count); - expect(reactionGroup.sumScores, sumScores); - expect(reactionGroup.firstReactionAt, firstReactionAtDate); - expect(reactionGroup.lastReactionAt, lastReactionAtDate); + 'first_reaction_at': '2023-10-26T10:00:00.000Z', + 'last_reaction_at': '2023-10-26T12:00:00.000Z', + }); + }); + + test('fromData reads back the group written by toData', () { + final reactionGroup = ReactionGroup( + count: count, + sumScores: sumScores, + firstReactionAt: firstReactionAtDate, + lastReactionAt: lastReactionAtDate, + ); + + expect(ReactionGroup.fromData(reactionGroup.toData()), reactionGroup); }); test('copyWith creates a new object with updated values', () { @@ -82,21 +95,6 @@ void main() { expect(copiedGroup.lastReactionAt, lastReactionAtDate); }); - test('props returns correct list of properties', () { - final reactionGroup = ReactionGroup( - count: count, - sumScores: sumScores, - firstReactionAt: firstReactionAtDate, - lastReactionAt: lastReactionAtDate, - ); - expect(reactionGroup.props, [ - count, - sumScores, - firstReactionAtDate, - lastReactionAtDate, - ]); - }); - test('equality works correctly', () { final reactionGroup1 = ReactionGroup( count: count, diff --git a/packages/stream_chat_persistence/lib/src/converter/reaction_groups_converter.dart b/packages/stream_chat_persistence/lib/src/converter/reaction_groups_converter.dart index 8964f8ae7e..fcddc97e45 100644 --- a/packages/stream_chat_persistence/lib/src/converter/reaction_groups_converter.dart +++ b/packages/stream_chat_persistence/lib/src/converter/reaction_groups_converter.dart @@ -4,14 +4,14 @@ import 'package:drift/drift.dart'; import 'package:stream_chat/stream_chat.dart'; import 'converter.dart'; -/// A [TypeConverter] that serializes [VotingVisibility] to a [String] column. +/// A [TypeConverter] that stores a message's [ReactionGroup]s as a JSON [String] column. class ReactionGroupsConverter extends MapConverter { @override Map fromSql(String fromDb) { final json = jsonDecode(fromDb) as Map; return json.map( (key, e) { - final group = ReactionGroup.fromJson(e as Map); + final group = ReactionGroup.fromData(e as Map); return MapEntry(key, group); }, ); @@ -21,7 +21,7 @@ class ReactionGroupsConverter extends MapConverter { String toSql(Map value) { return jsonEncode( value.map( - (k, e) => MapEntry(k, e.toJson()), + (k, e) => MapEntry(k, e.toData()), ), ); } From d4e87e18930371a4fe4c92780c2dc1c81909cbdd Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 6 Oct 2026 11:20:03 +0200 Subject: [PATCH 13/19] refactor(llc)!: make Action a plain model Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 8 +- openapi-migration/10-messages.md | 7 +- openapi-migration/README.md | 1 + openapi-migration/tool/generate_plan.py | 9 +- packages/stream_chat/CHANGELOG.md | 1 + .../lib/src/core/models/action.dart | 35 ++++--- .../lib/src/core/models/action.freezed.dart | 99 +++++++++++++++++++ .../lib/src/core/models/action.g.dart | 23 ----- .../lib/src/core/models/attachment.dart | 2 + .../lib/src/core/models/attachment.g.dart | 7 +- .../models/converters/v1_json_converters.dart | 28 ++++++ .../stream_chat/test/fixtures/action.json | 7 -- .../test/src/core/models/action_test.dart | 40 ++------ .../converters/v1_json_converters_test.dart | 46 +++++++++ 14 files changed, 226 insertions(+), 87 deletions(-) create mode 100644 packages/stream_chat/lib/src/core/models/action.freezed.dart delete mode 100644 packages/stream_chat/lib/src/core/models/action.g.dart delete mode 100644 packages/stream_chat/test/fixtures/action.json diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index d89ded6243..b0a31289e6 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -79,7 +79,7 @@ from the spec, so don't subclass them or depend on their private constructors. | [**App Settings**](#app-settings) | `getAppSettings` returns a `Result` instead of throwing; `GetAppSettingsResponse` is renamed `AppSettingsResponse` | | [**Guest Users**](#guest-users) | `connectGuestUser` is unchanged; `StreamChatApi.guest` and `ConnectGuestUserResponse` are removed | | [**File Upload**](#file-upload) | Uploads return a `Result` and deletes a `Result` instead of throwing; `AttachmentFileUploaderProvider` receives a `Dio` | -| [**Messages**](#messages) | `Moderation` and `ReactionGroup` no longer decode from or encode to JSON, and are no longer `Equatable`s | +| [**Messages**](#messages) | `Moderation`, `ReactionGroup` and `Action` no longer decode from or encode to JSON; `Action` compares by value | | _(filled in per feature as PRs land)_ | | --- @@ -225,6 +225,8 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `Moderation extends Equatable`, `Moderation.props` | `Moderation` (value `==`, `copyWith`) | `removed` | Equality is unchanged; `props` is gone and `Moderation` is no longer an `Equatable` | | `ReactionGroup.fromJson` / `toJson` | — | `removed` | A plain class; construct it directly. `Message.fromJson` still reads a message's reaction groups. `fromData` / `toData` are the offline database's format, not API JSON | | `ReactionGroup extends Equatable`, `ReactionGroup.props` | `ReactionGroup` (value `==`) | `removed` | Equality and `copyWith` are unchanged; `props` is gone and `ReactionGroup` is no longer an `Equatable` | +| `Action.fromJson` / `toJson` | — | `removed` | A plain class; construct it directly. `Attachment.fromJson` and `toJson` still read and write an attachment's actions | +| `Action` identity `==` | value `==`, plus `copyWith` | `retyped` | Two actions with the same fields are now equal, and so are attachments that differ only in holding separate copies of them | | _(more added per feature as PRs land)_ | | | | --- @@ -790,6 +792,10 @@ still carries them in `Message.moderation` and `Message.reactionGroups`, read fr `ReactionGroup.fromData` and `toData` read and write the offline database's format; they are not a codec for API JSON. +**`Action` is a plain class too, and compares by value.** It no longer decodes from or encodes to JSON, and it gains +`copyWith`. Two actions with the same fields are now equal, where v10 compared them by identity; an `Attachment` +holding them follows suit. + ```dart // v10 final moderation = Moderation.fromJson(json); diff --git a/openapi-migration/10-messages.md b/openapi-migration/10-messages.md index ea5f85345f..5e620f4af9 100644 --- a/openapi-migration/10-messages.md +++ b/openapi-migration/10-messages.md @@ -50,7 +50,6 @@ - `Message` is public, persisted, WebSocket-delivered and the most customised type in the SDK. Keep ours; treat the generated `MessageResponse` as a mapping source only. - `Attachment`: the generated model defines fields our `extraData` currently absorbs. Decide the promotion rules before writing the mapper. - Replace the temporary `@DataSerializable` storage codec (`UserGroup`, `UserGroupMember`, `ReactionGroup`): decide between dedicated tables and codecs owned by `stream_chat_persistence` before `Message` and `Attachment` become plain models, then delete the typedef and every `fromData`/`toData` it generates. -- `Action` is still the v10 json_serializable class, embedded in `Attachment.actions`, which `Attachment.toJson` writes when sending and `toData` writes to persistence. It becomes a plain model with `Attachment`; group 04 left it alone. ## Decisions taken @@ -74,6 +73,12 @@ an endpoint, so the definition of done below stays open. `messages.reaction_groups` and `pinned_messages.reaction_groups` store the groups through the temporary `@DataSerializable` codec, whose output is byte-identical to v10's `toJson`, so no `schemaVersion` bump. +- **`Action` is a plain `@freezed` model,** ahead of `Attachment` rather than with it. It loses + `fromJson` and `toJson`, gains `copyWith` and `const`, and compares by value where v10 compared by + identity, so attachments holding equal actions now compare equal. `Attachment.actions` reads and + writes through `ActionV1JsonConverter`: `Attachment.toJson` sends the actions and `toData` stores + them, and the converter writes the same keys v10 did, `value` included when null, so requests and + the stored `attachments` columns are unchanged. `Action` needs no codec of its own. ## Risks diff --git a/openapi-migration/README.md b/openapi-migration/README.md index c9fcc04dc3..ffb36003c2 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -144,6 +144,7 @@ includes a named response that carries only `duration` today, such as `HideChann | `userGroupsFromV1Json` | `Message.mentionedGroups` | [10](10-messages.md) | | `moderationFromV1Json` | `Message.moderation` | [10](10-messages.md) | | `reactionGroupsFromV1Json` | `Message.reactionGroups` | [10](10-messages.md) | +| `ActionV1JsonConverter` | `Attachment.actions` | [10](10-messages.md) | | `DataSerializable` | `UserGroup`, `UserGroupMember`, `ReactionGroup` (`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) | diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 4a65377c12..9ec9f3832c 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -552,9 +552,6 @@ def match(path): 'Replace the temporary `@DataSerializable` storage codec (`UserGroup`, `UserGroupMember`, `ReactionGroup`): decide ' 'between dedicated tables and codecs owned by `stream_chat_persistence` before `Message` and ' '`Attachment` become plain models, then delete the typedef and every `fromData`/`toData` it generates.', - '`Action` is still the v10 json_serializable class, embedded in `Attachment.actions`, which ' - '`Attachment.toJson` writes when sending and `toData` writes to persistence. It becomes a plain model ' - 'with `Attachment`; group 04 left it alone.', ], risks=[ '`message_api.dart` also holds the four draft methods, which belong to group 07 — leave them alone ' @@ -584,6 +581,12 @@ def match(path): `messages.reaction_groups` and `pinned_messages.reaction_groups` store the groups through the temporary `@DataSerializable` codec, whose output is byte-identical to v10's `toJson`, so no `schemaVersion` bump. + - **`Action` is a plain `@freezed` model,** ahead of `Attachment` rather than with it. It loses + `fromJson` and `toJson`, gains `copyWith` and `const`, and compares by value where v10 compared by + identity, so attachments holding equal actions now compare equal. `Attachment.actions` reads and + writes through `ActionV1JsonConverter`: `Attachment.toJson` sends the actions and `toData` stores + them, and the converter writes the same keys v10 did, `value` included when null, so requests and + the stored `attachments` columns are unchanged. `Action` needs no codec of its own. """), ), dict( diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index f4998207a7..05ccac5b36 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -85,6 +85,7 @@ - `StreamChatApi.fileUploader` and `StreamChatApi`'s `attachmentFileUploaderProvider` are removed; pass `attachmentFileUploaderProvider` to `StreamChatClient` instead. - `Moderation` no longer decodes from or encodes to JSON and no longer extends `Equatable`, so `props` is removed; it still compares by value and gains `copyWith`. - `ReactionGroup` no longer decodes from or encodes to JSON and no longer extends `Equatable`, so `props` is removed; it still compares by value. It gains `fromData` and `toData`, which read and write only the format `stream_chat_persistence` stores it in. +- `Action` no longer decodes from or encodes to JSON, compares by value instead of by identity, and gains `copyWith`. 🐞 Fixed diff --git a/packages/stream_chat/lib/src/core/models/action.dart b/packages/stream_chat/lib/src/core/models/action.dart index 709fbfe0c5..caf4be4cf9 100644 --- a/packages/stream_chat/lib/src/core/models/action.dart +++ b/packages/stream_chat/lib/src/core/models/action.dart @@ -1,12 +1,12 @@ -import 'package:json_annotation/json_annotation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; -part 'action.g.dart'; +part 'action.freezed.dart'; -/// The class that contains the information about an action -@JsonSerializable() -class Action { - /// Constructor used for json serialization - Action({ +/// An interactive control attached to a message, such as the Send, Shuffle and Cancel buttons of a giphy preview. +@freezed +class Action with _$Action { + /// Creates a new [Action]. + const Action({ required this.name, this.style = 'default', required this.text, @@ -14,24 +14,23 @@ class Action { this.value, }); - /// Create a new instance from a json - factory Action.fromJson(Map json) => _$ActionFromJson(json); - - /// The name of the action + /// The name the action is submitted under. + @override final String name; - /// The style of the action + /// The visual style of the action, such as `primary` or `default`. + @override final String style; - /// The test of the action + /// The label shown for the action. + @override final String text; - /// The type of the action + /// The kind of control, such as `button`. + @override final String type; - /// The value of the action + /// The value the action submits, or null if it submits none. + @override final String? value; - - /// Serialize to json - Map toJson() => _$ActionToJson(this); } diff --git a/packages/stream_chat/lib/src/core/models/action.freezed.dart b/packages/stream_chat/lib/src/core/models/action.freezed.dart new file mode 100644 index 0000000000..7458bc90c8 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/action.freezed.dart @@ -0,0 +1,99 @@ +// 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 'action.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$Action { + String get name; + String get style; + String get text; + String get type; + String? get value; + + /// Create a copy of Action + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $ActionCopyWith get copyWith => _$ActionCopyWithImpl(this as Action, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is Action && + (identical(other.name, name) || other.name == name) && + (identical(other.style, style) || other.style == style) && + (identical(other.text, text) || other.text == text) && + (identical(other.type, type) || other.type == type) && + (identical(other.value, value) || other.value == value)); + } + + @override + int get hashCode => Object.hash(runtimeType, name, style, text, type, value); + + @override + String toString() { + return 'Action(name: $name, style: $style, text: $text, type: $type, value: $value)'; + } +} + +/// @nodoc +abstract mixin class $ActionCopyWith<$Res> { + factory $ActionCopyWith(Action value, $Res Function(Action) _then) = _$ActionCopyWithImpl; + @useResult + $Res call({String name, String style, String text, String type, String? value}); +} + +/// @nodoc +class _$ActionCopyWithImpl<$Res> implements $ActionCopyWith<$Res> { + _$ActionCopyWithImpl(this._self, this._then); + + final Action _self; + final $Res Function(Action) _then; + + /// Create a copy of Action + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? name = null, + Object? style = null, + Object? text = null, + Object? type = null, + Object? value = freezed, + }) { + return _then( + Action( + name: null == name + ? _self.name + : name // ignore: cast_nullable_to_non_nullable + as String, + style: null == style + ? _self.style + : style // ignore: cast_nullable_to_non_nullable + as String, + text: null == text + ? _self.text + : text // ignore: cast_nullable_to_non_nullable + as String, + type: null == type + ? _self.type + : type // ignore: cast_nullable_to_non_nullable + as String, + value: freezed == value + ? _self.value + : value // ignore: cast_nullable_to_non_nullable + as String?, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/action.g.dart b/packages/stream_chat/lib/src/core/models/action.g.dart deleted file mode 100644 index ec604d553f..0000000000 --- a/packages/stream_chat/lib/src/core/models/action.g.dart +++ /dev/null @@ -1,23 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'action.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -Action _$ActionFromJson(Map json) => Action( - name: json['name'] as String, - style: json['style'] as String? ?? 'default', - text: json['text'] as String, - type: json['type'] as String, - value: json['value'] as String?, -); - -Map _$ActionToJson(Action instance) => { - 'name': instance.name, - 'style': instance.style, - 'text': instance.text, - 'type': instance.type, - 'value': instance.value, -}; diff --git a/packages/stream_chat/lib/src/core/models/attachment.dart b/packages/stream_chat/lib/src/core/models/attachment.dart index 6f1c7d7409..0960af959b 100644 --- a/packages/stream_chat/lib/src/core/models/attachment.dart +++ b/packages/stream_chat/lib/src/core/models/attachment.dart @@ -7,6 +7,7 @@ import 'package:uuid/uuid.dart'; import '../util/serializer.dart'; import 'action.dart'; import 'attachment_file.dart'; +import 'converters/v1_json_converters.dart'; import 'response/og_attachment_response.dart'; part 'attachment.g.dart'; @@ -140,6 +141,7 @@ class Attachment extends Equatable { final String? assetUrl; /// Actions from a command + @ActionV1JsonConverter() final List? actions; /// The original width of the attached image. diff --git a/packages/stream_chat/lib/src/core/models/attachment.g.dart b/packages/stream_chat/lib/src/core/models/attachment.g.dart index de9fd74d4a..fbb4f2ab68 100644 --- a/packages/stream_chat/lib/src/core/models/attachment.g.dart +++ b/packages/stream_chat/lib/src/core/models/attachment.g.dart @@ -26,7 +26,10 @@ Attachment _$AttachmentFromJson(Map json) => Attachment( authorIcon: json['author_icon'] as String?, assetUrl: json['asset_url'] as String?, actions: - (json['actions'] as List?)?.map((e) => Action.fromJson(e as Map)).toList() ?? const [], + (json['actions'] as List?) + ?.map((e) => const ActionV1JsonConverter().fromJson(e as Map)) + .toList() ?? + const [], originalWidth: (json['original_width'] as num?)?.toInt(), originalHeight: (json['original_height'] as num?)?.toInt(), extraData: json['extra_data'] as Map? ?? const {}, @@ -54,7 +57,7 @@ Map _$AttachmentToJson(Attachment instance) => e.toJson()).toList(), + 'actions': ?instance.actions?.map(const ActionV1JsonConverter().toJson).toList(), 'original_width': ?instance.originalWidth, 'original_height': ?instance.originalHeight, 'file': ?instance.file?.toJson(), 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 6d44c2403e..5c3c0b5ae5 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,6 +2,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:meta/meta.dart'; import 'package:stream_core/stream_core.dart' show StreamDateTimeConverter; +import '../action.dart'; import '../device.dart'; import '../moderation.dart'; import '../push_provider.dart'; @@ -12,6 +13,33 @@ import '../user_group_member.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. +/// Converts an [Action] to and from its v1 keys. +// TODO(openapi-migration): remove in group 10 +@internal +class ActionV1JsonConverter implements JsonConverter> { + /// Creates a new [ActionV1JsonConverter]. + const ActionV1JsonConverter(); + + @override + Action fromJson(Map json) => Action( + name: json['name'] as String, + style: json['style'] as String? ?? 'default', + text: json['text'] as String, + type: json['type'] as String, + value: json['value'] as String?, + ); + + // Writes `value` even when it is null, as v10 did, so sent attachments and the stored ones keep their format. + @override + Map toJson(Action action) => { + 'name': action.name, + 'style': action.style, + 'text': action.text, + 'type': action.type, + 'value': action.value, + }; +} + /// Converts a [Device] to and from its v1 `id` and `push_provider` keys. // TODO(openapi-migration): remove in group 09 @internal diff --git a/packages/stream_chat/test/fixtures/action.json b/packages/stream_chat/test/fixtures/action.json deleted file mode 100644 index 59da5684cd..0000000000 --- a/packages/stream_chat/test/fixtures/action.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "name", - "style": "style", - "text": "text", - "type": "type", - "value": "value" -} \ No newline at end of file diff --git a/packages/stream_chat/test/src/core/models/action_test.dart b/packages/stream_chat/test/src/core/models/action_test.dart index 0cf1c996fb..f722d0877c 100644 --- a/packages/stream_chat/test/src/core/models/action_test.dart +++ b/packages/stream_chat/test/src/core/models/action_test.dart @@ -1,38 +1,14 @@ import 'package:stream_chat/src/core/models/action.dart'; import 'package:test/test.dart'; -import '../../utils.dart'; - void main() { - group('src/models/action', () { - test('should parse json correctly', () { - final action = Action.fromJson(jsonFixture('action.json')); - expect(action.name, 'name'); - expect(action.style, 'style'); - expect(action.text, 'text'); - expect(action.type, 'type'); - expect(action.value, 'value'); - }); - - test('should serialize to json correctly', () { - final action = Action( - name: 'name', - style: 'style', - text: 'text', - type: 'type', - value: 'value', - ); - - expect( - action.toJson(), - { - 'name': 'name', - 'style': 'style', - 'text': 'text', - 'type': 'type', - 'value': 'value', - }, - ); - }); + test('Action compares by value', () { + const action = Action(name: 'image_action', style: 'primary', text: 'Send', type: 'button', value: 'send'); + + expect( + action, + const Action(name: 'image_action', style: 'primary', text: 'Send', type: 'button', value: 'send'), + ); + expect(action, isNot(action.copyWith(value: 'shuffle'))); }); } diff --git a/packages/stream_chat/test/src/core/models/converters/v1_json_converters_test.dart b/packages/stream_chat/test/src/core/models/converters/v1_json_converters_test.dart index a9f6b43470..d9848025f8 100644 --- a/packages/stream_chat/test/src/core/models/converters/v1_json_converters_test.dart +++ b/packages/stream_chat/test/src/core/models/converters/v1_json_converters_test.dart @@ -1,3 +1,5 @@ +import 'package:stream_chat/src/core/models/action.dart'; +import 'package:stream_chat/src/core/models/attachment.dart'; import 'package:stream_chat/src/core/models/converters/v1_json_converters.dart'; import 'package:stream_chat/src/core/models/device.dart'; import 'package:stream_chat/src/core/models/message.dart'; @@ -215,4 +217,48 @@ void main() { expect(message.reactionGroups, isNull); }); + + test('Attachment.fromJson reads every action field from its v1 keys', () { + final attachment = Attachment.fromJson(const { + 'type': 'giphy', + 'actions': [ + {'name': 'image_action', 'style': 'primary', 'text': 'Send', 'type': 'button', 'value': 'send'}, + ], + }); + + expect(attachment.actions, [ + const Action(name: 'image_action', style: 'primary', text: 'Send', type: 'button', value: 'send'), + ]); + }); + + test('Attachment.fromJson reads an action without a style as the default style', () { + final attachment = Attachment.fromJson(const { + 'type': 'giphy', + 'actions': [ + {'name': 'image_action', 'text': 'Cancel', 'type': 'button'}, + ], + }); + + expect(attachment.actions, [const Action(name: 'image_action', text: 'Cancel', type: 'button')]); + }); + + test('Attachment.toJson writes every action key, including a null value', () { + final attachment = Attachment( + type: 'giphy', + actions: const [Action(name: 'image_action', text: 'Cancel', type: 'button')], + ); + + expect(attachment.toJson()['actions'], [ + {'name': 'image_action', 'style': 'default', 'text': 'Cancel', 'type': 'button', 'value': null}, + ]); + }); + + test('Attachment.fromJson reads back the actions written by toJson', () { + final attachment = Attachment( + type: 'giphy', + actions: const [Action(name: 'image_action', style: 'primary', text: 'Send', type: 'button', value: 'send')], + ); + + expect(Attachment.fromJson(attachment.toJson()).actions, attachment.actions); + }); } From b072b06f001a87ef6cfc0b734ba357bb89cc9284 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 6 Oct 2026 11:49:45 +0200 Subject: [PATCH 14/19] refactor(llc)!: make Reaction a plain model Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 7 +- openapi-migration/10-messages.md | 7 ++ openapi-migration/README.md | 1 + openapi-migration/tool/generate_plan.py | 7 ++ packages/stream_chat/CHANGELOG.md | 1 + .../lib/src/core/api/message_api.dart | 3 +- .../lib/src/core/api/responses.dart | 3 + .../lib/src/core/api/responses.g.dart | 13 ++- .../models/converters/v1_json_converters.dart | 47 +++++++++ .../lib/src/core/models/message.dart | 2 + .../lib/src/core/models/message.g.dart | 4 +- .../lib/src/core/models/reaction.dart | 57 ++++------- .../lib/src/core/models/reaction.freezed.dart | 60 ++++++++++++ .../lib/src/core/models/reaction.g.dart | 26 ----- .../stream_chat/lib/src/ws/events/event.dart | 2 + .../lib/src/ws/events/event.g.dart | 20 +++- .../test/src/core/api/message_api_test.dart | 14 +-- .../converters/v1_json_converters_test.dart | 98 +++++++++++++++++++ .../test/src/core/models/reaction_test.dart | 71 ++++---------- 19 files changed, 308 insertions(+), 135 deletions(-) create mode 100644 packages/stream_chat/lib/src/core/models/reaction.freezed.dart delete mode 100644 packages/stream_chat/lib/src/core/models/reaction.g.dart diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index b0a31289e6..b982fe0a6a 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -79,7 +79,7 @@ from the spec, so don't subclass them or depend on their private constructors. | [**App Settings**](#app-settings) | `getAppSettings` returns a `Result` instead of throwing; `GetAppSettingsResponse` is renamed `AppSettingsResponse` | | [**Guest Users**](#guest-users) | `connectGuestUser` is unchanged; `StreamChatApi.guest` and `ConnectGuestUserResponse` are removed | | [**File Upload**](#file-upload) | Uploads return a `Result` and deletes a `Result` instead of throwing; `AttachmentFileUploaderProvider` receives a `Dio` | -| [**Messages**](#messages) | `Moderation`, `ReactionGroup` and `Action` no longer decode from or encode to JSON; `Action` compares by value | +| [**Messages**](#messages) | `Moderation`, `ReactionGroup`, `Action` and `Reaction` no longer decode from or encode to JSON; `Action` compares by value | | _(filled in per feature as PRs land)_ | | --- @@ -227,6 +227,8 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `ReactionGroup extends Equatable`, `ReactionGroup.props` | `ReactionGroup` (value `==`) | `removed` | Equality and `copyWith` are unchanged; `props` is gone and `ReactionGroup` is no longer an `Equatable` | | `Action.fromJson` / `toJson` | — | `removed` | A plain class; construct it directly. `Attachment.fromJson` and `toJson` still read and write an attachment's actions | | `Action` identity `==` | value `==`, plus `copyWith` | `retyped` | Two actions with the same fields are now equal, and so are attachments that differ only in holding separate copies of them | +| `Reaction.fromJson` / `toJson` | — | `removed` | A plain class; construct it directly. Messages, events and `sendReaction` still read and write reactions | +| `Reaction extends Equatable`, `Reaction.props` | `Reaction` (value `==`) | `removed` | Equality, `copyWith` and `merge` are unchanged; `props` is gone and `Reaction` is no longer an `Equatable` | | _(more added per feature as PRs land)_ | | | | --- @@ -796,6 +798,9 @@ JSON. `copyWith`. Two actions with the same fields are now equal, where v10 compared them by identity; an `Attachment` holding them follows suit. +**`Reaction` is a plain class.** It no longer decodes from or encodes to JSON and is no longer an `Equatable`; +equality, `copyWith` and `merge` are unchanged. Messages, events and `sendReaction` still carry reactions as before. + ```dart // v10 final moderation = Moderation.fromJson(json); diff --git a/openapi-migration/10-messages.md b/openapi-migration/10-messages.md index 5e620f4af9..1a68809ed6 100644 --- a/openapi-migration/10-messages.md +++ b/openapi-migration/10-messages.md @@ -79,6 +79,13 @@ an endpoint, so the definition of done below stays open. writes through `ActionV1JsonConverter`: `Attachment.toJson` sends the actions and `toData` stores them, and the converter writes the same keys v10 did, `value` included when null, so requests and the stored `attachments` columns are unchanged. `Action` needs no codec of its own. +- **`Reaction` is a plain `@freezed` model.** It loses `fromJson`, `toJson` and `Equatable`; equality + is unchanged. Its constructor stays non-const (`userId` from `user`, dates default to now) and it + keeps v10's hand-written `copyWith` and `merge` (`@Freezed(copyWith: false)`). `Reaction.topLevelFields` + stays public. `ReactionV1JsonConverter` reads and writes it everywhere v1 JSON carries one: the + message's latest and own reactions, `Event.reaction`, the two hand-written reaction responses, and + the `sendReaction` body, which keeps v10's request shape (type, score, emoji code, custom data at the + root). `stream_chat_persistence` stores reactions as table rows, so no codec is needed. ## Risks diff --git a/openapi-migration/README.md b/openapi-migration/README.md index ffb36003c2..a4c3a031f4 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -145,6 +145,7 @@ includes a named response that carries only `duration` today, such as `HideChann | `moderationFromV1Json` | `Message.moderation` | [10](10-messages.md) | | `reactionGroupsFromV1Json` | `Message.reactionGroups` | [10](10-messages.md) | | `ActionV1JsonConverter` | `Attachment.actions` | [10](10-messages.md) | +| `ReactionV1JsonConverter` | `Message.latestReactions` / `ownReactions`, `Event.reaction`, `QueryReactionsResponse.reactions`, `SendReactionResponse.reaction`, the `sendReaction` body | [10](10-messages.md) | | `DataSerializable` | `UserGroup`, `UserGroupMember`, `ReactionGroup` (`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) | diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 9ec9f3832c..5b5980a9be 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -587,6 +587,13 @@ def match(path): writes through `ActionV1JsonConverter`: `Attachment.toJson` sends the actions and `toData` stores them, and the converter writes the same keys v10 did, `value` included when null, so requests and the stored `attachments` columns are unchanged. `Action` needs no codec of its own. + - **`Reaction` is a plain `@freezed` model.** It loses `fromJson`, `toJson` and `Equatable`; equality + is unchanged. Its constructor stays non-const (`userId` from `user`, dates default to now) and it + keeps v10's hand-written `copyWith` and `merge` (`@Freezed(copyWith: false)`). `Reaction.topLevelFields` + stays public. `ReactionV1JsonConverter` reads and writes it everywhere v1 JSON carries one: the + message's latest and own reactions, `Event.reaction`, the two hand-written reaction responses, and + the `sendReaction` body, which keeps v10's request shape (type, score, emoji code, custom data at the + root). `stream_chat_persistence` stores reactions as table rows, so no codec is needed. """), ), dict( diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 05ccac5b36..32dc97ae2f 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -86,6 +86,7 @@ - `Moderation` no longer decodes from or encodes to JSON and no longer extends `Equatable`, so `props` is removed; it still compares by value and gains `copyWith`. - `ReactionGroup` no longer decodes from or encodes to JSON and no longer extends `Equatable`, so `props` is removed; it still compares by value. It gains `fromData` and `toData`, which read and write only the format `stream_chat_persistence` stores it in. - `Action` no longer decodes from or encodes to JSON, compares by value instead of by identity, and gains `copyWith`. +- `Reaction` no longer decodes from or encodes to JSON and no longer extends `Equatable`, so `props` is removed; it still compares by value. 🐞 Fixed diff --git a/packages/stream_chat/lib/src/core/api/message_api.dart b/packages/stream_chat/lib/src/core/api/message_api.dart index 05d4f451ee..464b2d2a3e 100644 --- a/packages/stream_chat/lib/src/core/api/message_api.dart +++ b/packages/stream_chat/lib/src/core/api/message_api.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import '../http/stream_http_client.dart'; +import '../models/converters/v1_json_converters.dart'; import '../models/draft.dart'; import '../models/draft_message.dart'; import '../models/message.dart'; @@ -222,7 +223,7 @@ class MessageApi { final response = await _client.post( '/messages/$messageId/reaction', data: json.encode({ - 'reaction': reaction.toJson(), + 'reaction': const ReactionV1JsonConverter().toJson(reaction), 'skip_push': skipPush, 'enforce_unique': enforceUnique, }), diff --git a/packages/stream_chat/lib/src/core/api/responses.dart b/packages/stream_chat/lib/src/core/api/responses.dart index f288c9cf9f..94f630f7ba 100644 --- a/packages/stream_chat/lib/src/core/api/responses.dart +++ b/packages/stream_chat/lib/src/core/api/responses.dart @@ -4,6 +4,7 @@ import '../../ws/events/event.dart'; import '../models/banned_user.dart'; import '../models/channel_model.dart'; import '../models/channel_state.dart'; +import '../models/converters/v1_json_converters.dart'; import '../models/draft.dart'; import '../models/location.dart'; import '../models/member.dart'; @@ -141,6 +142,7 @@ class QueryBannedUsersResponse extends _BaseResponse { class QueryReactionsResponse extends _BaseResponse { /// List of reactions returned by the query @JsonKey(defaultValue: []) + @ReactionV1JsonConverter() late List reactions; /// The cursor for the next page of results. @@ -167,6 +169,7 @@ class QueryRepliesResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class SendReactionResponse extends MessageResponse { /// The reaction created by the api call + @ReactionV1JsonConverter() late Reaction reaction; /// Create a new instance from a json diff --git a/packages/stream_chat/lib/src/core/api/responses.g.dart b/packages/stream_chat/lib/src/core/api/responses.g.dart index f7d5e36ba1..6bb1cc2a87 100644 --- a/packages/stream_chat/lib/src/core/api/responses.g.dart +++ b/packages/stream_chat/lib/src/core/api/responses.g.dart @@ -73,7 +73,14 @@ QueryReactionsResponse _$QueryReactionsResponseFromJson( ) => QueryReactionsResponse() ..duration = json['duration'] as String? ..reactions = - (json['reactions'] as List?)?.map((e) => Reaction.fromJson(e as Map)).toList() ?? [] + (json['reactions'] as List?) + ?.map( + (e) => const ReactionV1JsonConverter().fromJson( + e as Map, + ), + ) + .toList() ?? + [] ..next = json['next'] as String?; QueryRepliesResponse _$QueryRepliesResponseFromJson( @@ -88,7 +95,9 @@ SendReactionResponse _$SendReactionResponseFromJson( ) => SendReactionResponse() ..duration = json['duration'] as String? ..message = Message.fromJson(json['message'] as Map) - ..reaction = Reaction.fromJson(json['reaction'] as Map); + ..reaction = const ReactionV1JsonConverter().fromJson( + json['reaction'] as Map, + ); UpdateUsersResponse _$UpdateUsersResponseFromJson(Map json) => UpdateUsersResponse() ..duration = json['duration'] as String? 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 5c3c0b5ae5..f5ae56358a 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,11 +2,14 @@ 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 '../action.dart'; import '../device.dart'; import '../moderation.dart'; import '../push_provider.dart'; +import '../reaction.dart'; import '../reaction_group.dart'; +import '../user.dart'; import '../user_group.dart'; import '../user_group_member.dart'; @@ -40,6 +43,50 @@ class ActionV1JsonConverter implements JsonConverter> { + /// Creates a new [ReactionV1JsonConverter]. + const ReactionV1JsonConverter(); + + @override + Reaction fromJson(Map json) { + final data = Serializer.moveToExtraDataFromRoot(json, Reaction.topLevelFields); + return Reaction( + messageId: data['message_id'] as String?, + type: data['type'] as String, + user: switch (data['user']) { + final Map user => User.fromJson(user), + _ => null, + }, + userId: data['user_id'] as String?, + score: (data['score'] as num?)?.toInt() ?? 1, + emojiCode: data['emoji_code'] as String?, + createdAt: switch (data['created_at']) { + final Object it => _dateTime.fromJson(it), + null => null, + }, + updatedAt: switch (data['updated_at']) { + final Object it => _dateTime.fromJson(it), + null => null, + }, + extraData: data['extra_data'] as Map, + ); + } + + @override + Map toJson(Reaction reaction) => Serializer.moveFromExtraDataToRoot({ + 'type': reaction.type, + 'score': reaction.score, + if (reaction.emojiCode case final emojiCode?) 'emoji_code': emojiCode, + 'extra_data': reaction.extraData, + }); +} + /// Converts a [Device] to and from its v1 `id` and `push_provider` keys. // TODO(openapi-migration): remove in group 09 @internal diff --git a/packages/stream_chat/lib/src/core/models/message.dart b/packages/stream_chat/lib/src/core/models/message.dart index ea1db4f882..66dda0d862 100644 --- a/packages/stream_chat/lib/src/core/models/message.dart +++ b/packages/stream_chat/lib/src/core/models/message.dart @@ -191,10 +191,12 @@ class Message extends Equatable { /// The latest reactions to the message created by any user. @JsonKey(includeToJson: false) + @ReactionV1JsonConverter() final List? latestReactions; /// The reactions added to the message by the current user. @JsonKey(includeToJson: false) + @ReactionV1JsonConverter() final List? ownReactions; /// The ID of the parent message, if the message is a thread reply. 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 86edd300f9..4768a01ff1 100644 --- a/packages/stream_chat/lib/src/core/models/message.g.dart +++ b/packages/stream_chat/lib/src/core/models/message.g.dart @@ -27,10 +27,10 @@ Message _$MessageFromJson(Map json) => Message( Message._reactionGroupsReadValue(json, 'reaction_groups') as Map?, ), latestReactions: (json['latest_reactions'] as List?) - ?.map((e) => Reaction.fromJson(e as Map)) + ?.map((e) => const ReactionV1JsonConverter().fromJson(e as Map)) .toList(), ownReactions: (json['own_reactions'] as List?) - ?.map((e) => Reaction.fromJson(e as Map)) + ?.map((e) => const ReactionV1JsonConverter().fromJson(e as Map)) .toList(), parentId: json['parent_id'] as String?, quotedMessage: json['quoted_message'] == null diff --git a/packages/stream_chat/lib/src/core/models/reaction.dart b/packages/stream_chat/lib/src/core/models/reaction.dart index 2b0cf5f3e3..66131417be 100644 --- a/packages/stream_chat/lib/src/core/models/reaction.dart +++ b/packages/stream_chat/lib/src/core/models/reaction.dart @@ -1,16 +1,16 @@ -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 '../util/serializer.dart'; import 'user.dart'; -part 'reaction.g.dart'; +part 'reaction.freezed.dart'; -/// The class that defines a reaction -@JsonSerializable() -class Reaction extends Equatable { - /// Constructor used for json serialization +/// A reaction a user added to a message. +@Freezed(copyWith: false) +class Reaction with _$Reaction { + /// Creates a new [Reaction]. + /// + /// [userId] defaults to the id of [user], and [createdAt] and [updatedAt] default to now. Reaction({ this.messageId, required this.type, @@ -25,45 +25,40 @@ class Reaction extends Equatable { createdAt = createdAt ?? DateTime.timestamp(), updatedAt = updatedAt ?? DateTime.timestamp(); - /// Create a new instance from a json - factory Reaction.fromJson(Map json) => _$ReactionFromJson( - Serializer.moveToExtraDataFromRoot( - json, - topLevelFields, - ), - ); - /// The messageId to which the reaction belongs - @JsonKey(includeToJson: false) + @override final String? messageId; /// The type of the reaction + @override final String type; /// The score of the reaction (ie. number of reactions sent) + @override final int score; /// The emoji code of the reaction (used for notifications) - @JsonKey(includeIfNull: false) + @override final String? emojiCode; /// The user that sent the reaction - @JsonKey(includeToJson: false) + @override final User? user; /// The userId that sent the reaction - @JsonKey(includeToJson: false) + @override final String? userId; /// The date of the reaction - @JsonKey(includeToJson: false) + @override final DateTime createdAt; /// The date of the reaction update - @JsonKey(includeToJson: false) + @override final DateTime updatedAt; /// Reaction custom extraData + @override final Map extraData; /// Map of custom user extraData @@ -78,11 +73,6 @@ class Reaction extends Equatable { 'updated_at', ]; - /// Serialize to json - Map toJson() => Serializer.moveFromExtraDataToRoot( - _$ReactionToJson(this), - ); - /// Creates a copy of [Reaction] with specified attributes overridden. Reaction copyWith({ String? messageId, @@ -119,19 +109,6 @@ class Reaction extends Equatable { updatedAt: other.updatedAt, extraData: other.extraData, ); - - @override - List get props => [ - messageId, - type, - user, - userId, - score, - emojiCode, - createdAt, - updatedAt, - extraData, - ]; } /// A filter for a reaction query. diff --git a/packages/stream_chat/lib/src/core/models/reaction.freezed.dart b/packages/stream_chat/lib/src/core/models/reaction.freezed.dart new file mode 100644 index 0000000000..bebce4b81d --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/reaction.freezed.dart @@ -0,0 +1,60 @@ +// 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 'reaction.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$Reaction { + String? get messageId; + String get type; + int get score; + String? get emojiCode; + User? get user; + String? get userId; + DateTime get createdAt; + DateTime get updatedAt; + Map get extraData; + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is Reaction && + (identical(other.messageId, messageId) || other.messageId == messageId) && + (identical(other.type, type) || other.type == type) && + (identical(other.score, score) || other.score == score) && + (identical(other.emojiCode, emojiCode) || other.emojiCode == emojiCode) && + (identical(other.user, user) || other.user == user) && + (identical(other.userId, userId) || other.userId == userId) && + (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.hash( + runtimeType, + messageId, + type, + score, + emojiCode, + user, + userId, + createdAt, + updatedAt, + const DeepCollectionEquality().hash(extraData), + ); + + @override + String toString() { + return 'Reaction(messageId: $messageId, type: $type, score: $score, emojiCode: $emojiCode, user: $user, userId: $userId, createdAt: $createdAt, updatedAt: $updatedAt, extraData: $extraData)'; + } +} diff --git a/packages/stream_chat/lib/src/core/models/reaction.g.dart b/packages/stream_chat/lib/src/core/models/reaction.g.dart deleted file mode 100644 index a3005bd298..0000000000 --- a/packages/stream_chat/lib/src/core/models/reaction.g.dart +++ /dev/null @@ -1,26 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'reaction.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -Reaction _$ReactionFromJson(Map json) => Reaction( - messageId: json['message_id'] as String?, - type: json['type'] as String, - user: json['user'] == null ? null : User.fromJson(json['user'] as Map), - userId: json['user_id'] as String?, - score: (json['score'] as num?)?.toInt() ?? 1, - emojiCode: json['emoji_code'] 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), - extraData: json['extra_data'] as Map? ?? const {}, -); - -Map _$ReactionToJson(Reaction instance) => { - 'type': instance.type, - 'score': instance.score, - 'emoji_code': ?instance.emojiCode, - 'extra_data': instance.extraData, -}; diff --git a/packages/stream_chat/lib/src/ws/events/event.dart b/packages/stream_chat/lib/src/ws/events/event.dart index 7f505a8672..1df6874a05 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'; @@ -109,6 +110,7 @@ class Event extends WsEvent { final Member? member; /// The reaction sent with the event + @ReactionV1JsonConverter() final Reaction? reaction; /// The number of unread messages for current user 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..8eab87c2e0 100644 --- a/packages/stream_chat/lib/src/ws/events/event.g.dart +++ b/packages/stream_chat/lib/src/ws/events/event.g.dart @@ -19,7 +19,10 @@ Event _$EventFromJson(Map json) => Event( pollVote: json['poll_vote'] == null ? null : PollVote.fromJson(json['poll_vote'] as Map), 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), + reaction: _$JsonConverterFromJson, Reaction>( + json['reaction'], + const ReactionV1JsonConverter().fromJson, + ), online: json['online'] as bool?, channel: json['channel'] == null ? null : ChannelModel.fromJson(json['channel'] as Map), member: json['member'] == null ? null : Member.fromJson(json['member'] as Map), @@ -81,7 +84,10 @@ Map _$EventToJson(Event instance) => { 'poll_vote': ?instance.pollVote?.toJson(), 'channel': ?instance.channel?.toJson(), 'member': ?instance.member?.toJson(), - 'reaction': ?instance.reaction?.toJson(), + 'reaction': ?_$JsonConverterToJson, Reaction>( + instance.reaction, + const ReactionV1JsonConverter().toJson, + ), 'total_unread_count': ?instance.totalUnreadCount, 'unread_channels': ?instance.unreadChannels, 'online': ?instance.online, @@ -110,6 +116,11 @@ 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 +128,8 @@ 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/test/src/core/api/message_api_test.dart b/packages/stream_chat/test/src/core/api/message_api_test.dart index 0cfeacdd83..54656dc762 100644 --- a/packages/stream_chat/test/src/core/api/message_api_test.dart +++ b/packages/stream_chat/test/src/core/api/message_api_test.dart @@ -291,7 +291,7 @@ void main() { () => client.post( path, data: jsonEncode({ - 'reaction': reaction.toJson(), + 'reaction': {'type': reactionType, 'score': 1}, 'skip_push': false, 'enforce_unique': false, }), @@ -301,7 +301,7 @@ void main() { path, data: { 'message': message.toJson(), - 'reaction': {...reaction.toJson(), 'message_id': messageId}, + 'reaction': {'type': reactionType, 'score': 1, 'message_id': messageId}, }, ), ); @@ -330,7 +330,7 @@ void main() { () => client.post( path, data: jsonEncode({ - 'reaction': reaction.toJson(), + 'reaction': {'type': reactionType, 'score': 1}, 'skip_push': false, 'enforce_unique': true, }), @@ -340,7 +340,7 @@ void main() { path, data: { 'message': message.toJson(), - 'reaction': {...reaction.toJson(), 'message_id': messageId}, + 'reaction': {'type': reactionType, 'score': 1, 'message_id': messageId}, }, ), ); @@ -403,7 +403,7 @@ void main() { data: { 'reactions': [ ...reactions.map( - (it) => {...it.toJson(), 'message_id': messageId}, + (it) => {'type': it.type, 'score': it.score, 'message_id': messageId}, ), ], }, @@ -709,7 +709,7 @@ void main() { data: { 'reactions': [ ...reactions.map( - (it) => {...it.toJson(), 'message_id': messageId}, + (it) => {'type': it.type, 'score': it.score, 'message_id': messageId}, ), ], 'next': null, @@ -749,7 +749,7 @@ void main() { data: { 'reactions': [ ...reactions.map( - (it) => {...it.toJson(), 'message_id': messageId}, + (it) => {'type': it.type, 'score': it.score, 'message_id': messageId}, ), ], 'next': nextCursor, diff --git a/packages/stream_chat/test/src/core/models/converters/v1_json_converters_test.dart b/packages/stream_chat/test/src/core/models/converters/v1_json_converters_test.dart index d9848025f8..e4ec1f153d 100644 --- a/packages/stream_chat/test/src/core/models/converters/v1_json_converters_test.dart +++ b/packages/stream_chat/test/src/core/models/converters/v1_json_converters_test.dart @@ -5,11 +5,16 @@ import 'package:stream_chat/src/core/models/device.dart'; import 'package:stream_chat/src/core/models/message.dart'; import 'package:stream_chat/src/core/models/moderation.dart'; import 'package:stream_chat/src/core/models/push_provider.dart'; +import 'package:stream_chat/src/core/models/reaction.dart'; import 'package:stream_chat/src/core/models/reaction_group.dart'; +import 'package:stream_chat/src/core/models/user.dart'; import 'package:stream_chat/src/core/models/user_group.dart'; import 'package:stream_chat/src/core/models/user_group_member.dart'; +import 'package:stream_chat/src/ws/events/event.dart'; import 'package:test/test.dart'; +import '../../../utils.dart'; + void main() { test('DeviceV1JsonConverter.fromJson reads a v1 device, ignoring the fields a Device does not carry', () { const converter = DeviceV1JsonConverter(); @@ -261,4 +266,97 @@ void main() { expect(Attachment.fromJson(attachment.toJson()).actions, attachment.actions); }); + + test('Event.fromJson reads every reaction field from its v1 keys, with custom data in extraData', () { + final json = jsonFixture('reaction.json'); + + final event = Event.fromJson({ + 'type': 'reaction.new', + 'reaction': {...json, 'bananas': 'yes'}, + }); + + expect( + event.reaction, + Reaction( + messageId: '76cd8c82-b557-4e48-9d12-87995d3a0e04', + type: 'wow', + user: User.fromJson(json['user'] as Map), + userId: '2de0297c-f3f2-489d-b930-ef77342edccf', + emojiCode: '😮', + createdAt: DateTime.parse('2020-01-28T22:17:31.108742Z'), + updatedAt: DateTime.parse('2020-01-28T22:17:31.108742Z'), + extraData: const {'bananas': 'yes'}, + ), + ); + }); + + test('Event.fromJson reads a reaction without a score as scoring one', () { + final event = Event.fromJson(const { + 'type': 'reaction.new', + 'reaction': { + 'type': 'like', + 'created_at': '2020-01-28T22:17:31.108742Z', + 'updated_at': '2020-01-28T22:17:31.108742Z', + }, + }); + + expect(event.reaction!.score, 1); + }); + + test('Event.toJson writes the reaction in the request shape, with custom data at the root', () { + final event = Event( + type: 'reaction.new', + reaction: Reaction( + messageId: 'message-id', + type: 'wow', + user: User(id: 'user-id'), + score: 2, + emojiCode: '😮', + extraData: const {'bananas': 'yes'}, + ), + ); + + expect(event.toJson()['reaction'], {'type': 'wow', 'score': 2, 'emoji_code': '😮', 'bananas': 'yes'}); + }); + + test('Message.fromJson reads the latest and own reactions', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'latest_reactions': [ + { + 'type': 'like', + 'user_id': 'user-1', + 'created_at': '2024-01-01T00:00:00.000Z', + 'updated_at': '2024-01-01T00:00:00.000Z', + }, + ], + 'own_reactions': [ + { + 'type': 'love', + 'score': 3, + 'user_id': 'user-2', + 'created_at': '2024-01-02T00:00:00.000Z', + 'updated_at': '2024-01-02T00:00:00.000Z', + }, + ], + }); + + expect(message.latestReactions, [ + Reaction( + type: 'like', + userId: 'user-1', + createdAt: DateTime.utc(2024, 1, 1), + updatedAt: DateTime.utc(2024, 1, 1), + ), + ]); + expect(message.ownReactions, [ + Reaction( + type: 'love', + score: 3, + userId: 'user-2', + createdAt: DateTime.utc(2024, 1, 2), + updatedAt: DateTime.utc(2024, 1, 2), + ), + ]); + }); } diff --git a/packages/stream_chat/test/src/core/models/reaction_test.dart b/packages/stream_chat/test/src/core/models/reaction_test.dart index 0bb3a0a49d..370bfa7187 100644 --- a/packages/stream_chat/test/src/core/models/reaction_test.dart +++ b/packages/stream_chat/test/src/core/models/reaction_test.dart @@ -4,62 +4,25 @@ import 'package:test/test.dart'; import '../../utils.dart'; +// The reaction in fixtures/reaction.json, built directly. +Reaction _fixtureReaction() { + final json = jsonFixture('reaction.json'); + return Reaction( + messageId: json['message_id'] as String, + type: json['type'] as String, + user: User.fromJson(json['user'] as Map), + userId: json['user_id'] as String, + score: json['score'] as int, + emojiCode: json['emoji_code'] as String, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + ); +} + void main() { group('src/models/reaction', () { - test('should parse json correctly', () { - final reaction = Reaction.fromJson(jsonFixture('reaction.json')); - expect(reaction.messageId, '76cd8c82-b557-4e48-9d12-87995d3a0e04'); - expect(reaction.createdAt, DateTime.parse('2020-01-28T22:17:31.108742Z')); - expect(reaction.updatedAt, DateTime.parse('2020-01-28T22:17:31.108742Z')); - expect(reaction.type, 'wow'); - expect( - reaction.user?.toJson(), - { - 'id': '2de0297c-f3f2-489d-b930-ef77342edccf', - 'role': 'user', - 'teams': [], - 'created_at': '2020-01-28T22:17:30.810011Z', - 'updated_at': '2020-01-28T22:17:31.077195Z', - 'online': false, - 'banned': false, - 'image': 'https://randomuser.me/api/portraits/women/45.jpg', - 'name': 'Daisy Morgan', - }, - ); - expect(reaction.score, 1); - expect(reaction.userId, '2de0297c-f3f2-489d-b930-ef77342edccf'); - expect(reaction.emojiCode, '😮'); - }); - - test('should serialize to json correctly', () { - final reaction = Reaction( - messageId: '76cd8c82-b557-4e48-9d12-87995d3a0e04', - createdAt: DateTime.parse('2020-01-28T22:17:31.108742Z'), - updatedAt: DateTime.parse('2020-01-28T22:17:31.108742Z'), - type: 'wow', - user: User( - id: '2de0297c-f3f2-489d-b930-ef77342edccf', - image: 'https://randomuser.me/api/portraits/women/45.jpg', - name: 'Daisy Morgan', - ), - userId: '2de0297c-f3f2-489d-b930-ef77342edccf', - extraData: const {'bananas': 'yes'}, - emojiCode: '😮', - ); - - expect( - reaction.toJson(), - { - 'type': 'wow', - 'score': 1, - 'emoji_code': '😮', - 'bananas': 'yes', - }, - ); - }); - test('copyWith', () { - final reaction = Reaction.fromJson(jsonFixture('reaction.json')); + final reaction = _fixtureReaction(); var newReaction = reaction.copyWith(); expect(newReaction.messageId, '76cd8c82-b557-4e48-9d12-87995d3a0e04'); expect(newReaction.createdAt, DateTime.parse('2020-01-28T22:17:31.108742Z')); @@ -136,7 +99,7 @@ void main() { }); test('merge', () { - final reaction = Reaction.fromJson(jsonFixture('reaction.json')); + final reaction = _fixtureReaction(); final newUserCreateTime = DateTime.now(); final newReaction = reaction.merge( From 03c00a83afb8c72424f45016504443497bf09af4 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Wed, 7 Oct 2026 14:26:00 +0200 Subject: [PATCH 15/19] refactor(llc)!: make Location a plain model Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 9 +- openapi-migration/10-messages.md | 9 ++ openapi-migration/README.md | 1 + openapi-migration/tool/generate_plan.py | 9 ++ packages/stream_chat/CHANGELOG.md | 1 + .../lib/src/core/api/responses.dart | 1 + .../lib/src/core/api/responses.g.dart | 4 +- .../lib/src/core/api/user_api.dart | 3 +- .../lib/src/core/models/channel_state.dart | 2 + .../lib/src/core/models/channel_state.g.dart | 6 +- .../models/converters/v1_json_converters.dart | 45 ++++++++++ .../lib/src/core/models/location.dart | 64 +++++--------- .../lib/src/core/models/location.freezed.dart | 66 ++++++++++++++ .../lib/src/core/models/location.g.dart | 28 ------ .../lib/src/core/models/message.dart | 1 + .../lib/src/core/models/message.g.dart | 18 +++- .../converters/v1_json_converters_test.dart | 87 +++++++++++++++++++ .../test/src/core/models/location_test.dart | 58 ++----------- 18 files changed, 278 insertions(+), 134 deletions(-) create mode 100644 packages/stream_chat/lib/src/core/models/location.freezed.dart delete mode 100644 packages/stream_chat/lib/src/core/models/location.g.dart diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index b982fe0a6a..a28453df53 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -79,7 +79,7 @@ from the spec, so don't subclass them or depend on their private constructors. | [**App Settings**](#app-settings) | `getAppSettings` returns a `Result` instead of throwing; `GetAppSettingsResponse` is renamed `AppSettingsResponse` | | [**Guest Users**](#guest-users) | `connectGuestUser` is unchanged; `StreamChatApi.guest` and `ConnectGuestUserResponse` are removed | | [**File Upload**](#file-upload) | Uploads return a `Result` and deletes a `Result` instead of throwing; `AttachmentFileUploaderProvider` receives a `Dio` | -| [**Messages**](#messages) | `Moderation`, `ReactionGroup`, `Action` and `Reaction` no longer decode from or encode to JSON; `Action` compares by value | +| [**Messages**](#messages) | `Moderation`, `ReactionGroup`, `Action`, `Reaction` and `Location` no longer decode from or encode to JSON; `Action` compares by value | | _(filled in per feature as PRs land)_ | | --- @@ -229,6 +229,8 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `Action` identity `==` | value `==`, plus `copyWith` | `retyped` | Two actions with the same fields are now equal, and so are attachments that differ only in holding separate copies of them | | `Reaction.fromJson` / `toJson` | — | `removed` | A plain class; construct it directly. Messages, events and `sendReaction` still read and write reactions | | `Reaction extends Equatable`, `Reaction.props` | `Reaction` (value `==`) | `removed` | Equality, `copyWith` and `merge` are unchanged; `props` is gone and `Reaction` is no longer an `Equatable` | +| `Location.fromJson` / `toJson` | — | `removed` | A plain class; construct it directly. Messages, channel states and the live location calls still read and write locations | +| `Location extends Equatable`, `Location.props` | `Location` (value `==`) | `removed` | Equality and `copyWith` are unchanged; `props` is gone and `Location` is no longer an `Equatable` | | _(more added per feature as PRs land)_ | | | | --- @@ -798,8 +800,9 @@ JSON. `copyWith`. Two actions with the same fields are now equal, where v10 compared them by identity; an `Attachment` holding them follows suit. -**`Reaction` is a plain class.** It no longer decodes from or encodes to JSON and is no longer an `Equatable`; -equality, `copyWith` and `merge` are unchanged. Messages, events and `sendReaction` still carry reactions as before. +**`Reaction` and `Location` are plain classes.** Neither decodes from or encodes to JSON any more, and neither is an +`Equatable`; equality and `copyWith` are unchanged, as is `Reaction.merge`. Messages, events, channel states, +`sendReaction` and the live location calls still carry them as before. ```dart // v10 diff --git a/openapi-migration/10-messages.md b/openapi-migration/10-messages.md index 1a68809ed6..63f1b445af 100644 --- a/openapi-migration/10-messages.md +++ b/openapi-migration/10-messages.md @@ -86,6 +86,15 @@ an endpoint, so the definition of done below stays open. message's latest and own reactions, `Event.reaction`, the two hand-written reaction responses, and the `sendReaction` body, which keeps v10's request shape (type, score, emoji code, custom data at the root). `stream_chat_persistence` stores reactions as table rows, so no codec is needed. +- **`Location` is a plain `@freezed` model.** It loses `fromJson`, `toJson` and `Equatable`; equality + is unchanged. Its constructor stays non-const (`endAt` normalised to UTC, dates default to now) and + it keeps v10's hand-written `copyWith` (`@Freezed(copyWith: false)`). Its `channel` and `message` + stay json_serializable `ChannelModel` and `Message`, which `LocationV1JsonConverter` decodes through + their own `fromJson`; the API nests them only in active live locations, one level deep, so the + message-location cycle ends there. The converter reads and writes `Message.sharedLocation` (the write + keeps v10's request shape: coordinates, device and end date), `ChannelState.activeLiveLocations`, + `GetActiveLiveLocationsResponse` and `updateLiveLocation`'s response. `stream_chat_persistence` stores + locations as table rows, so no codec is needed. ## Risks diff --git a/openapi-migration/README.md b/openapi-migration/README.md index a4c3a031f4..4518e27c7c 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -145,6 +145,7 @@ includes a named response that carries only `duration` today, such as `HideChann | `moderationFromV1Json` | `Message.moderation` | [10](10-messages.md) | | `reactionGroupsFromV1Json` | `Message.reactionGroups` | [10](10-messages.md) | | `ActionV1JsonConverter` | `Attachment.actions` | [10](10-messages.md) | +| `LocationV1JsonConverter` | `Message.sharedLocation`, `ChannelState.activeLiveLocations`, `GetActiveLiveLocationsResponse.activeLiveLocations`, `updateLiveLocation`'s response | [10](10-messages.md) | | `ReactionV1JsonConverter` | `Message.latestReactions` / `ownReactions`, `Event.reaction`, `QueryReactionsResponse.reactions`, `SendReactionResponse.reaction`, the `sendReaction` body | [10](10-messages.md) | | `DataSerializable` | `UserGroup`, `UserGroupMember`, `ReactionGroup` (`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) | diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 5b5980a9be..883f4b8bcd 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -594,6 +594,15 @@ def match(path): message's latest and own reactions, `Event.reaction`, the two hand-written reaction responses, and the `sendReaction` body, which keeps v10's request shape (type, score, emoji code, custom data at the root). `stream_chat_persistence` stores reactions as table rows, so no codec is needed. + - **`Location` is a plain `@freezed` model.** It loses `fromJson`, `toJson` and `Equatable`; equality + is unchanged. Its constructor stays non-const (`endAt` normalised to UTC, dates default to now) and + it keeps v10's hand-written `copyWith` (`@Freezed(copyWith: false)`). Its `channel` and `message` + stay json_serializable `ChannelModel` and `Message`, which `LocationV1JsonConverter` decodes through + their own `fromJson`; the API nests them only in active live locations, one level deep, so the + message-location cycle ends there. The converter reads and writes `Message.sharedLocation` (the write + keeps v10's request shape: coordinates, device and end date), `ChannelState.activeLiveLocations`, + `GetActiveLiveLocationsResponse` and `updateLiveLocation`'s response. `stream_chat_persistence` stores + locations as table rows, so no codec is needed. """), ), dict( diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 32dc97ae2f..d92e012a19 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -87,6 +87,7 @@ - `ReactionGroup` no longer decodes from or encodes to JSON and no longer extends `Equatable`, so `props` is removed; it still compares by value. It gains `fromData` and `toData`, which read and write only the format `stream_chat_persistence` stores it in. - `Action` no longer decodes from or encodes to JSON, compares by value instead of by identity, and gains `copyWith`. - `Reaction` no longer decodes from or encodes to JSON and no longer extends `Equatable`, so `props` is removed; it still compares by value. +- `Location` no longer decodes from or encodes to JSON and no longer extends `Equatable`, so `props` is removed; it still compares by value. 🐞 Fixed diff --git a/packages/stream_chat/lib/src/core/api/responses.dart b/packages/stream_chat/lib/src/core/api/responses.dart index 94f630f7ba..d13f278c95 100644 --- a/packages/stream_chat/lib/src/core/api/responses.dart +++ b/packages/stream_chat/lib/src/core/api/responses.dart @@ -705,6 +705,7 @@ Map _userPreferencesFromJson(Map? json) @JsonSerializable(createToJson: false) class GetActiveLiveLocationsResponse extends _BaseResponse { /// List of active live locations returned by the api call + @LocationV1JsonConverter() late List activeLiveLocations; /// Create a new instance from a json 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 6bb1cc2a87..923275d284 100644 --- a/packages/stream_chat/lib/src/core/api/responses.g.dart +++ b/packages/stream_chat/lib/src/core/api/responses.g.dart @@ -385,5 +385,7 @@ GetActiveLiveLocationsResponse _$GetActiveLiveLocationsResponseFromJson( ) => GetActiveLiveLocationsResponse() ..duration = json['duration'] as String? ..activeLiveLocations = (json['active_live_locations'] as List) - .map((e) => Location.fromJson(e as Map)) + .map( + (e) => const LocationV1JsonConverter().fromJson(e as Map), + ) .toList(); diff --git a/packages/stream_chat/lib/src/core/api/user_api.dart b/packages/stream_chat/lib/src/core/api/user_api.dart index 44fb959526..fab0bf1724 100644 --- a/packages/stream_chat/lib/src/core/api/user_api.dart +++ b/packages/stream_chat/lib/src/core/api/user_api.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:stream_core/stream_core.dart' show LocationCoordinate; import '../http/stream_http_client.dart'; +import '../models/converters/v1_json_converters.dart'; import '../models/location.dart'; import '../models/user.dart'; import 'requests.dart'; @@ -126,6 +127,6 @@ class UserApi { }), ); - return Location.fromJson(response.data); + return const LocationV1JsonConverter().fromJson(response.data); } } diff --git a/packages/stream_chat/lib/src/core/models/channel_state.dart b/packages/stream_chat/lib/src/core/models/channel_state.dart index 2695aea380..77667a8b8e 100644 --- a/packages/stream_chat/lib/src/core/models/channel_state.dart +++ b/packages/stream_chat/lib/src/core/models/channel_state.dart @@ -2,6 +2,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:stream_core/stream_core.dart' show Filter, FilterField, NullOrdering, Sort, SortDirection, SortField, Standard, normalizeStringForSort; import 'channel_model.dart'; +import 'converters/v1_json_converters.dart'; import 'draft.dart'; import 'location.dart'; import 'member.dart'; @@ -90,6 +91,7 @@ class ChannelState { final ChannelPushPreference? pushPreferences; /// The list of active live locations in the channel. + @LocationV1JsonConverter() final List? activeLiveLocations; /// Create a new instance from a json diff --git a/packages/stream_chat/lib/src/core/models/channel_state.g.dart b/packages/stream_chat/lib/src/core/models/channel_state.g.dart index 6d98a30b44..652760d341 100644 --- a/packages/stream_chat/lib/src/core/models/channel_state.g.dart +++ b/packages/stream_chat/lib/src/core/models/channel_state.g.dart @@ -27,7 +27,9 @@ ChannelState _$ChannelStateFromJson(Map json) => ChannelState( json['push_preferences'] as Map, ), activeLiveLocations: (json['active_live_locations'] as List?) - ?.map((e) => Location.fromJson(e as Map)) + ?.map( + (e) => const LocationV1JsonConverter().fromJson(e as Map), + ) .toList(), ); @@ -45,5 +47,5 @@ Map _$ChannelStateToJson( 'draft': instance.draft?.toJson(), 'pending_messages': instance.pendingMessages?.map((e) => e.toJson()).toList(), 'push_preferences': instance.pushPreferences?.toJson(), - 'active_live_locations': instance.activeLiveLocations?.map((e) => e.toJson()).toList(), + 'active_live_locations': instance.activeLiveLocations?.map(const LocationV1JsonConverter().toJson).toList(), }; 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 f5ae56358a..19dadcf9a4 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 @@ -4,7 +4,10 @@ import 'package:stream_core/stream_core.dart' show StreamDateTimeConverter; import '../../util/serializer.dart'; import '../action.dart'; +import '../channel_model.dart'; import '../device.dart'; +import '../location.dart'; +import '../message.dart'; import '../moderation.dart'; import '../push_provider.dart'; import '../reaction.dart'; @@ -43,6 +46,46 @@ class ActionV1JsonConverter implements JsonConverter> { + /// Creates a new [LocationV1JsonConverter]. + const LocationV1JsonConverter(); + + @override + Location fromJson(Map json) => Location( + channelCid: json['channel_cid'] as String?, + channel: switch (json['channel']) { + final Map channel => ChannelModel.fromJson(channel), + _ => null, + }, + messageId: json['message_id'] as String?, + message: switch (json['message']) { + final Map message => Message.fromJson(message), + _ => null, + }, + userId: json['user_id'] as String?, + latitude: (json['latitude'] as num).toDouble(), + longitude: (json['longitude'] as num).toDouble(), + createdByDeviceId: json['created_by_device_id'] as String?, + endAt: _dateTimeOrNull(json['end_at']), + createdAt: _dateTimeOrNull(json['created_at']), + updatedAt: _dateTimeOrNull(json['updated_at']), + ); + + @override + Map toJson(Location location) => { + 'latitude': location.latitude, + 'longitude': location.longitude, + if (location.createdByDeviceId case final createdByDeviceId?) 'created_by_device_id': createdByDeviceId, + if (location.endAt case final endAt?) 'end_at': endAt.toIso8601String(), + }; +} + /// Converts a [Reaction] to and from its v1 keys. /// /// Custom data sits at the root of the v1 map, beside the known keys. [toJson] writes the request shape: the type, @@ -161,6 +204,8 @@ ReactionGroup _reactionGroupFromV1Json(Map json) => ReactionGro // Dates arrive as ISO-8601 strings on v1 and as epoch nanoseconds on v2; the converter reads both. const _dateTime = StreamDateTimeConverter(); +DateTime? _dateTimeOrNull(Object? json) => json == null ? null : _dateTime.fromJson(json); + // Reads the keys directly rather than through the generated group type, whose members require `app_pk`: a payload // without it should not fail the whole message. UserGroup _userGroupFromV1Json(Map json) => UserGroup( diff --git a/packages/stream_chat/lib/src/core/models/location.dart b/packages/stream_chat/lib/src/core/models/location.dart index a4721ecc1a..0541813b93 100644 --- a/packages/stream_chat/lib/src/core/models/location.dart +++ b/packages/stream_chat/lib/src/core/models/location.dart @@ -1,10 +1,9 @@ -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 LocationCoordinate; import 'channel_model.dart'; import 'message.dart'; -part 'location.g.dart'; +part 'location.freezed.dart'; /// {@template location} /// A model class representing a shared location. @@ -17,8 +16,8 @@ part 'location.g.dart'; /// 2. **Live Location**: A location that updates in real-time and has an /// end time. /// {@endtemplate} -@JsonSerializable() -class Location extends Equatable { +@Freezed(copyWith: false) +class Location with _$Location { /// {@macro location} Location({ this.channelCid, @@ -36,54 +35,53 @@ class Location extends Equatable { createdAt = createdAt ?? DateTime.timestamp(), updatedAt = updatedAt ?? DateTime.timestamp(); - /// Create a new instance from a json - factory Location.fromJson(Map json) => _$LocationFromJson(json); - /// The channel CID where the message exists. /// /// Only set on a location that has been received, not on one built locally. - @JsonKey(includeToJson: false) + @override final String? channelCid; /// The channel where the message exists. - @JsonKey(includeToJson: false) + @override final ChannelModel? channel; /// The ID of the message that contains the shared location. - @JsonKey(includeToJson: false) + @override final String? messageId; /// The message that contains the shared location. - @JsonKey(includeToJson: false) + @override final Message? message; /// The ID of the user who shared the location. - @JsonKey(includeToJson: false) + @override final String? userId; /// The latitude of the shared location. + @override final double latitude; /// The longitude of the shared location. + @override final double longitude; - /// The ID of the device that created the reminder. - @JsonKey(includeIfNull: false) + /// The ID of the device that shared the location. + @override final String? createdByDeviceId; /// The date at which the shared location will end. - @JsonKey(includeIfNull: false) + @override final DateTime? endAt; - /// The date at which the reminder was created. - @JsonKey(includeToJson: false) + /// The date at which the location was shared. + @override final DateTime createdAt; - /// The date at which the reminder was last updated. - @JsonKey(includeToJson: false) + /// The date at which the location was last updated. + @override final DateTime updatedAt; - /// Returns true if the live location is still active (end_at > now) + /// Whether this is a live location whose [endAt] is still in the future. bool get isActive { final endAt = this.endAt; if (endAt == null) return false; @@ -91,21 +89,18 @@ class Location extends Equatable { return endAt.isAfter(DateTime.now()); } - /// Returns true if the live location is expired (end_at <= now) + /// Whether this location is not [isActive]: a static location, or a live one whose [endAt] has passed. bool get isExpired => !isActive; - /// Returns true if this is a live location (has end_at) + /// Whether this is a live location, one with an [endAt]. bool get isLive => endAt != null; - /// Returns true if this is a static location (no end_at) + /// Whether this is a static location, one without an [endAt]. bool get isStatic => endAt == null; /// Returns the coordinates of the shared location. LocationCoordinate get coordinates => .new(latitude: latitude, longitude: longitude); - /// Serialize to json - Map toJson() => _$LocationToJson(this); - /// Creates a copy of [Location] with specified attributes overridden. Location copyWith({ String? channelCid, @@ -134,19 +129,4 @@ class Location extends Equatable { updatedAt: updatedAt ?? this.updatedAt, ); } - - @override - List get props => [ - channelCid, - channel, - messageId, - message, - userId, - latitude, - longitude, - createdByDeviceId, - endAt, - createdAt, - updatedAt, - ]; } diff --git a/packages/stream_chat/lib/src/core/models/location.freezed.dart b/packages/stream_chat/lib/src/core/models/location.freezed.dart new file mode 100644 index 0000000000..35314dd42f --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/location.freezed.dart @@ -0,0 +1,66 @@ +// 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 'location.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$Location { + String? get channelCid; + ChannelModel? get channel; + String? get messageId; + Message? get message; + String? get userId; + double get latitude; + double get longitude; + String? get createdByDeviceId; + DateTime? get endAt; + DateTime get createdAt; + DateTime get updatedAt; + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is Location && + (identical(other.channelCid, channelCid) || other.channelCid == channelCid) && + (identical(other.channel, channel) || other.channel == channel) && + (identical(other.messageId, messageId) || other.messageId == messageId) && + (identical(other.message, message) || other.message == message) && + (identical(other.userId, userId) || other.userId == userId) && + (identical(other.latitude, latitude) || other.latitude == latitude) && + (identical(other.longitude, longitude) || other.longitude == longitude) && + (identical(other.createdByDeviceId, createdByDeviceId) || other.createdByDeviceId == createdByDeviceId) && + (identical(other.endAt, endAt) || other.endAt == endAt) && + (identical(other.createdAt, createdAt) || other.createdAt == createdAt) && + (identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + channelCid, + channel, + messageId, + message, + userId, + latitude, + longitude, + createdByDeviceId, + endAt, + createdAt, + updatedAt, + ); + + @override + String toString() { + return 'Location(channelCid: $channelCid, channel: $channel, messageId: $messageId, message: $message, userId: $userId, latitude: $latitude, longitude: $longitude, createdByDeviceId: $createdByDeviceId, endAt: $endAt, createdAt: $createdAt, updatedAt: $updatedAt)'; + } +} diff --git a/packages/stream_chat/lib/src/core/models/location.g.dart b/packages/stream_chat/lib/src/core/models/location.g.dart deleted file mode 100644 index b41b2bc802..0000000000 --- a/packages/stream_chat/lib/src/core/models/location.g.dart +++ /dev/null @@ -1,28 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'location.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -Location _$LocationFromJson(Map json) => Location( - channelCid: json['channel_cid'] as String?, - channel: json['channel'] == null ? null : ChannelModel.fromJson(json['channel'] as Map), - messageId: json['message_id'] as String?, - message: json['message'] == null ? null : Message.fromJson(json['message'] as Map), - userId: json['user_id'] as String?, - latitude: (json['latitude'] as num).toDouble(), - longitude: (json['longitude'] as num).toDouble(), - createdByDeviceId: json['created_by_device_id'] as String?, - endAt: json['end_at'] == null ? null : DateTime.parse(json['end_at'] 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), -); - -Map _$LocationToJson(Location instance) => { - 'latitude': instance.latitude, - 'longitude': instance.longitude, - 'created_by_device_id': ?instance.createdByDeviceId, - 'end_at': ?instance.endAt?.toIso8601String(), -}; diff --git a/packages/stream_chat/lib/src/core/models/message.dart b/packages/stream_chat/lib/src/core/models/message.dart index 66dda0d862..35a24f372d 100644 --- a/packages/stream_chat/lib/src/core/models/message.dart +++ b/packages/stream_chat/lib/src/core/models/message.dart @@ -357,6 +357,7 @@ class Message extends Equatable { /// This is used to share a location in a message, allowing users to view the /// location on a map. @JsonKey(includeIfNull: false) + @LocationV1JsonConverter() final Location? sharedLocation; /// Whether the message was deleted only for the current user. 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 4768a01ff1..6356efb08b 100644 --- a/packages/stream_chat/lib/src/core/models/message.g.dart +++ b/packages/stream_chat/lib/src/core/models/message.g.dart @@ -64,9 +64,10 @@ Message _$MessageFromJson(Map json) => Message( 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?, - sharedLocation: json['shared_location'] == null - ? null - : Location.fromJson(json['shared_location'] as Map), + sharedLocation: _$JsonConverterFromJson, Location>( + json['shared_location'], + const LocationV1JsonConverter().fromJson, + ), ); Map _$MessageToJson(Message instance) => { @@ -87,6 +88,15 @@ Map _$MessageToJson(Message instance) => { 'pin_expires': instance.pinExpires?.toIso8601String(), 'poll_id': instance.pollId, 'restricted_visibility': ?instance.restrictedVisibility, - 'shared_location': ?instance.sharedLocation?.toJson(), + 'shared_location': ?_$JsonConverterToJson, Location>( + instance.sharedLocation, + const LocationV1JsonConverter().toJson, + ), 'extra_data': instance.extraData, }; + +Value? _$JsonConverterFromJson(Object? json, Value? Function(Json json) fromJson) => + json == null ? null : fromJson(json as Json); + +Json? _$JsonConverterToJson(Value? value, Json? Function(Value value) toJson) => + value == null ? null : toJson(value); diff --git a/packages/stream_chat/test/src/core/models/converters/v1_json_converters_test.dart b/packages/stream_chat/test/src/core/models/converters/v1_json_converters_test.dart index e4ec1f153d..688c2e5f56 100644 --- a/packages/stream_chat/test/src/core/models/converters/v1_json_converters_test.dart +++ b/packages/stream_chat/test/src/core/models/converters/v1_json_converters_test.dart @@ -1,7 +1,10 @@ import 'package:stream_chat/src/core/models/action.dart'; import 'package:stream_chat/src/core/models/attachment.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/channel_state.dart'; import 'package:stream_chat/src/core/models/converters/v1_json_converters.dart'; import 'package:stream_chat/src/core/models/device.dart'; +import 'package:stream_chat/src/core/models/location.dart'; import 'package:stream_chat/src/core/models/message.dart'; import 'package:stream_chat/src/core/models/moderation.dart'; import 'package:stream_chat/src/core/models/push_provider.dart'; @@ -359,4 +362,88 @@ void main() { ), ]); }); + + test('Message.fromJson reads every shared location field from its v1 keys', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'shared_location': { + 'channel_cid': 'messaging:general', + 'message_id': 'message-id', + 'user_id': 'user-id', + 'latitude': 37.7749, + 'longitude': -122.4194, + 'created_by_device_id': 'device-id', + 'end_at': '2024-12-31T23:59:59.999Z', + 'created_at': '2024-01-01T00:00:00.000Z', + 'updated_at': '2024-01-02T00:00:00.000Z', + }, + }); + + expect( + message.sharedLocation, + Location( + channelCid: 'messaging:general', + messageId: 'message-id', + userId: 'user-id', + latitude: 37.7749, + longitude: -122.4194, + createdByDeviceId: 'device-id', + endAt: DateTime.utc(2024, 12, 31, 23, 59, 59, 999), + createdAt: DateTime.utc(2024), + updatedAt: DateTime.utc(2024, 1, 2), + ), + ); + }); + + test('ChannelState.fromJson reads active live locations with their channel and message', () { + final state = ChannelState.fromJson(const { + 'active_live_locations': [ + { + 'channel_cid': 'messaging:general', + 'channel': {'id': 'general', 'type': 'messaging', 'cid': 'messaging:general'}, + 'message_id': 'message-id', + 'message': {'id': 'message-id', 'text': 'Live location'}, + 'latitude': 1, + 'longitude': 2, + 'end_at': '2024-12-31T23:59:59.999Z', + 'created_at': '2024-01-01T00:00:00.000Z', + 'updated_at': '2024-01-01T00:00:00.000Z', + }, + ], + }); + + final location = state.activeLiveLocations!.single; + expect(location.channel, isA().having((it) => it.cid, 'cid', 'messaging:general')); + expect(location.message, isA().having((it) => it.text, 'text', 'Live location')); + expect(location.coordinates.latitude, 1.0); + expect(location.coordinates.longitude, 2.0); + }); + + test('Message.toJson writes the shared location in the request shape, with the end date in UTC', () { + final message = Message( + id: 'message-id', + sharedLocation: Location( + channelCid: 'messaging:general', + messageId: 'message-id', + userId: 'user-id', + latitude: 37.7749, + longitude: -122.4194, + createdByDeviceId: 'device-id', + endAt: DateTime.utc(2024, 12, 31, 23, 59, 59, 999), + ), + ); + + expect(message.toJson()['shared_location'], { + 'latitude': 37.7749, + 'longitude': -122.4194, + 'created_by_device_id': 'device-id', + 'end_at': '2024-12-31T23:59:59.999Z', + }); + }); + + test('Message.toJson leaves the device and end date out of a static location without them', () { + final message = Message(id: 'message-id', sharedLocation: Location(latitude: 1, longitude: 2)); + + expect(message.toJson()['shared_location'], {'latitude': 1.0, 'longitude': 2.0}); + }); } diff --git a/packages/stream_chat/test/src/core/models/location_test.dart b/packages/stream_chat/test/src/core/models/location_test.dart index cce5f9b53a..8ce697eb97 100644 --- a/packages/stream_chat/test/src/core/models/location_test.dart +++ b/packages/stream_chat/test/src/core/models/location_test.dart @@ -75,67 +75,19 @@ void main() { expect(fullLocation.updatedAt, equals(updatedAt)); }); - test('should correctly serialize to JSON', () { - final json = location.toJson(); - - expect(json['latitude'], equals(latitude)); - expect(json['longitude'], equals(longitude)); - expect(json['created_by_device_id'], equals(createdByDeviceId)); - expect(json['end_at'], isNull); - expect(json.containsKey('channel_cid'), isFalse); - expect(json.containsKey('channel'), isFalse); - expect(json.containsKey('message_id'), isFalse); - expect(json.containsKey('message'), isFalse); - expect(json.containsKey('user_id'), isFalse); - expect(json.containsKey('created_at'), isFalse); - expect(json.containsKey('updated_at'), isFalse); - }); - - test('should serialize live location with endAt correctly', () { - final endAt = DateTime.parse('2024-12-31T23:59:59.999Z'); + test('should store endAt in UTC regardless of input timezone', () { + final localEndAt = DateTime(2024, 10, 16, 17, 12, 30, 338, 726); final liveLocation = Location( latitude: latitude, longitude: longitude, createdByDeviceId: createdByDeviceId, - endAt: endAt, + endAt: localEndAt, ); - final json = liveLocation.toJson(); - - expect(json['latitude'], equals(latitude)); - expect(json['longitude'], equals(longitude)); - expect(json['created_by_device_id'], equals(createdByDeviceId)); - expect(json['end_at'], equals('2024-12-31T23:59:59.999Z')); + expect(liveLocation.endAt?.isUtc, isTrue); + expect(liveLocation.endAt, equals(localEndAt.toUtc())); }); - test( - 'should convert endAt to UTC in toJson regardless of input timezone', - () { - // Create a non-UTC DateTime (local time) - final localEndAt = DateTime(2024, 10, 16, 17, 12, 30, 338, 726); - final liveLocation = Location( - latitude: latitude, - longitude: longitude, - createdByDeviceId: createdByDeviceId, - endAt: localEndAt, - ); - - final json = liveLocation.toJson(); - final serializedEndAt = json['end_at'] as String?; - - // Verify the serialized date is in UTC format (ends with 'Z') - expect(serializedEndAt, isNotNull); - expect(serializedEndAt, endsWith('Z')); - - // Verify the stored endAt is in UTC - expect(liveLocation.endAt?.isUtc, isTrue); - - // Verify the date is the same instant, just in UTC - final expectedUtc = localEndAt.toUtc(); - expect(liveLocation.endAt, equals(expectedUtc)); - }, - ); - test('should return correct coordinates', () { final coordinates = location.coordinates; From d2c4277bc921cf3036e496fb6b1b17413f62b4d8 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 9 Oct 2026 09:41:45 +0200 Subject: [PATCH 16/19] refactor(llc)!: route message reminders through the generated client Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 61 ++ openapi-migration/06-reminders.md | 55 +- openapi-migration/09-users.md | 3 +- openapi-migration/10-messages.md | 27 + openapi-migration/README.md | 10 +- openapi-migration/tool/generate_plan.py | 76 ++- packages/stream_chat/CHANGELOG.md | 6 + .../lib/src/client/channel/channel.dart | 26 +- .../stream_chat/lib/src/client/client.dart | 61 +- .../lib/src/core/api/reminders_api.dart | 89 --- .../lib/src/core/api/responses.dart | 35 - .../lib/src/core/api/responses.g.dart | 15 - .../lib/src/core/api/stream_chat_api.dart | 5 - .../models/converters/v1_json_converters.dart | 45 +- .../lib/src/core/models/message.dart | 118 ++-- .../lib/src/core/models/message.g.dart | 9 +- .../lib/src/core/models/message_reminder.dart | 76 +-- .../core/models/message_reminder.freezed.dart | 50 ++ .../src/core/models/message_reminder.g.dart | 28 - .../response/create_reminder_response.dart | 23 + .../create_reminder_response.freezed.dart | 77 +++ .../response/delete_reminder_response.dart | 16 + .../delete_reminder_response.freezed.dart | 71 ++ .../response/query_reminders_response.dart | 33 + .../query_reminders_response.freezed.dart | 89 +++ .../response/update_reminder_response.dart | 23 + .../update_reminder_response.freezed.dart | 77 +++ .../lib/src/core/util/message_decoding.dart | 47 ++ .../repository/mapper/attachment_mapper.dart | 63 ++ .../src/repository/mapper/drafts_mapper.dart | 56 ++ .../repository/mapper/location_mapper.dart | 24 + .../src/repository/mapper/message_mapper.dart | 89 +++ .../repository/mapper/moderation_mapper.dart | 17 + .../repository/mapper/reaction_mapper.dart | 36 + .../repository/mapper/reminders_mapper.dart | 54 ++ .../src/repository/reminders_repository.dart | 69 ++ .../stream_chat/lib/src/ws/events/event.dart | 1 + .../lib/src/ws/events/event.g.dart | 10 +- packages/stream_chat/lib/stream_chat.dart | 4 + .../test/src/client/channel/channel_test.dart | 145 ++--- .../client/client_create_reminder_test.dart | 201 ++++++ .../client/client_delete_reminder_test.dart | 41 ++ .../client/client_query_reminders_test.dart | 188 ++++++ .../client/client_update_reminder_test.dart | 118 ++++ .../test/src/client/message_fixtures.dart | 616 ++++++++++++++++++ .../test/src/core/api/reminders_api_test.dart | 272 -------- .../converters/v1_json_converters_test.dart | 98 +++ .../core/models/message_reminder_test.dart | 49 +- .../test/src/core/models/message_test.dart | 185 +++++- ...ream_message_reminder_list_controller.dart | 83 ++- ...message_reminder_list_controller_test.dart | 135 ++-- 51 files changed, 2952 insertions(+), 853 deletions(-) delete mode 100644 packages/stream_chat/lib/src/core/api/reminders_api.dart create mode 100644 packages/stream_chat/lib/src/core/models/message_reminder.freezed.dart delete mode 100644 packages/stream_chat/lib/src/core/models/message_reminder.g.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/create_reminder_response.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/create_reminder_response.freezed.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/delete_reminder_response.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/delete_reminder_response.freezed.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/query_reminders_response.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/query_reminders_response.freezed.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/update_reminder_response.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/update_reminder_response.freezed.dart create mode 100644 packages/stream_chat/lib/src/core/util/message_decoding.dart create mode 100644 packages/stream_chat/lib/src/repository/mapper/attachment_mapper.dart create mode 100644 packages/stream_chat/lib/src/repository/mapper/drafts_mapper.dart create mode 100644 packages/stream_chat/lib/src/repository/mapper/location_mapper.dart create mode 100644 packages/stream_chat/lib/src/repository/mapper/message_mapper.dart create mode 100644 packages/stream_chat/lib/src/repository/mapper/reaction_mapper.dart create mode 100644 packages/stream_chat/lib/src/repository/mapper/reminders_mapper.dart create mode 100644 packages/stream_chat/lib/src/repository/reminders_repository.dart create mode 100644 packages/stream_chat/test/src/client/client_create_reminder_test.dart create mode 100644 packages/stream_chat/test/src/client/client_delete_reminder_test.dart create mode 100644 packages/stream_chat/test/src/client/client_query_reminders_test.dart create mode 100644 packages/stream_chat/test/src/client/client_update_reminder_test.dart create mode 100644 packages/stream_chat/test/src/client/message_fixtures.dart delete mode 100644 packages/stream_chat/test/src/core/api/reminders_api_test.dart diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index 7281a65ed1..c682a34b85 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -31,6 +31,7 @@ onto Stream's OpenAPI-generated API client. - [App Settings](#app-settings) - [Guest Users](#guest-users) - [Polls](#polls) + - [Message Reminders](#message-reminders) - [File Upload](#file-upload) - [Messages](#messages) - [User Groups](#user-groups) @@ -86,6 +87,7 @@ from the spec, so don't subclass them or depend on their private constructors. | [**App Settings**](#app-settings) | `getAppSettings` returns a `Result` instead of throwing; `GetAppSettingsResponse` is renamed `AppSettingsResponse` | | [**Guest Users**](#guest-users) | `connectGuestUser` is unchanged; `StreamChatApi.guest` and `ConnectGuestUserResponse` are removed | | [**Polls**](#polls) | Poll calls return a `Result`; eight responses become `PollResponse`, `PollOptionResponse` and `PollVoteResponse`; queries take `limit`/`next`/`prev`; `VotingVisibility` is an extension type | +| [**Message Reminders**](#message-reminders) | Reminder calls return a `Result`; `deleteReminder` answers a new `DeleteReminderResponse`; `queryReminders` takes `limit`/`next`/`prev`; `MessageReminder` no longer decodes JSON | | [**File Upload**](#file-upload) | Uploads return a `Result` and deletes a `Result` instead of throwing; `AttachmentFileUploaderProvider` receives a `Dio` | | [**Partial Updates**](#partial-updates) | Channel and member partial updates — `updatePartial`, `updateName`, `updateImage`, slow mode, pin and archive — return a `Result` instead of throwing; their responses take the API's names, `UpdateChannelPartialResponse` and `UpdateMemberPartialResponse`, and `partialMemberUpdate` becomes `updateMemberPartial` | | [**Channel Lifecycle**](#channel-lifecycle) | Hiding, showing and deleting a channel return a `Result` instead of throwing; stopping watching still throws | @@ -323,6 +325,17 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `UpdateUsersResponse` identity `==` | value `==`, plus `copyWith` | `retyped` | Two instances with the same fields are now equal | | `UpdateUsersResponse.duration` (`String?`) | `String` | `retyped` | Always present; drop any `!` or `?? ''` | | `StreamChatApi.user.updateUsers` / `partialUpdateUsers` | `StreamChatClient.updateUsers` / `updateUsersPartial` | `removed` | The endpoints moved to the generated client | +| `StreamChatClient.createReminder` / `.updateReminder` / `.queryReminders` → `Future` and the like | `Future>` and the like | `retyped` | Return a `Result` instead of throwing; the same holds for `Channel.createReminder` and `.updateReminder` | +| `StreamChatClient.deleteReminder` / `Channel.deleteReminder` → `Future` | `Future>` | `retyped` | A new response carrying `duration` | +| `queryReminders(pagination: PaginationParams(limit: l, next: n))` | `queryReminders(limit: l, next: n)` | `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 this query | +| `CreateReminderResponse()..reminder = …`, `UpdateReminderResponse()..reminder = …`, `QueryRemindersResponse()..reminders = …` and their other setters | `CreateReminderResponse(duration: …, reminder: …)` and the like | `retyped` | The responses are plain classes with a const constructor and final fields | +| `CreateReminderResponse.fromJson`, `UpdateReminderResponse.fromJson`, `QueryRemindersResponse.fromJson` | — | `removed` | Construct the responses directly | +| reminder responses' `duration` (`String?`) | `String` | `retyped` | Always present; drop any `!` or `?? ''` | +| reminder responses' identity `==` | value `==`, plus `copyWith` | `retyped` | Two responses with the same fields are now equal | +| `MessageReminderResponse` | — | `removed` | The shared base class of the create and update responses | +| `MessageReminder.fromJson` / `toJson` | — | `removed` | A plain class; construct it directly. Messages and events still read reminders | +| `MessageReminder extends Equatable`, `MessageReminder.props` | `MessageReminder` (value `==`) | `removed` | Equality, `copyWith` and `merge` are unchanged; `props` is gone and `MessageReminder` is no longer an `Equatable` | +| `StreamChatApi.reminders` (`RemindersApi`) | `StreamChatClient` reminder methods | `removed` | The endpoints moved to the generated client | | _(more added per feature as PRs land)_ | | | | --- @@ -1179,6 +1192,54 @@ changes. **`StreamChatApi.polls` is removed.** Call the poll methods on `StreamChatClient` or `Channel` instead. +### Message Reminders + +**Every reminder call returns a `Result` instead of throwing,** on `StreamChatClient` and on `Channel`. 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.createReminder(messageId, remindAt: remindAt); + showReminder(response.reminder); +} on StreamChatException catch (e) { + report(e); +} + +// v11 +final result = await client.createReminder(messageId, remindAt: remindAt); +result.fold( + onSuccess: (response) => showReminder(response.reminder), + onFailure: (error, _) => report(error), +); +``` + +**`deleteReminder` answers a `DeleteReminderResponse`** where v10 answered an `EmptyResponse`. It carries only +`duration` today. + +**`queryReminders` takes `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 this query. The response now +carries a `prev` cursor next to `next`; pass it as `prev` to fetch the page before. + +```dart +// v10 +await client.queryReminders(pagination: PaginationParams(limit: 10, next: cursor)); + +// v11 +await client.queryReminders(limit: 10, next: cursor); +``` + +**`MessageReminder` and the reminder responses no longer decode JSON.** Build them with their constructors — +`CreateReminderResponse(duration: '0ms', reminder: reminder)` where v10 wrote +`CreateReminderResponse()..reminder = reminder`. `Message.reminder` and the reminder events still decode from the +same keys. `MessageReminder` is no longer an `Equatable`: equality, `copyWith` and `merge` are unchanged, and +`props` is gone. + +**`StreamChatApi.reminders` is removed.** Call the reminder methods on `StreamChatClient` or `Channel` instead. + +> **Why:** a failure is a value the caller handles where it happens, and the public models stop being wire shapes, +> so the API payload can change without changing the type a caller holds. + ### File Upload **Uploads and deletes return a `Result` instead of throwing.** `sendImage`, `sendFile`, `uploadImage` and diff --git a/openapi-migration/06-reminders.md b/openapi-migration/06-reminders.md index 969d08f45e..4e7ca1ad7e 100644 --- a/openapi-migration/06-reminders.md +++ b/openapi-migration/06-reminders.md @@ -2,7 +2,7 @@ **Goal:** Small and self-contained, and it exercises the `PATCH` shape our hand-written layer expresses differently. -**Size:** 4 hand-written method(s) across 1 file(s) → 4 generated operation(s). +**Size:** 0 hand-written method(s) across 0 file(s) → 4 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,10 +13,6 @@ | File | Method | Returns | | --- | --- | --- | -| `reminders_api.dart` | `queryReminders` | `QueryRemindersResponse` | -| `reminders_api.dart` | `createReminder` | `CreateReminderResponse` | -| `reminders_api.dart` | `updateReminder` | `UpdateReminderResponse` | -| `reminders_api.dart` | `deleteReminder` | `EmptyResponse` | ### Generated operations that cover it @@ -27,9 +23,34 @@ | `POST` | `/api/v2/chat/reminders/query` | `queryReminders` | `QueryRemindersResponse` | | `PATCH` | `/api/v2/chat/messages/{message_id}/reminders` | `updateReminder` | `UpdateReminderResponse` | -## Decisions to make +## Decisions taken -- `MessageReminder` is public and persisted; decide keep-vs-adopt with persistence in the same PR. +- **`MessageReminder` is a plain `@freezed` model.** It loses `fromJson`, `toJson` and `Equatable`; + equality is unchanged. Its constructor stays non-const (dates default to now) and it keeps v10's + hand-written `copyWith` and `merge` (`@Freezed(copyWith: false)`). `expiresAt` stays out; adding it + is additive. It is not persisted after all: `Message.reminder` is never written to JSON and + `stream_chat_persistence` has no reminder column, so no codec is needed. +- **v1 JSON keeps decoding through `MessageReminderV1JsonConverter`** on `Message.reminder` and + `Event.reminder`. `Event` writes the field too, so the converter runs both ways and writes the keys + v10 did (ids and dates, without the channel, message or user). +- **The four methods return a `Result`,** on `StreamChatClient` and `Channel`. `deleteReminder` + answers the named `DeleteReminderResponse`, so it returns a new `DeleteReminderResponse` envelope + rather than `Result`. `CreateReminderResponse`, `UpdateReminderResponse` and + `QueryRemindersResponse` keep their names; `reminder` is non-null, as the spec requires it. Their + shared base class `MessageReminderResponse` is removed (approved). +- **`queryReminders` takes `limit`, `next` and `prev`** instead of `PaginationParams` (approved). The + backend reads only those (default limit 10, at most 100, both cursors together rejected), so + the other `PaginationParams` fields were never honoured. `limit` defaults to 10, and the response + gains `prev`. +- **A null `remindAt` on `updateReminder` clears the due date,** as in v10: the backend treats an + absent and an explicit null `remind_at` alike, and the update is not a partial patch. +- **The v1 and v2 routes reach the same controllers** (`lib/chat/routes.go` mounts `coreRoutes` under + both), with no feature flag or beta gate; create still checks the channel's + `user_message_reminders` setting. +- **The reminder's message maps through the message mapper** of [10](10-messages.md), its first + consumer. Create and update answer without the channel; a query includes it. +- **Verified live** against the demo app: create, bookmark, update (set and clear), query with + `next`/`prev`, both cursors rejected, delete and a second delete (404). ## Risks @@ -37,19 +58,19 @@ ## 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. -- [ ] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels +- [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)). -- [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group +- [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 673daa1efb..48fb9c651b 100644 --- a/openapi-migration/09-users.md +++ b/openapi-migration/09-users.md @@ -30,6 +30,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()` and `FullUserResponse.toModel()` drop custom data named like one of the user's own fields (`_shadowedCustomKeys`): `deactivated_at`, `deleted_at` and `shadow_banned` are refilled from the typed fields, and the `OwnUser`-only keys and `revoke_tokens_issued_before` are left out, where v1 kept them in a plain user's `extraData`. Revisit once the mapper serves plain users. +- **Decide `User`, `FullUserResponse` and `OwnUser` together, here:** which private fields a plain user exposes, whether the caller's own entry maps to `OwnUser`, and what is kept in `extraData` once `User` drops `fromJson` and `toJson`. One input, verified live while migrating [06](06-reminders.md): `blocked_user_ids`, which v1 left in every nested user's `extraData`, arrives as `[]` on every user nested in a message or reminder, on v1 and v2 alike and even for the caller with real blocks; only connect and `queryUsers` return the caller's real list. Writing it back into `extraData` in `UserResponse.toModel()` restores v10 parity for nested users at no information cost, but changes every group's mapped users, so it was deferred to this decision. - **Whether `User` promotes its `extraData`-backed getters to real fields.** `User.deactivatedAt`, `deletedAt` and `shadowBanned`, added in [20](20-user-updates.md) the way `Member` promoted its fields, arrive as root fields but live in `extraData` and are read back through getters; the constructors write them there. Promoting them is a break: their keys leave `extraData`, a key set in `extraData` no longer sets the field, and a `custom` filter or sort field naming one reads null locally. When promoting: (1) decide each field's `merge` rule (`OwnUser.merge` takes the other user's `extraData` whole today); (2) make `toJson` leave out `extraData` keys named like a field; (3) keep the constructor copying `extraData`; (4) add persistence columns; (5) point `UserFilterField.shadowBanned` at the field. Decide together with [11](11-channels-and-members.md)'s `ChannelModel` and `Member` promotions. `revokeTokensIssuedBefore` stays out of `User`. - `UserFilterField.bypassModeration` reads `extraData`, which no generated user has a field to fill. - `queryUsers` answers `FullUserResponse`, which [20](20-user-updates.md) already maps to `User`; the caller's own entry could map to `OwnUser` once `Mute`, `ChannelMute` and `PrivacySettings` have response mappers. @@ -40,7 +41,7 @@ - User data arrives over the WebSocket on nearly every event. - `ConnectUserDetails.fromOwnUser` flattens the whole `extraData` into the connect payload, so the `deactivated_at`, `deleted_at` and `shadow_banned` entries the [20](20-user-updates.md) getters read go back to the server as custom data (v10 already did this for `shadow_banned`). Strip the user's own keys there when the WebSocket moves to v2. - Landing `UserResponse` -> `User` unblocks the two fields [08](08-moderation-and-blocklists.md) had to drop from `MuteUsersResponse`: the `mutes` the call created and the `ownUser` it left behind. Adding them is additive for anyone reading the response, so revisit them here rather than leaving them dropped for good. -- Until then, a user mapped from `UserResponse` and the same user decoded from v1 JSON carry different `extraData`: the v1 path keeps `blocked_user_ids`, `deleted_at`, `deactivated_at` and `revoke_tokens_issued_before`, the mapper drops them. Equality includes `extraData`, so a `Poll` from a REST call (its `createdBy`, its votes' `user`) and the same poll from an event compare unequal. +- 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 `blocked_user_ids` and `revoke_tokens_issued_before`, and writes the two dates back as UTC ISO-8601 strings where v1 keeps the server's own string. Equality includes `extraData`, so a `Poll` from a REST call (its `createdBy`, its votes' `user`), a reminder's `Message` (its sender, mentions, reactions) and the same objects from an event compare unequal. ## Definition of done diff --git a/openapi-migration/10-messages.md b/openapi-migration/10-messages.md index 6b0c738334..8181bf267f 100644 --- a/openapi-migration/10-messages.md +++ b/openapi-migration/10-messages.md @@ -96,6 +96,33 @@ an endpoint, so the definition of done below stays open. keeps v10's request shape: coordinates, device and end date), `ChannelState.activeLiveLocations`, `GetActiveLiveLocationsResponse` and `updateLiveLocation`'s response. `stream_chat_persistence` stores locations as table rows, so no codec is needed. +- **`message_mapper.dart` maps `MessageResponse` onto today's json_serializable `Message`** through + its constructor, with the generated attachment, reaction, + reaction group, moderation, shared location, reminder, draft and draft payload types, each mapped in + its own file (`attachment_mapper.dart`, `reaction_mapper.dart`, `moderation_mapper.dart`, + `location_mapper.dart`, `drafts_mapper.dart`, `reminders_mapper.dart`). Its first + consumer is [06](06-reminders.md). `Message.fromJson` and the mapper share the type and state + derivation and the reaction groups built from counts and scores when a payload has none + (`lib/src/core/util/message_decoding.dart`, internal). +- **The keys v1 lands in `Message.extraData` stay there:** `cid` as a plain entry, and `html`, `mml`, + `image_labels` and `deleted_reply_count` behind new constructor parameters and typed getters + (`Message.html`, `mml`, `imageLabels`, `deletedReplyCount`), the pattern `ChannelModel.disabled` uses. + Dropping them would be a silent behavioural break. `DraftMessage` keeps the `html` and `mml` it is + sent as plain `extraData` entries, without getters. +- **`mentioned_channel_members` is dropped:** the SDK has no feature built on it, so the mapper leaves + it out of `extraData`, and drops a custom key of that name. `Message.fromJson` still lands it there + from v1 JSON. +- **`custom` becomes `extraData` without the keys named like the model's own fields,** for messages, + attachments, reactions and draft messages. A reaction's emoji code arrives in `custom` and maps to + `Reaction.emojiCode`. +- **Moderation actions go through `ModerationAction.fromJson`,** so legacy names read as current ones. +- **Attachments map at parity with `Attachment.fromJson`:** each gets a new local `id`, and the id the + attachment was sent with stays in `extraData`; `giphy` and `fields` are written in their v1 JSON + shape, which `GiphyInfo` and the UI read. Making `Attachment` plain, and promoting those fields, is + left to this group. +- **A reminder or location nests its message one level deep,** so the recursion ends there. +- **`MessageWithChannelResponse` gets its mapper with its first consumer** (getMessage, search), not + ahead of it. ## Risks diff --git a/openapi-migration/README.md b/openapi-migration/README.md index 294cfee303..649d3ca83e 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -17,7 +17,7 @@ generated operations in scope, the decisions that group has to make, its risks, | [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 | 0 | 13 | ☑ | -| [06](06-reminders.md) | Message Reminders | 4 | 4 | ☐ | +| [06](06-reminders.md) | Message Reminders | 0 | 4 | ☑ | | [07](07-threads-and-drafts.md) | Threads & Drafts | 7 | 7 | ☐ | | [08](08-moderation-and-blocklists.md) | Moderation & Blocklists | 0 | 34 | ☑ | | [09](09-users.md) | Users | 3 | 3 | ☐ | @@ -34,7 +34,7 @@ generated operations in scope, the decisions that group has to make, its risks, | [20](20-user-updates.md) | User Updates — split out of 09 | 0 | 2 | ☑ | | [21](21-custom-data-rename.md) | `extraData` → `custom`, every model at once | — | — | ☐ | -**Coverage:** 51 hand-written methods across 9 files, and all 129 generated operations, each claimed by exactly +**Coverage:** 47 hand-written methods across 8 files, and all 129 generated operations, each claimed by exactly one group. Verified mechanically — see [Keeping this plan honest](#keeping-this-plan-honest). @@ -160,12 +160,14 @@ includes a named response that carries only `duration` today, such as `HideChann | `reactionGroupsFromV1Json` | `Message.reactionGroups` | [10](10-messages.md) | | `ActionV1JsonConverter` | `Attachment.actions` | [10](10-messages.md) | | `LocationV1JsonConverter` | `Message.sharedLocation`, `ChannelState.activeLiveLocations`, `GetActiveLiveLocationsResponse.activeLiveLocations`, `updateLiveLocation`'s response | [10](10-messages.md) | -| `ReactionV1JsonConverter` | `Message.latestReactions` / `ownReactions`, `Event.reaction`, `QueryReactionsResponse.reactions`, `SendReactionResponse.reaction`, the `sendReaction` body | [10](10-messages.md) | +| `MessageReminderV1JsonConverter` | `Message.reminder`, `Event.reminder` | WebSocket v2 (no group) | +| `ReactionV1JsonConverter` | `Message.latestReactions` / `ownReactions`, `Event.reaction`, `QueryReactionsResponse.reactions`, `SendReactionResponse.reaction`, the `sendReaction` body | WebSocket v2 (no group) | | `DataSerializable` | `UserGroup`, `UserGroupMember`, `ReactionGroup`, `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) | | `channel_mapper.dart` (kept, re-pointed) | today's `ChannelModel`, `ChannelConfig` and `Member`, which still read and write JSON | [11](11-channels-and-members.md) | +| `message_mapper.dart`, `attachment_mapper.dart`, `reaction_mapper.dart`, `location_mapper.dart`, `drafts_mapper.dart` (kept, re-pointed) | today's `Message`, `Attachment`, `Reaction`, `Location`, `Draft` and `DraftMessage`, which still read and write JSON | [10](10-messages.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`. @@ -201,7 +203,7 @@ 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 +- **21** 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 1db4792209..3b89f30c66 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -395,13 +395,40 @@ def match(verb, path): ), dict( num='06', slug='reminders', title='Message Reminders', - hand=['reminders_api.dart'], + hand=[], match=owns('/api/v2/chat/messages/{message_id}/reminders', '/api/v2/chat/reminders'), goal='Small and self-contained, and it exercises the `PATCH` shape our hand-written layer expresses ' 'differently.', - decisions=[ - '`MessageReminder` is public and persisted; decide keep-vs-adopt with persistence in the same PR.', - ], + decisions=[], + taken=textwrap.dedent("""\ + - **`MessageReminder` is a plain `@freezed` model.** It loses `fromJson`, `toJson` and `Equatable`; + equality is unchanged. Its constructor stays non-const (dates default to now) and it keeps v10's + hand-written `copyWith` and `merge` (`@Freezed(copyWith: false)`). `expiresAt` stays out; adding it + is additive. It is not persisted after all: `Message.reminder` is never written to JSON and + `stream_chat_persistence` has no reminder column, so no codec is needed. + - **v1 JSON keeps decoding through `MessageReminderV1JsonConverter`** on `Message.reminder` and + `Event.reminder`. `Event` writes the field too, so the converter runs both ways and writes the keys + v10 did (ids and dates, without the channel, message or user). + - **The four methods return a `Result`,** on `StreamChatClient` and `Channel`. `deleteReminder` + answers the named `DeleteReminderResponse`, so it returns a new `DeleteReminderResponse` envelope + rather than `Result`. `CreateReminderResponse`, `UpdateReminderResponse` and + `QueryRemindersResponse` keep their names; `reminder` is non-null, as the spec requires it. Their + shared base class `MessageReminderResponse` is removed (approved). + - **`queryReminders` takes `limit`, `next` and `prev`** instead of `PaginationParams` (approved). The + backend reads only those (default limit 10, at most 100, both cursors together rejected), so + the other `PaginationParams` fields were never honoured. `limit` defaults to 10, and the response + gains `prev`. + - **A null `remindAt` on `updateReminder` clears the due date,** as in v10: the backend treats an + absent and an explicit null `remind_at` alike, and the update is not a partial patch. + - **The v1 and v2 routes reach the same controllers** (`lib/chat/routes.go` mounts `coreRoutes` under + both), with no feature flag or beta gate; create still checks the channel's + `user_message_reminders` setting. + - **The reminder's message maps through the message mapper** of [10](10-messages.md), its first + consumer. Create and update answer without the channel; a query includes it. + - **Verified live** against the demo app: create, bookmark, update (set and clear), query with + `next`/`prev`, both cursors rejected, delete and a second delete (404). + """), + done=DONE.replace('- [ ]', '- [x]'), risks=['Reminder events also arrive over the WebSocket.'], ), dict( @@ -578,6 +605,14 @@ def match(verb, path): 'own fields (`_shadowedCustomKeys`): `deactivated_at`, `deleted_at` and `shadow_banned` are refilled from ' 'the typed fields, and the `OwnUser`-only keys and `revoke_tokens_issued_before` are left out, where v1 ' 'kept them in a plain user\'s `extraData`. Revisit once the mapper serves plain users.', + '**Decide `User`, `FullUserResponse` and `OwnUser` together, here:** which private fields a plain user ' + 'exposes, whether the caller\'s own entry maps to `OwnUser`, and what is kept in `extraData` once `User` ' + 'drops `fromJson` and `toJson`. One input, verified live while migrating [06](06-reminders.md): ' + '`blocked_user_ids`, which v1 left in every nested user\'s `extraData`, arrives as `[]` on every user ' + 'nested in a message or reminder, on v1 and v2 alike and even for the caller with real blocks; only ' + 'connect and `queryUsers` return the caller\'s real list. Writing it back into `extraData` in ' + '`UserResponse.toModel()` restores v10 parity for nested users at no information cost, but changes every ' + 'group\'s mapped users, so it was deferred to this decision.', '**Whether `User` promotes its `extraData`-backed getters to real fields.** `User.deactivatedAt`, ' '`deletedAt` and `shadowBanned`, added in [20](20-user-updates.md) the way `Member` promoted its fields, ' 'arrive as root fields but live in `extraData` and are read back through getters; the constructors write ' @@ -606,8 +641,10 @@ def match(verb, path): '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.', + '`revoke_tokens_issued_before`; the mapper drops `blocked_user_ids` and `revoke_tokens_issued_before`, and ' + 'writes the two dates back as UTC ISO-8601 strings where v1 keeps the server\'s own string. Equality ' + 'includes `extraData`, so a `Poll` from a REST call (its `createdBy`, its votes\' `user`), a reminder\'s ' + '`Message` (its sender, mentions, reactions) and the same objects from an event compare unequal.', ], done=DONE + ( '- [ ] Temporary adapters owned by this group (`DeviceV1JsonConverter`) are deleted and removed from\n' @@ -685,6 +722,33 @@ def match(verb, path): keeps v10's request shape: coordinates, device and end date), `ChannelState.activeLiveLocations`, `GetActiveLiveLocationsResponse` and `updateLiveLocation`'s response. `stream_chat_persistence` stores locations as table rows, so no codec is needed. + - **`message_mapper.dart` maps `MessageResponse` onto today's json_serializable `Message`** through + its constructor, with the generated attachment, reaction, + reaction group, moderation, shared location, reminder, draft and draft payload types, each mapped in + its own file (`attachment_mapper.dart`, `reaction_mapper.dart`, `moderation_mapper.dart`, + `location_mapper.dart`, `drafts_mapper.dart`, `reminders_mapper.dart`). Its first + consumer is [06](06-reminders.md). `Message.fromJson` and the mapper share the type and state + derivation and the reaction groups built from counts and scores when a payload has none + (`lib/src/core/util/message_decoding.dart`, internal). + - **The keys v1 lands in `Message.extraData` stay there:** `cid` as a plain entry, and `html`, `mml`, + `image_labels` and `deleted_reply_count` behind new constructor parameters and typed getters + (`Message.html`, `mml`, `imageLabels`, `deletedReplyCount`), the pattern `ChannelModel.disabled` uses. + Dropping them would be a silent behavioural break. `DraftMessage` keeps the `html` and `mml` it is + sent as plain `extraData` entries, without getters. + - **`mentioned_channel_members` is dropped:** the SDK has no feature built on it, so the mapper leaves + it out of `extraData`, and drops a custom key of that name. `Message.fromJson` still lands it there + from v1 JSON. + - **`custom` becomes `extraData` without the keys named like the model's own fields,** for messages, + attachments, reactions and draft messages. A reaction's emoji code arrives in `custom` and maps to + `Reaction.emojiCode`. + - **Moderation actions go through `ModerationAction.fromJson`,** so legacy names read as current ones. + - **Attachments map at parity with `Attachment.fromJson`:** each gets a new local `id`, and the id the + attachment was sent with stays in `extraData`; `giphy` and `fields` are written in their v1 JSON + shape, which `GiphyInfo` and the UI read. Making `Attachment` plain, and promoting those fields, is + left to this group. + - **A reminder or location nests its message one level deep,** so the recursion ends there. + - **`MessageWithChannelResponse` gets its mapper with its first consumer** (getMessage, search), not + ahead of it. """), ), dict( diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 9b45f41e19..8792697ff2 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -133,6 +133,11 @@ - The users in an `UpdateUsersResponse` no longer carry their devices, mutes, channel mutes, privacy settings, unread counts, blocked user ids, hidden channels or token revocation time in `extraData`. - `updateUser` and `updateUsers` no longer send the user's `role`, `teams` and `teamsRole`. - `StreamChatApi.user.updateUsers` and `partialUpdateUsers` are removed; call `updateUsers` and `updateUsersPartial` on `StreamChatClient` instead. +- The reminder calls on `StreamChatClient` and `Channel` return a `Result` instead of throwing, and `deleteReminder` returns a `Result` instead of an `EmptyResponse`; a `Channel` that is not initialized still throws a `StateError`. +- `queryReminders` takes `limit` (10 when omitted), `next` and `prev` instead of `PaginationParams`, and its response carries a `prev` cursor. +- `CreateReminderResponse`, `UpdateReminderResponse` and `QueryRemindersResponse` no longer decode from JSON, are immutable, built through a const constructor, compare by value, and their `duration` is a non-nullable `String`; `MessageReminderResponse` is removed. +- `MessageReminder` no longer decodes from or encodes to JSON and no longer extends `Equatable`, so `props` is removed; it still compares by value. +- `StreamChatApi.reminders` is removed; its endpoints are reached through `StreamChatClient`. 🐞 Fixed @@ -166,6 +171,7 @@ - Added `StreamChatClient.moderation.muteUsers` and `unmuteUsers`, which mute and unmute several users in one call and report the ids that matched no user. - Added `ChannelModel.truncatedBy`, `autoTranslationEnabled` and `autoTranslationLanguage`, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, `futureChannelBanExpires`, `deletedAt` and `topLevelFields`; the constructors accept the new fields. - Added `User.deactivatedAt`, `deletedAt` and `shadowBanned`; the constructors accept the new fields. +- Added `Message.html`, `mml`, `imageLabels` and `deletedReplyCount`, read from the message's `extraData`; the constructor accepts them. 🔒 Security diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index 7e7ed53312..e497053f48 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -1582,12 +1582,12 @@ class Channel { ); } - /// Create a reminder for the given [messageId]. + /// Creates a reminder on the message with the id [messageId]. /// - /// Optionally, provide a [remindAt] date to set when the reminder should - /// be triggered. If not provided, the reminder will be created as a - /// bookmark type instead. - Future createReminder( + /// The reminder is due at [remindAt]. Without it, the reminder is a bookmark, which is never due. + /// + /// Throws a [StateError] if this channel is not initialized. + Future> createReminder( String messageId, { DateTime? remindAt, }) { @@ -1598,12 +1598,12 @@ class Channel { ); } - /// Update an existing reminder with the given [reminderId]. + /// Updates the reminder on the message with the id [messageId]. + /// + /// The reminder becomes due at [remindAt]. Without it, the reminder becomes a bookmark, which is never due. /// - /// Optionally, provide a [remindAt] date to set when the reminder should - /// be triggered. If not provided, the reminder will be updated as a - /// bookmark type instead. - Future updateReminder( + /// Throws a [StateError] if this channel is not initialized. + Future> updateReminder( String messageId, { DateTime? remindAt, }) { @@ -1614,8 +1614,10 @@ class Channel { ); } - /// Remove the reminder for the given [messageId]. - Future deleteReminder(String messageId) { + /// Deletes the reminder on the message with the id [messageId]. + /// + /// Throws a [StateError] if this channel is not initialized. + Future> deleteReminder(String messageId) { _checkInitialized(); return _client.deleteReminder(messageId); } diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 37b12e8f5d..37b98f5f0c 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -64,8 +64,10 @@ import '../core/models/request/update_user_partial_request.dart'; import '../core/models/response/add_user_group_members_response.dart'; import '../core/models/response/app_settings_response.dart'; import '../core/models/response/block_users_response.dart'; +import '../core/models/response/create_reminder_response.dart'; import '../core/models/response/create_user_group_response.dart'; import '../core/models/response/delete_channel_response.dart'; +import '../core/models/response/delete_reminder_response.dart'; import '../core/models/response/get_blocked_users_response.dart'; import '../core/models/response/get_unread_count_response.dart'; import '../core/models/response/get_user_group_response.dart'; @@ -80,6 +82,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 '../core/models/response/query_reminders_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'; @@ -87,6 +90,7 @@ import '../core/models/response/show_channel_response.dart'; import '../core/models/response/unblock_users_response.dart'; import '../core/models/response/update_channel_partial_response.dart'; import '../core/models/response/update_member_partial_response.dart'; +import '../core/models/response/update_reminder_response.dart'; import '../core/models/response/update_user_group_response.dart'; import '../core/models/response/update_users_response.dart'; import '../core/models/role_type.dart'; @@ -104,6 +108,7 @@ import '../repository/devices_repository.dart'; import '../repository/general_repository.dart'; import '../repository/moderation_repository.dart'; import '../repository/polls_repository.dart'; +import '../repository/reminders_repository.dart'; import '../repository/roles_repository.dart'; import '../repository/user_groups_repository.dart'; import '../repository/users_repository.dart'; @@ -210,6 +215,7 @@ class StreamChatClient { _userGroupsRepository = UserGroupsRepository(api); _usersRepository = UsersRepository(api); _pollsRepository = PollsRepository(api); + _remindersRepository = RemindersRepository(api); _generalRepository = GeneralRepository(api); _moderationRepository = ModerationRepository(api); _channelsRepository = ChannelsRepository(api); @@ -256,6 +262,7 @@ class StreamChatClient { late final UserGroupsRepository _userGroupsRepository; late final UsersRepository _usersRepository; late final PollsRepository _pollsRepository; + late final RemindersRepository _remindersRepository; late final GeneralRepository _generalRepository; late final ModerationRepository _moderationRepository; late final ChannelsRepository _channelsRepository; @@ -2313,52 +2320,38 @@ class StreamChatClient { ); } - /// Queries reminders for the current user. + /// Fetches one page of the current user's reminders matching [filter], ordered by [sort]. /// - /// Optionally, pass [filter], [sort] and [pagination] to filter, sort and - /// paginate the reminders. - Future queryReminders({ + /// Up to [limit] reminders 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. + Future> queryReminders({ MessageReminderFilter? filter, List? sort, - PaginationParams pagination = const PaginationParams(), - }) { - return _chatApi.reminders.queryReminders( - filter: filter, - sort: sort, - pagination: pagination, - ); - } + int limit = 10, + String? next, + String? prev, + }) => _remindersRepository.queryReminders(filter: filter, sort: sort, limit: limit, next: next, prev: prev); - /// Creates a reminder for the given [messageId]. + /// Creates a reminder on the message with the id [messageId]. /// - /// Optionally, pass [remindAt] to set the reminder time. - Future createReminder( + /// The reminder is due at [remindAt]. Without it, the reminder is a bookmark, which is never due. + Future> createReminder( String messageId, { DateTime? remindAt, - }) { - return _chatApi.reminders.createReminder( - messageId, - remindAt: remindAt, - ); - } + }) => _remindersRepository.createReminder(messageId, remindAt: remindAt); - /// Updates a reminder for the given [messageId]. + /// Updates the reminder on the message with the id [messageId]. /// - /// Optionally, pass [remindAt] to set the new reminder time. - Future updateReminder( + /// The reminder becomes due at [remindAt]. Without it, the reminder becomes a bookmark, which is never due. + Future> updateReminder( String messageId, { DateTime? remindAt, - }) { - return _chatApi.reminders.updateReminder( - messageId, - remindAt: remindAt, - ); - } + }) => _remindersRepository.updateReminder(messageId, remindAt: remindAt); - /// Deletes a reminder for the given [messageId]. - Future deleteReminder(String messageId) { - return _chatApi.reminders.deleteReminder(messageId); - } + /// Deletes the reminder on the message with the id [messageId]. + Future> deleteReminder(String messageId) => + _remindersRepository.deleteReminder(messageId); /// Lists user groups with cursor-based pagination. /// diff --git a/packages/stream_chat/lib/src/core/api/reminders_api.dart b/packages/stream_chat/lib/src/core/api/reminders_api.dart deleted file mode 100644 index f4965dc3b7..0000000000 --- a/packages/stream_chat/lib/src/core/api/reminders_api.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'dart:convert'; - -import '../http/stream_http_client.dart'; -import '../models/message_reminder.dart'; -import 'requests.dart'; -import 'responses.dart'; - -/// Defines the api dedicated to message reminders operations -class RemindersApi { - /// Initialize a new reminders api - const RemindersApi(this._client); - - final StreamHttpClient _client; - - /// Retrieves the list of reminders for the current user. - /// - /// Optionally, you can filter and sort the reminders using the [filter] and - /// [sort] parameters respectively. You can also paginate the results using - /// [pagination]. - /// - /// Returns a [QueryRemindersResponse] containing the list of reminders. - Future queryReminders({ - MessageReminderFilter? filter, - List? sort, - PaginationParams? pagination, - }) async { - final response = await _client.post( - '/reminders/query', - data: jsonEncode({ - if (filter != null) 'filter': filter, - if (sort != null) 'sort': sort, - if (pagination != null) ...pagination.toJson(), - }), - ); - - return QueryRemindersResponse.fromJson(response.data); - } - - /// Creates a new reminder for the specified [messageId]. - /// - /// You can specify the time to remind using the [remindAt] parameter. - /// - /// Returns a [CreateReminderResponse] containing the created reminder. - Future createReminder( - String messageId, { - DateTime? remindAt, - }) async { - final response = await _client.post( - '/messages/$messageId/reminders', - data: jsonEncode({ - if (remindAt != null) 'remind_at': remindAt.toUtc().toIso8601String(), - }), - ); - - return CreateReminderResponse.fromJson(response.data); - } - - /// Updates an existing reminder for the specified [messageId]. - /// - /// You can change the reminder time using the [remindAt] parameter. - /// - /// Returns an [UpdateReminderResponse] containing the updated reminder. - Future updateReminder( - String messageId, { - DateTime? remindAt, - }) async { - final response = await _client.patch( - '/messages/$messageId/reminders', - data: jsonEncode({ - if (remindAt != null) 'remind_at': remindAt.toUtc().toIso8601String(), - }), - ); - - return UpdateReminderResponse.fromJson(response.data); - } - - /// Deletes a reminder for the specified [messageId]. - /// - /// Returns an [EmptyResponse] indicating the deletion was successful. - Future deleteReminder( - String messageId, - ) async { - final response = await _client.delete( - '/messages/$messageId/reminders', - ); - - return EmptyResponse.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 c5f2ec6a84..632b2fc8be 100644 --- a/packages/stream_chat/lib/src/core/api/responses.dart +++ b/packages/stream_chat/lib/src/core/api/responses.dart @@ -9,7 +9,6 @@ import '../models/draft.dart'; import '../models/location.dart'; import '../models/member.dart'; import '../models/message.dart'; -import '../models/message_reminder.dart'; import '../models/predefined_filter.dart'; import '../models/push_preference.dart'; import '../models/reaction.dart'; @@ -437,40 +436,6 @@ class QueryDraftsResponse extends _BaseResponse { static QueryDraftsResponse fromJson(Map json) => _$QueryDraftsResponseFromJson(json); } -/// Base Model response for draft based api calls. -class MessageReminderResponse extends _BaseResponse { - /// Draft returned by the api call - late MessageReminder reminder; -} - -/// Model response for [StreamChatClient.createReminder] api call -@JsonSerializable(createToJson: false) -class CreateReminderResponse extends MessageReminderResponse { - /// Create a new instance from a json - static CreateReminderResponse fromJson(Map json) => _$CreateReminderResponseFromJson(json); -} - -/// Model response for [StreamChatClient.updateReminder] api call -@JsonSerializable(createToJson: false) -class UpdateReminderResponse extends MessageReminderResponse { - /// Create a new instance from a json - static UpdateReminderResponse fromJson(Map json) => _$UpdateReminderResponseFromJson(json); -} - -/// Model response for [StreamChatClient.queryReminders] api call -@JsonSerializable(createToJson: false) -class QueryRemindersResponse extends _BaseResponse { - /// List of reminders returned by the query - @JsonKey(defaultValue: []) - late List reminders; - - /// The next page token - late String? next; - - /// Create a new instance from a json - static QueryRemindersResponse fromJson(Map json) => _$QueryRemindersResponseFromJson(json); -} - /// Model response for [StreamChatClient.setPushPreferences] api call @JsonSerializable(createToJson: false) class UpsertPushPreferencesResponse 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 ad91552fed..6394d1e3c4 100644 --- a/packages/stream_chat/lib/src/core/api/responses.g.dart +++ b/packages/stream_chat/lib/src/core/api/responses.g.dart @@ -175,21 +175,6 @@ 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() - ..duration = json['duration'] as String? - ..reminder = MessageReminder.fromJson(json['reminder'] as Map); - -UpdateReminderResponse _$UpdateReminderResponseFromJson(Map json) => UpdateReminderResponse() - ..duration = json['duration'] as String? - ..reminder = MessageReminder.fromJson(json['reminder'] as Map); - -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?; - UpsertPushPreferencesResponse _$UpsertPushPreferencesResponseFromJson(Map json) => UpsertPushPreferencesResponse() ..duration = json['duration'] as String? 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 3726a0eba0..8627561cdc 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 'general_api.dart'; import 'message_api.dart'; import 'moderation_api.dart'; import 'push_preferences_api.dart'; -import 'reminders_api.dart'; import 'threads_api.dart'; import 'user_api.dart'; @@ -61,10 +60,6 @@ class StreamChatApi { ModerationApi get moderation => _moderation ??= ModerationApi(_client); ModerationApi? _moderation; - /// Api dedicated to message reminders operations - RemindersApi get reminders => _reminders ??= RemindersApi(_client); - RemindersApi? _reminders; - /// Api dedicated to general operations GeneralApi get general => _general ??= GeneralApi(_client); GeneralApi? _general; 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 1a588eb167..71ad2dc7a7 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 @@ -8,6 +8,7 @@ import '../channel_model.dart'; import '../device.dart'; import '../location.dart'; import '../message.dart'; +import '../message_reminder.dart'; import '../moderation.dart'; import '../poll.dart'; import '../poll_option.dart'; @@ -94,7 +95,7 @@ class LocationV1JsonConverter implements JsonConverter> { /// Creates a new [ReactionV1JsonConverter]. @@ -134,6 +135,48 @@ class ReactionV1JsonConverter implements JsonConverter> { + /// Creates a new [MessageReminderV1JsonConverter]. + const MessageReminderV1JsonConverter(); + + @override + MessageReminder fromJson(Map json) => MessageReminder( + channelCid: json['channel_cid'] as String, + channel: switch (json['channel']) { + final Map channel => ChannelModel.fromJson(channel), + _ => null, + }, + messageId: json['message_id'] as String, + message: switch (json['message']) { + final Map message => Message.fromJson(message), + _ => null, + }, + userId: json['user_id'] as String, + user: switch (json['user']) { + final Map user => User.fromJson(user), + _ => null, + }, + remindAt: _dateTimeOrNull(json['remind_at']), + createdAt: _dateTimeOrNull(json['created_at']), + updatedAt: _dateTimeOrNull(json['updated_at']), + ); + + @override + Map toJson(MessageReminder reminder) => { + 'channel_cid': reminder.channelCid, + 'message_id': reminder.messageId, + 'user_id': reminder.userId, + 'remind_at': reminder.remindAt?.toIso8601String(), + 'created_at': reminder.createdAt.toIso8601String(), + 'updated_at': reminder.updatedAt.toIso8601String(), + }; +} + /// Converts a [Device] to and from its v1 `id` and `push_provider` keys. // TODO(openapi-migration): remove in group 09 @internal diff --git a/packages/stream_chat/lib/src/core/models/message.dart b/packages/stream_chat/lib/src/core/models/message.dart index 034ffba517..3559e37f4c 100644 --- a/packages/stream_chat/lib/src/core/models/message.dart +++ b/packages/stream_chat/lib/src/core/models/message.dart @@ -5,6 +5,7 @@ import 'package:stream_core/stream_core.dart' show Filter, FilterField, Standard import 'package:uuid/uuid.dart'; import '../util/extension.dart'; +import '../util/message_decoding.dart'; import '../util/serializer.dart'; import 'attachment.dart'; import 'converters/v1_json_converters.dart'; @@ -31,6 +32,9 @@ const _nullConst = _NullConst(); @JsonSerializable() class Message extends Equatable { /// Constructor used for json serialization. + /// + /// The [html], [mml], [imageLabels] and [deletedReplyCount] arguments, when given, are stored in [extraData], + /// replacing the entry each one reads. Message({ String? id, this.text, @@ -69,7 +73,7 @@ class Message extends Equatable { this.pinnedBy, this.poll, this._pollId, - this.extraData = const {}, + Map extraData = const {}, this.state = const MessageState.initial(), this.i18n, this.restrictedVisibility, @@ -78,32 +82,54 @@ class Message extends Equatable { this.reminder, this.channelRole, this.sharedLocation, + String? html, + String? mml, + Map>? imageLabels, + int? deletedReplyCount, }) : id = id ?? const Uuid().v4(), type = MessageType(type), pinExpires = pinExpires?.toUtc(), remoteCreatedAt = createdAt, remoteUpdatedAt = updatedAt, - remoteDeletedAt = deletedAt; + remoteDeletedAt = deletedAt, + // For backwards compatibility, set 'html', 'mml', 'image_labels' and 'deleted_reply_count' in [extraData]. + // Only copied when one is given, as messages are rebuilt on every change. + extraData = html == null && mml == null && imageLabels == null && deletedReplyCount == null + ? extraData + : { + ...extraData, + 'html': ?html, + 'mml': ?mml, + 'image_labels': ?imageLabels, + 'deleted_reply_count': ?deletedReplyCount, + }; /// Create a new instance from JSON. factory Message.fromJson(Map json) { - final message = _$MessageFromJson( - Serializer.moveToExtraDataFromRoot(json, topLevelFields), - ); - - final isDeletedForMe = message.deletedForMe ?? false; - // TODO: Remove this override once type is properly enriched on the backend. - final type = isDeletedForMe ? MessageType.deleted : message.type; - final state = switch (message) { - _ when isDeletedForMe => MessageState.deletedForMe, - _ when message.deletedAt != null => MessageState.softDeleted, - _ when message.updatedAt.isAfter(message.createdAt) => MessageState.updated, - _ => MessageState.sent, - }; - - return message.copyWith(type: type, state: state); + final data = Serializer.moveToExtraDataFromRoot(json, topLevelFields); + final message = _$MessageFromJson(data); + + return message + .copyWith( + reactionGroups: + message.reactionGroups ?? + reactionGroupsFromCounts( + _countsFromJson(data['reaction_counts']), + _countsFromJson(data['reaction_scores']), + ), + ) + .withDerivedState(); } + // Reads a map of reaction type to count or score, whatever kind of number each value is. + static Map? _countsFromJson(Object? json) => switch (json) { + final Map counts => { + for (final MapEntry(:key, :value) in counts.entries) + if (value is num) key: value.toInt(), + }, + _ => null, + }; + /// The message ID. This is either created by Stream or set client side when /// the message is added. final String id; @@ -152,41 +178,8 @@ class Message extends Equatable { @JsonKey(toJson: User.toIds) final List mentionedUsers; - static Object? _reactionGroupsReadValue( - Map json, - String key, - ) { - final reactionGroups = json[key] as Map?; - if (reactionGroups != null) return reactionGroups; - - final reactionCounts = json['reaction_counts'] as Map?; - final reactionScores = json['reaction_scores'] as Map?; - if (reactionCounts == null && reactionScores == null) return null; - - final reactionTypes = {...?reactionCounts?.keys, ...?reactionScores?.keys}; - if (reactionTypes.isEmpty) return null; - - final groups = {}; - for (final type in reactionTypes) { - final count = reactionCounts?[type] ?? 0; - final sumScores = reactionScores?[type] ?? 0; - - // Keep the group while count is positive; score may be zero or negative. - if (count <= 0) continue; - final now = DateTime.timestamp(); - groups[type] = { - 'count': count, - 'sum_scores': sumScores, - 'first_reaction_at': now.toIso8601String(), - 'last_reaction_at': now.toIso8601String(), - }; - } - - return groups; - } - /// A map of reaction types and their corresponding reaction groups. - @JsonKey(includeToJson: false, readValue: _reactionGroupsReadValue, fromJson: reactionGroupsFromV1Json) + @JsonKey(includeToJson: false, fromJson: reactionGroupsFromV1Json) final Map? reactionGroups; /// The latest reactions to the message created by any user. @@ -338,6 +331,7 @@ class Message extends Equatable { /// /// This is present when a user has set a reminder for this message. @JsonKey(includeToJson: false) + @MessageReminderV1JsonConverter() final MessageReminder? reminder; static Object? _channelRoleReadValue(Map json, String key) { @@ -368,6 +362,28 @@ class Message extends Equatable { /// Message custom extraData. final Map extraData; + /// The text of this message rendered as HTML. + @JsonKey(includeToJson: false, includeFromJson: false) + String? get html => extraData['html'].safeCast(); + + /// The message markup language (MML) source of this message. + @JsonKey(includeToJson: false, includeFromJson: false) + String? get mml => extraData['mml'].safeCast(); + + /// The labels image moderation assigned to the images attached to this message, grouped by image. + @JsonKey(includeToJson: false, includeFromJson: false) + Map>? get imageLabels => switch (extraData['image_labels']) { + final Map labels => { + for (final MapEntry(:key, :value) in labels.entries) + if (value case final List imageLabels) key: imageLabels.whereType().toList(), + }, + _ => null, + }; + + /// The number of replies to this message that were deleted. + @JsonKey(includeToJson: false, includeFromJson: false) + int? get deletedReplyCount => extraData['deleted_reply_count'].safeCast(); + /// A Map of translations. @JsonKey(includeToJson: false) final Map? i18n; 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 b3fb6baef0..4e861a0cc8 100644 --- a/packages/stream_chat/lib/src/core/models/message.g.dart +++ b/packages/stream_chat/lib/src/core/models/message.g.dart @@ -23,9 +23,7 @@ Message _$MessageFromJson(Map json) => Message( const [], silent: json['silent'] as bool? ?? false, shadowed: json['shadowed'] as bool? ?? false, - reactionGroups: reactionGroupsFromV1Json( - Message._reactionGroupsReadValue(json, 'reaction_groups') as Map?, - ), + reactionGroups: reactionGroupsFromV1Json(json['reaction_groups'] as Map?), latestReactions: (json['latest_reactions'] as List?) ?.map((e) => const ReactionV1JsonConverter().fromJson(e as Map)) .toList(), @@ -62,7 +60,10 @@ Message _$MessageFromJson(Map json) => Message( restrictedVisibility: (json['restricted_visibility'] as List?)?.map((e) => e as String).toList(), moderation: moderationFromV1Json(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), + reminder: _$JsonConverterFromJson, MessageReminder>( + json['reminder'], + const MessageReminderV1JsonConverter().fromJson, + ), channelRole: Message._channelRoleReadValue(json, 'channel_role') as String?, sharedLocation: _$JsonConverterFromJson, Location>( json['shared_location'], diff --git a/packages/stream_chat/lib/src/core/models/message_reminder.dart b/packages/stream_chat/lib/src/core/models/message_reminder.dart index 77cee97e67..d4d7efb76e 100644 --- a/packages/stream_chat/lib/src/core/models/message_reminder.dart +++ b/packages/stream_chat/lib/src/core/models/message_reminder.dart @@ -1,12 +1,11 @@ -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 'channel_model.dart'; import 'message.dart'; import 'user.dart'; -part 'message_reminder.g.dart'; +part 'message_reminder.freezed.dart'; class _NullConst { const _NullConst(); @@ -15,19 +14,13 @@ class _NullConst { const _nullConst = _NullConst(); /// {@template messageReminder} -/// A model class representing a message reminder. +/// A reminder a user set on a message. /// -/// The [MessageReminder] class represents a marked message that is important -/// to the user. -/// -/// It can be of two types: -/// 1. **Scheduled Reminder**: (`remindAt != null`) - Used to notify the user -/// about a message after a certain time. -/// 2. **Bookmarks**: (`remindAt == null`) - Used to mark a message for later -/// reference without notification. +/// A reminder with a [remindAt] is scheduled: the user is notified about the message at that time. One without it +/// is a bookmark, which keeps the message for later without a notification. /// {@endtemplate} -@JsonSerializable() -class MessageReminder extends Equatable { +@Freezed(copyWith: false) +class MessageReminder with _$MessageReminder { /// {@macro messageReminder} MessageReminder({ required this.channelCid, @@ -42,45 +35,47 @@ class MessageReminder extends Equatable { }) : createdAt = createdAt ?? DateTime.now(), updatedAt = updatedAt ?? DateTime.now(); - /// Create a new instance from a json - factory MessageReminder.fromJson(Map json) => _$MessageReminderFromJson(json); - - /// The channel CID where the message exists. + /// The full id of the channel holding the message, in the form `type:id`. + @override final String channelCid; - /// The channel where the message exists. - @JsonKey(includeToJson: false) + /// The channel holding the message. + @override final ChannelModel? channel; - /// The ID of the message that is marked as important. + /// The id of the message the reminder is set on. + @override final String messageId; - /// The message that is marked as important. - @JsonKey(includeToJson: false) + /// The message the reminder is set on. + @override final Message? message; - /// The ID of the user who marked the message as important. + /// The id of the user who set the reminder. + @override final String userId; - /// The user who marked the message as important. - @JsonKey(includeToJson: false) + /// The user who set the reminder. + @override final User? user; /// The time at which the user wants to be reminded about the message. /// - /// If `null`, the reminder is a bookmark and no notification will be sent. + /// If `null`, the reminder is a bookmark and no notification is sent. + @override final DateTime? remindAt; /// The date at which the reminder was created. + @override final DateTime createdAt; /// The date at which the reminder was last updated. + @override final DateTime updatedAt; - /// Convert the object to JSON - Map toJson() => _$MessageReminderToJson(this); - - /// Creates a copy of this [Draft] with specified attributes overridden. + /// Creates a copy of this reminder with the given fields replaced. + /// + /// A field passed as null keeps its current value, except [remindAt], which a null clears. MessageReminder copyWith({ String? channelCid, ChannelModel? channel, @@ -105,8 +100,10 @@ class MessageReminder extends Equatable { ); } - /// Returns a new [MessageReminder] instance that merges the current - /// instance with another [MessageReminder] instance. + /// Returns this reminder with the fields [other] sets. + /// + /// A field [other] leaves null keeps its current value, except [remindAt]: a null one clears it. If [other] is + /// null, this reminder is returned unchanged. MessageReminder merge(MessageReminder? other) { if (other == null) return this; return copyWith( @@ -121,19 +118,6 @@ class MessageReminder extends Equatable { updatedAt: other.updatedAt, ); } - - @override - List get props => [ - channelCid, - channel, - messageId, - message, - userId, - user, - remindAt, - createdAt, - updatedAt, - ]; } /// A filter for a reminder query. diff --git a/packages/stream_chat/lib/src/core/models/message_reminder.freezed.dart b/packages/stream_chat/lib/src/core/models/message_reminder.freezed.dart new file mode 100644 index 0000000000..6aeab6eb0a --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/message_reminder.freezed.dart @@ -0,0 +1,50 @@ +// 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 'message_reminder.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$MessageReminder { + String get channelCid; + ChannelModel? get channel; + String get messageId; + Message? get message; + String get userId; + User? get user; + DateTime? get remindAt; + DateTime get createdAt; + DateTime get updatedAt; + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is MessageReminder && + (identical(other.channelCid, channelCid) || other.channelCid == channelCid) && + (identical(other.channel, channel) || other.channel == channel) && + (identical(other.messageId, messageId) || other.messageId == messageId) && + (identical(other.message, message) || other.message == message) && + (identical(other.userId, userId) || other.userId == userId) && + (identical(other.user, user) || other.user == user) && + (identical(other.remindAt, remindAt) || other.remindAt == remindAt) && + (identical(other.createdAt, createdAt) || other.createdAt == createdAt) && + (identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)); + } + + @override + int get hashCode => + Object.hash(runtimeType, channelCid, channel, messageId, message, userId, user, remindAt, createdAt, updatedAt); + + @override + String toString() { + return 'MessageReminder(channelCid: $channelCid, channel: $channel, messageId: $messageId, message: $message, userId: $userId, user: $user, remindAt: $remindAt, createdAt: $createdAt, updatedAt: $updatedAt)'; + } +} diff --git a/packages/stream_chat/lib/src/core/models/message_reminder.g.dart b/packages/stream_chat/lib/src/core/models/message_reminder.g.dart deleted file mode 100644 index 2df37be652..0000000000 --- a/packages/stream_chat/lib/src/core/models/message_reminder.g.dart +++ /dev/null @@ -1,28 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'message_reminder.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -MessageReminder _$MessageReminderFromJson(Map json) => MessageReminder( - channelCid: json['channel_cid'] as String, - channel: json['channel'] == null ? null : ChannelModel.fromJson(json['channel'] as Map), - messageId: json['message_id'] as String, - message: json['message'] == null ? null : Message.fromJson(json['message'] as Map), - userId: json['user_id'] as String, - user: json['user'] == null ? null : User.fromJson(json['user'] as Map), - remindAt: json['remind_at'] == null ? null : DateTime.parse(json['remind_at'] 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), -); - -Map _$MessageReminderToJson(MessageReminder instance) => { - 'channel_cid': instance.channelCid, - 'message_id': instance.messageId, - 'user_id': instance.userId, - 'remind_at': instance.remindAt?.toIso8601String(), - 'created_at': instance.createdAt.toIso8601String(), - 'updated_at': instance.updatedAt.toIso8601String(), -}; diff --git a/packages/stream_chat/lib/src/core/models/response/create_reminder_response.dart b/packages/stream_chat/lib/src/core/models/response/create_reminder_response.dart new file mode 100644 index 0000000000..52b1625cc9 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/create_reminder_response.dart @@ -0,0 +1,23 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../message_reminder.dart'; + +part 'create_reminder_response.freezed.dart'; + +/// The outcome of creating a reminder on a message. +@freezed +class CreateReminderResponse with _$CreateReminderResponse { + /// Creates a new [CreateReminderResponse]. + const CreateReminderResponse({ + required this.duration, + required this.reminder, + }); + + /// How long the request took to handle, such as `4.21ms`. + @override + final String duration; + + /// The reminder that was created. + @override + final MessageReminder reminder; +} diff --git a/packages/stream_chat/lib/src/core/models/response/create_reminder_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/create_reminder_response.freezed.dart new file mode 100644 index 0000000000..7daeb031d5 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/create_reminder_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 'create_reminder_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$CreateReminderResponse { + String get duration; + MessageReminder get reminder; + + /// Create a copy of CreateReminderResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $CreateReminderResponseCopyWith get copyWith => + _$CreateReminderResponseCopyWithImpl(this as CreateReminderResponse, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is CreateReminderResponse && + (identical(other.duration, duration) || other.duration == duration) && + (identical(other.reminder, reminder) || other.reminder == reminder)); + } + + @override + int get hashCode => Object.hash(runtimeType, duration, reminder); + + @override + String toString() { + return 'CreateReminderResponse(duration: $duration, reminder: $reminder)'; + } +} + +/// @nodoc +abstract mixin class $CreateReminderResponseCopyWith<$Res> { + factory $CreateReminderResponseCopyWith(CreateReminderResponse value, $Res Function(CreateReminderResponse) _then) = + _$CreateReminderResponseCopyWithImpl; + @useResult + $Res call({String duration, MessageReminder reminder}); +} + +/// @nodoc +class _$CreateReminderResponseCopyWithImpl<$Res> implements $CreateReminderResponseCopyWith<$Res> { + _$CreateReminderResponseCopyWithImpl(this._self, this._then); + + final CreateReminderResponse _self; + final $Res Function(CreateReminderResponse) _then; + + /// Create a copy of CreateReminderResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? duration = null, Object? reminder = null}) { + return _then( + CreateReminderResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + reminder: null == reminder + ? _self.reminder + : reminder // ignore: cast_nullable_to_non_nullable + as MessageReminder, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/response/delete_reminder_response.dart b/packages/stream_chat/lib/src/core/models/response/delete_reminder_response.dart new file mode 100644 index 0000000000..5325b849ee --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/delete_reminder_response.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'delete_reminder_response.freezed.dart'; + +/// The outcome of deleting the reminder on a message. +@freezed +class DeleteReminderResponse with _$DeleteReminderResponse { + /// Creates a new [DeleteReminderResponse]. + const DeleteReminderResponse({ + required this.duration, + }); + + /// How long the request took to handle, such as `4.21ms`. + @override + final String duration; +} diff --git a/packages/stream_chat/lib/src/core/models/response/delete_reminder_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/delete_reminder_response.freezed.dart new file mode 100644 index 0000000000..17915e8658 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/delete_reminder_response.freezed.dart @@ -0,0 +1,71 @@ +// 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 'delete_reminder_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$DeleteReminderResponse { + String get duration; + + /// Create a copy of DeleteReminderResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $DeleteReminderResponseCopyWith get copyWith => + _$DeleteReminderResponseCopyWithImpl(this as DeleteReminderResponse, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is DeleteReminderResponse && + (identical(other.duration, duration) || other.duration == duration)); + } + + @override + int get hashCode => Object.hash(runtimeType, duration); + + @override + String toString() { + return 'DeleteReminderResponse(duration: $duration)'; + } +} + +/// @nodoc +abstract mixin class $DeleteReminderResponseCopyWith<$Res> { + factory $DeleteReminderResponseCopyWith(DeleteReminderResponse value, $Res Function(DeleteReminderResponse) _then) = + _$DeleteReminderResponseCopyWithImpl; + @useResult + $Res call({String duration}); +} + +/// @nodoc +class _$DeleteReminderResponseCopyWithImpl<$Res> implements $DeleteReminderResponseCopyWith<$Res> { + _$DeleteReminderResponseCopyWithImpl(this._self, this._then); + + final DeleteReminderResponse _self; + final $Res Function(DeleteReminderResponse) _then; + + /// Create a copy of DeleteReminderResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? duration = null}) { + return _then( + DeleteReminderResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/response/query_reminders_response.dart b/packages/stream_chat/lib/src/core/models/response/query_reminders_response.dart new file mode 100644 index 0000000000..5b5029e491 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/query_reminders_response.dart @@ -0,0 +1,33 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../message_reminder.dart'; + +part 'query_reminders_response.freezed.dart'; + +/// One page of the current user's reminders matching a query. +@freezed +class QueryRemindersResponse with _$QueryRemindersResponse { + /// Creates a new [QueryRemindersResponse]. + const QueryRemindersResponse({ + required this.duration, + required this.reminders, + this.next, + this.prev, + }); + + /// How long the request took to handle, such as `4.21ms`. + @override + final String duration; + + /// The reminders on this page. + @override + final List reminders; + + /// 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_reminders_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/query_reminders_response.freezed.dart new file mode 100644 index 0000000000..fa56d413ae --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/query_reminders_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_reminders_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$QueryRemindersResponse { + String get duration; + List get reminders; + String? get next; + String? get prev; + + /// Create a copy of QueryRemindersResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $QueryRemindersResponseCopyWith get copyWith => + _$QueryRemindersResponseCopyWithImpl(this as QueryRemindersResponse, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is QueryRemindersResponse && + (identical(other.duration, duration) || other.duration == duration) && + const DeepCollectionEquality().equals(other.reminders, reminders) && + (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(reminders), next, prev); + + @override + String toString() { + return 'QueryRemindersResponse(duration: $duration, reminders: $reminders, next: $next, prev: $prev)'; + } +} + +/// @nodoc +abstract mixin class $QueryRemindersResponseCopyWith<$Res> { + factory $QueryRemindersResponseCopyWith(QueryRemindersResponse value, $Res Function(QueryRemindersResponse) _then) = + _$QueryRemindersResponseCopyWithImpl; + @useResult + $Res call({String duration, List reminders, String? next, String? prev}); +} + +/// @nodoc +class _$QueryRemindersResponseCopyWithImpl<$Res> implements $QueryRemindersResponseCopyWith<$Res> { + _$QueryRemindersResponseCopyWithImpl(this._self, this._then); + + final QueryRemindersResponse _self; + final $Res Function(QueryRemindersResponse) _then; + + /// Create a copy of QueryRemindersResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? duration = null, Object? reminders = null, Object? next = freezed, Object? prev = freezed}) { + return _then( + QueryRemindersResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + reminders: null == reminders + ? _self.reminders + : reminders // 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/update_reminder_response.dart b/packages/stream_chat/lib/src/core/models/response/update_reminder_response.dart new file mode 100644 index 0000000000..eca94365af --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/update_reminder_response.dart @@ -0,0 +1,23 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../message_reminder.dart'; + +part 'update_reminder_response.freezed.dart'; + +/// The outcome of updating the reminder on a message. +@freezed +class UpdateReminderResponse with _$UpdateReminderResponse { + /// Creates a new [UpdateReminderResponse]. + const UpdateReminderResponse({ + required this.duration, + required this.reminder, + }); + + /// How long the request took to handle, such as `4.21ms`. + @override + final String duration; + + /// The reminder as it is after the update. + @override + final MessageReminder reminder; +} diff --git a/packages/stream_chat/lib/src/core/models/response/update_reminder_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/update_reminder_response.freezed.dart new file mode 100644 index 0000000000..061294877e --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/update_reminder_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 'update_reminder_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$UpdateReminderResponse { + String get duration; + MessageReminder get reminder; + + /// Create a copy of UpdateReminderResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $UpdateReminderResponseCopyWith get copyWith => + _$UpdateReminderResponseCopyWithImpl(this as UpdateReminderResponse, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is UpdateReminderResponse && + (identical(other.duration, duration) || other.duration == duration) && + (identical(other.reminder, reminder) || other.reminder == reminder)); + } + + @override + int get hashCode => Object.hash(runtimeType, duration, reminder); + + @override + String toString() { + return 'UpdateReminderResponse(duration: $duration, reminder: $reminder)'; + } +} + +/// @nodoc +abstract mixin class $UpdateReminderResponseCopyWith<$Res> { + factory $UpdateReminderResponseCopyWith(UpdateReminderResponse value, $Res Function(UpdateReminderResponse) _then) = + _$UpdateReminderResponseCopyWithImpl; + @useResult + $Res call({String duration, MessageReminder reminder}); +} + +/// @nodoc +class _$UpdateReminderResponseCopyWithImpl<$Res> implements $UpdateReminderResponseCopyWith<$Res> { + _$UpdateReminderResponseCopyWithImpl(this._self, this._then); + + final UpdateReminderResponse _self; + final $Res Function(UpdateReminderResponse) _then; + + /// Create a copy of UpdateReminderResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? duration = null, Object? reminder = null}) { + return _then( + UpdateReminderResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + reminder: null == reminder + ? _self.reminder + : reminder // ignore: cast_nullable_to_non_nullable + as MessageReminder, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/util/message_decoding.dart b/packages/stream_chat/lib/src/core/util/message_decoding.dart new file mode 100644 index 0000000000..c4ddce3569 --- /dev/null +++ b/packages/stream_chat/lib/src/core/util/message_decoding.dart @@ -0,0 +1,47 @@ +import '../models/message.dart'; +import '../models/message_state.dart'; +import '../models/reaction_group.dart'; + +/// Derivations every decoder of a received [Message] applies. +extension MessageDecoding on Message { + /// Returns this message with the type and state its flags imply. + /// + /// A message deleted only for the current user reads as a [MessageType.deleted] message in the + /// [MessageState.deletedForMe] state. Otherwise the type is kept, and the state is + /// [MessageState.softDeleted] once the message has a [deletedAt], [MessageState.updated] once it was updated after + /// it was created, and [MessageState.sent] before either. + Message withDerivedState() { + final isDeletedForMe = deletedForMe ?? false; + // TODO: Remove this override once type is properly enriched on the backend. + final type = isDeletedForMe ? MessageType.deleted : this.type; + final state = switch (this) { + _ when isDeletedForMe => MessageState.deletedForMe, + _ when deletedAt != null => MessageState.softDeleted, + _ when updatedAt.isAfter(createdAt) => MessageState.updated, + _ => MessageState.sent, + }; + + return copyWith(type: type, state: state); + } +} + +/// Builds the reaction groups of a received message from its reaction [counts] and [scores], keyed by reaction type. +/// +/// Returns null when neither has an entry. A type with no positive count is left out, whatever its score. As the +/// dates of the reactions are unknown, each group's first and last reaction date is now. +Map? reactionGroupsFromCounts(Map? counts, Map? scores) { + final reactionTypes = {...?counts?.keys, ...?scores?.keys}; + if (reactionTypes.isEmpty) return null; + + final now = DateTime.timestamp(); + return { + for (final type in reactionTypes) + if ((counts?[type] ?? 0) > 0) + type: ReactionGroup( + count: counts?[type] ?? 0, + sumScores: scores?[type] ?? 0, + firstReactionAt: now, + lastReactionAt: now, + ), + }; +} diff --git a/packages/stream_chat/lib/src/repository/mapper/attachment_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/attachment_mapper.dart new file mode 100644 index 0000000000..6fb3666e81 --- /dev/null +++ b/packages/stream_chat/lib/src/repository/mapper/attachment_mapper.dart @@ -0,0 +1,63 @@ +import '../../../open_api/api.dart' as api; +import '../../core/models/action.dart'; +import '../../core/models/attachment.dart'; +import '../../core/models/attachment_file.dart'; + +// TODO(openapi-migration): re-point these mappers in group 10. + +/// Maps a generated [api.Attachment] to an [Attachment]. +extension AttachmentMapper on api.Attachment { + // Custom keys named like one of the attachment's own fields, including the ones it keeps in its extra data or only + // stores offline. + static const _shadowedCustomKeys = { + ...Attachment.topLevelFields, + 'giphy', + 'file', + 'upload_state', + }; + + /// Converts this attachment into an [Attachment]. + /// + /// The attachment gets a new local [Attachment.id]; an id it was sent with is kept in [Attachment.extraData], beside + /// the custom data and the Giphy renditions, with custom data named like one of the attachment's own fields left + /// out. + Attachment toModel() => Attachment( + type: type, + titleLink: titleLink, + title: title, + thumbUrl: thumbUrl, + text: text, + pretext: pretext, + ogScrapeUrl: ogScrapeUrl, + imageUrl: imageUrl, + footerIcon: footerIcon, + footer: footer, + fields: fields?.map((field) => field.toJson()).toList(), + fallback: fallback, + color: color, + authorName: authorName, + authorLink: authorLink, + authorIcon: authorIcon, + assetUrl: assetUrl, + actions: actions?.map((action) => action.toModel()).toList() ?? const [], + originalWidth: originalWidth, + originalHeight: originalHeight, + extraData: { + ...{...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), + if (giphy case final giphy?) 'giphy': giphy.toJson(), + }, + uploadState: const UploadState.success(), + ); +} + +/// Maps a generated [api.Action] to an [Action]. +extension ActionMapper on api.Action { + /// Converts this action into an [Action]. + Action toModel() => Action( + name: name, + style: style ?? 'default', + text: text, + type: type, + value: value, + ); +} diff --git a/packages/stream_chat/lib/src/repository/mapper/drafts_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/drafts_mapper.dart new file mode 100644 index 0000000000..45a97fbddf --- /dev/null +++ b/packages/stream_chat/lib/src/repository/mapper/drafts_mapper.dart @@ -0,0 +1,56 @@ +import '../../../open_api/api.dart' as api; +import '../../core/models/draft.dart'; +import '../../core/models/draft_message.dart'; +import '../../core/models/message.dart'; +import 'attachment_mapper.dart'; +import 'channel_mapper.dart'; +import 'message_mapper.dart'; +import 'user_mapper.dart'; + +// TODO(openapi-migration): re-point these mappers in group 10. + +/// Maps a generated [api.DraftResponse] to a [Draft]. +extension DraftResponseMapper on api.DraftResponse { + /// Converts this response into a [Draft]. + Draft toModel() => Draft( + channelCid: channelCid, + createdAt: createdAt, + message: message.toModel(), + channel: channel?.toModel(), + parentId: parentId, + parentMessage: parentMessage?.toModel(), + quotedMessage: quotedMessage?.toModel(), + ); +} + +/// Maps a generated [api.DraftPayloadResponse] to a [DraftMessage]. +extension DraftPayloadResponseMapper on api.DraftPayloadResponse { + // Custom keys named like one of the draft message's own fields, including the ones it keeps in its extra data. + static const _shadowedCustomKeys = { + ...DraftMessage.topLevelFields, + 'html', + 'mml', + }; + + /// Converts this response into a [DraftMessage]. + /// + /// The rendered HTML and the message markup are kept in [DraftMessage.extraData], beside the custom data, with + /// custom data named like one of the draft message's own fields left out. + DraftMessage toModel() => DraftMessage( + id: id, + text: text, + type: type ?? MessageType.regular, + attachments: attachments?.map((attachment) => attachment.toModel()).toList() ?? const [], + parentId: parentId, + showInChannel: showInChannel, + mentionedUsers: mentionedUsers?.map((user) => user.toModel()).toList() ?? const [], + quotedMessageId: quotedMessageId, + silent: silent ?? false, + pollId: pollId, + extraData: { + ...{...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), + 'html': ?html, + 'mml': ?mml, + }, + ); +} diff --git a/packages/stream_chat/lib/src/repository/mapper/location_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/location_mapper.dart new file mode 100644 index 0000000000..eaf566a03d --- /dev/null +++ b/packages/stream_chat/lib/src/repository/mapper/location_mapper.dart @@ -0,0 +1,24 @@ +import '../../../open_api/api.dart' as api; +import '../../core/models/location.dart'; +import 'channel_mapper.dart'; +import 'message_mapper.dart'; + +// TODO(openapi-migration): re-point this mapper in group 10. + +/// Maps a generated [api.SharedLocationResponseData] to a [Location]. +extension SharedLocationResponseDataMapper on api.SharedLocationResponseData { + /// Converts this response into a [Location]. + Location toModel() => Location( + channelCid: channelCid, + channel: channel?.toModel(), + messageId: messageId, + message: message?.toModel(), + userId: userId, + latitude: latitude, + longitude: longitude, + createdByDeviceId: createdByDeviceId, + endAt: endAt, + createdAt: createdAt, + updatedAt: updatedAt, + ); +} diff --git a/packages/stream_chat/lib/src/repository/mapper/message_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/message_mapper.dart new file mode 100644 index 0000000000..d47910f2e4 --- /dev/null +++ b/packages/stream_chat/lib/src/repository/mapper/message_mapper.dart @@ -0,0 +1,89 @@ +import '../../../open_api/api.dart' as api; +import '../../core/models/message.dart'; +import '../../core/util/message_decoding.dart'; +import 'attachment_mapper.dart'; +import 'drafts_mapper.dart'; +import 'location_mapper.dart'; +import 'moderation_mapper.dart'; +import 'polls_mapper.dart'; +import 'reaction_mapper.dart'; +import 'reminders_mapper.dart'; +import 'user_groups_mapper.dart'; +import 'user_mapper.dart'; + +// TODO(openapi-migration): re-point these mappers in group 10. + +/// Maps a generated [api.MessageResponse] to a [Message]. +extension MessageResponseMapper on api.MessageResponse { + // Custom keys named like one of the message's own fields, including the ones it keeps in its extra data. + static final _shadowedCustomKeys = { + ...Message.topLevelFields, + 'cid', + 'html', + 'mml', + 'image_labels', + 'deleted_reply_count', + 'mentioned_channel_members', + }; + + /// Converts this response into a [Message]. + /// + /// The message's type and state follow from whether and when it was deleted or updated. Without reaction groups, + /// they are built from the reaction counts and scores. + /// + /// The channel id is kept in [Message.extraData], beside the custom data, with custom data named like one of the + /// message's own fields left out. + Message toModel() => Message( + id: id, + text: text, + type: type, + attachments: [for (final attachment in attachments) attachment.toModel()], + mentionedChannel: mentionedChannel, + mentionedGroupIds: mentionedGroupIds, + mentionedGroups: mentionedGroups?.map((group) => group.toModel()).toList(), + mentionedHere: mentionedHere, + mentionedRoles: mentionedRoles, + mentionedUsers: [for (final user in mentionedUsers) user.toModel()], + silent: silent, + shadowed: shadowed, + reactionGroups: + reactionGroups?.map((type, group) => MapEntry(type, group.toModel())) ?? + reactionGroupsFromCounts(reactionCounts, reactionScores), + latestReactions: [for (final reaction in latestReactions) reaction.toModel()], + ownReactions: [for (final reaction in ownReactions) reaction.toModel()], + parentId: parentId, + quotedMessage: quotedMessage?.toModel(), + quotedMessageId: quotedMessageId, + replyCount: replyCount, + threadParticipants: threadParticipants?.map((user) => user.toModel()).toList(), + showInChannel: showInChannel, + command: command, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt, + deletedForMe: deletedForMe, + messageTextUpdatedAt: messageTextUpdatedAt, + user: user.toModel(), + pinned: pinned, + pinnedAt: pinnedAt, + pinExpires: pinExpires, + pinnedBy: pinnedBy?.toModel(), + poll: poll?.toModel(), + pollId: pollId, + extraData: { + ...{...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), + 'cid': cid, + }, + i18n: i18n, + restrictedVisibility: restrictedVisibility, + moderation: moderation?.toModel(), + draft: draft?.toModel(), + reminder: reminder?.toModel(), + channelRole: member?.channelRole, + sharedLocation: sharedLocation?.toModel(), + html: html, + mml: mml, + imageLabels: imageLabels, + deletedReplyCount: deletedReplyCount, + ).withDerivedState(); +} diff --git a/packages/stream_chat/lib/src/repository/mapper/moderation_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/moderation_mapper.dart index bc55e12eb3..0e7256345e 100644 --- a/packages/stream_chat/lib/src/repository/mapper/moderation_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/moderation_mapper.dart @@ -1,5 +1,6 @@ import '../../../open_api/api.dart' as api; import '../../core/models/delete_type.dart'; +import '../../core/models/moderation.dart'; import '../../core/models/response/flag_response.dart'; import '../../core/models/response/mute_users_response.dart'; import '../../core/models/response/unmute_users_response.dart'; @@ -36,3 +37,19 @@ extension DeleteTypeMapper on DeleteType { /// Converts this treatment into an [api.BanRequestDeleteMessages]. api.BanRequestDeleteMessages toRequest() => api.BanRequestDeleteMessages.fromJson(rawType); } + +// TODO(openapi-migration): re-point this mapper in group 10. + +/// Maps a generated [api.ModerationV2Response] to a [Moderation]. +extension ModerationV2ResponseMapper on api.ModerationV2Response { + /// Converts this response into a [Moderation]. + Moderation toModel() => Moderation( + action: ModerationAction.fromJson(action), + originalText: originalText, + textHarms: textHarms, + imageHarms: imageHarms, + blocklistMatched: blocklistMatched, + semanticFilterMatched: semanticFilterMatched, + platformCircumvented: platformCircumvented ?? false, + ); +} diff --git a/packages/stream_chat/lib/src/repository/mapper/reaction_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/reaction_mapper.dart new file mode 100644 index 0000000000..cbc51b3b60 --- /dev/null +++ b/packages/stream_chat/lib/src/repository/mapper/reaction_mapper.dart @@ -0,0 +1,36 @@ +import '../../../open_api/api.dart' as api; +import '../../core/models/reaction.dart'; +import '../../core/models/reaction_group.dart'; +import '../../core/util/extension.dart'; +import 'user_mapper.dart'; + +// TODO(openapi-migration): re-point these mappers in group 10. + +/// Maps a generated [api.ReactionResponse] to a [Reaction]. +extension ReactionResponseMapper on api.ReactionResponse { + /// Converts this response into a [Reaction]. + /// + /// Custom data named like one of the reaction's own fields is left out of [Reaction.extraData]. + Reaction toModel() => Reaction( + messageId: messageId, + type: type, + user: user.toModel(), + userId: userId, + score: score, + emojiCode: custom['emoji_code'].safeCast(), + createdAt: createdAt, + updatedAt: updatedAt, + extraData: {...custom}..removeWhere((key, _) => Reaction.topLevelFields.contains(key)), + ); +} + +/// Maps a generated [api.ReactionGroupResponse] to a [ReactionGroup]. +extension ReactionGroupResponseMapper on api.ReactionGroupResponse { + /// Converts this response into a [ReactionGroup]. + ReactionGroup toModel() => ReactionGroup( + count: count, + sumScores: sumScores, + firstReactionAt: firstReactionAt, + lastReactionAt: lastReactionAt, + ); +} diff --git a/packages/stream_chat/lib/src/repository/mapper/reminders_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/reminders_mapper.dart new file mode 100644 index 0000000000..9ef7cf1a07 --- /dev/null +++ b/packages/stream_chat/lib/src/repository/mapper/reminders_mapper.dart @@ -0,0 +1,54 @@ +import '../../../open_api/api.dart' as api; +import '../../core/models/message_reminder.dart'; +import '../../core/models/response/create_reminder_response.dart'; +import '../../core/models/response/delete_reminder_response.dart'; +import '../../core/models/response/query_reminders_response.dart'; +import '../../core/models/response/update_reminder_response.dart'; +import 'channel_mapper.dart'; +import 'message_mapper.dart'; +import 'user_mapper.dart'; + +/// Maps a generated [api.CreateReminderResponse] to a [CreateReminderResponse]. +extension CreateReminderResponseMapper on api.CreateReminderResponse { + /// Converts this response into a [CreateReminderResponse]. + CreateReminderResponse toModel() => CreateReminderResponse(duration: duration, reminder: reminder.toModel()); +} + +/// Maps a generated [api.UpdateReminderResponse] to an [UpdateReminderResponse]. +extension UpdateReminderResponseMapper on api.UpdateReminderResponse { + /// Converts this response into an [UpdateReminderResponse]. + UpdateReminderResponse toModel() => UpdateReminderResponse(duration: duration, reminder: reminder.toModel()); +} + +/// Maps a generated [api.DeleteReminderResponse] to a [DeleteReminderResponse]. +extension DeleteReminderResponseMapper on api.DeleteReminderResponse { + /// Converts this response into a [DeleteReminderResponse]. + DeleteReminderResponse toModel() => DeleteReminderResponse(duration: duration); +} + +/// Maps a generated [api.QueryRemindersResponse] to a [QueryRemindersResponse]. +extension QueryRemindersResponseMapper on api.QueryRemindersResponse { + /// Converts this response into a [QueryRemindersResponse]. + QueryRemindersResponse toModel() => QueryRemindersResponse( + duration: duration, + reminders: [for (final reminder in reminders) reminder.toModel()], + next: next, + prev: prev, + ); +} + +/// Maps a generated [api.ReminderResponseData] to a [MessageReminder]. +extension ReminderResponseDataMapper on api.ReminderResponseData { + /// Converts this response into a [MessageReminder]. + MessageReminder toModel() => MessageReminder( + channelCid: channelCid, + channel: channel?.toModel(), + messageId: messageId, + message: message?.toModel(), + userId: userId, + user: user?.toModel(), + remindAt: remindAt, + createdAt: createdAt, + updatedAt: updatedAt, + ); +} diff --git a/packages/stream_chat/lib/src/repository/reminders_repository.dart b/packages/stream_chat/lib/src/repository/reminders_repository.dart new file mode 100644 index 0000000000..69038b7538 --- /dev/null +++ b/packages/stream_chat/lib/src/repository/reminders_repository.dart @@ -0,0 +1,69 @@ +import 'package:stream_core/stream_core.dart' show PatternMatching, Result; + +import '../../open_api/api.dart' as api; +import '../core/models/message_reminder.dart'; +import '../core/models/response/create_reminder_response.dart'; +import '../core/models/response/delete_reminder_response.dart'; +import '../core/models/response/query_reminders_response.dart'; +import '../core/models/response/update_reminder_response.dart'; +import 'mapper/reminders_mapper.dart'; +import 'mapper/sort_mapper.dart'; + +/// Repository dedicated to message reminder operations. +class RemindersRepository { + /// Initialize a new reminders repository. + const RemindersRepository(this._api); + + final api.DefaultApi _api; + + /// Fetches one page of the current user's reminders matching [filter], ordered by [sort]. + /// + /// [next] and [prev] are the cursors a previous page returned; at most one of them may be given. + Future> queryReminders({ + MessageReminderFilter? filter, + List? sort, + int? limit, + String? next, + String? prev, + }) async { + final result = await _api.queryReminders( + queryRemindersRequest: api.QueryRemindersRequest( + filter: filter?.toJson(), + sort: sort?.map((it) => it.toRequest()).toList(), + limit: limit, + next: next, + prev: prev, + ), + ); + + return result.map((response) => response.toModel()); + } + + /// Creates a reminder on the message with the id [messageId], due at [remindAt], or a bookmark without it. + Future> createReminder(String messageId, {DateTime? remindAt}) async { + final result = await _api.createReminder( + messageId: messageId, + createReminderRequest: api.CreateReminderRequest(remindAt: remindAt), + ); + + return result.map((response) => response.toModel()); + } + + /// Sets the reminder on the message with the id [messageId] to be due at [remindAt], or turns it into a bookmark + /// without it. + Future> updateReminder(String messageId, {DateTime? remindAt}) async { + final result = await _api.updateReminder( + messageId: messageId, + updateReminderRequest: api.UpdateReminderRequest(remindAt: remindAt), + ); + + return result.map((response) => response.toModel()); + } + + /// Deletes the reminder on the message with the id [messageId]. + Future> deleteReminder(String messageId) async { + final result = await _api.deleteReminder(messageId: messageId); + + return result.map((response) => response.toModel()); + } +} diff --git a/packages/stream_chat/lib/src/ws/events/event.dart b/packages/stream_chat/lib/src/ws/events/event.dart index 4bbf87ba72..c8b1ddbc00 100644 --- a/packages/stream_chat/lib/src/ws/events/event.dart +++ b/packages/stream_chat/lib/src/ws/events/event.dart @@ -182,6 +182,7 @@ class Event extends WsEvent { final Draft? draft; /// The message reminder sent with the event. + @MessageReminderV1JsonConverter() final MessageReminder? reminder; /// Push notification preferences for the current user. 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 7fe9c16d0f..8d489ff901 100644 --- a/packages/stream_chat/lib/src/ws/events/event.g.dart +++ b/packages/stream_chat/lib/src/ws/events/event.g.dart @@ -51,7 +51,10 @@ Event _$EventFromJson(Map json) => Event( unreadMessages: (json['unread_messages'] as num?)?.toInt(), lastReadMessageId: json['last_read_message_id'] as String?, draft: json['draft'] == null ? null : Draft.fromJson(json['draft'] as Map), - reminder: json['reminder'] == null ? null : MessageReminder.fromJson(json['reminder'] as Map), + reminder: _$JsonConverterFromJson, MessageReminder>( + json['reminder'], + const MessageReminderV1JsonConverter().fromJson, + ), pushPreference: json['push_preference'] == null ? null : PushPreference.fromJson(json['push_preference'] as Map), @@ -107,7 +110,10 @@ Map _$EventToJson(Event instance) => { 'unread_messages': ?instance.unreadMessages, 'last_read_message_id': ?instance.lastReadMessageId, 'draft': ?instance.draft?.toJson(), - 'reminder': ?instance.reminder?.toJson(), + 'reminder': ?_$JsonConverterToJson, MessageReminder>( + instance.reminder, + const MessageReminderV1JsonConverter().toJson, + ), 'push_preference': ?instance.pushPreference?.toJson(), 'channel_push_preference': ?instance.channelPushPreference?.toJson(), 'channel_member_count': ?instance.channelMemberCount, diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index 22484c7594..a075308785 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -124,8 +124,10 @@ export 'src/core/models/request/update_user_partial_request.dart'; export 'src/core/models/response/add_user_group_members_response.dart'; export 'src/core/models/response/app_settings_response.dart'; export 'src/core/models/response/block_users_response.dart'; +export 'src/core/models/response/create_reminder_response.dart'; export 'src/core/models/response/create_user_group_response.dart'; export 'src/core/models/response/delete_channel_response.dart'; +export 'src/core/models/response/delete_reminder_response.dart'; export 'src/core/models/response/flag_response.dart'; export 'src/core/models/response/get_blocked_users_response.dart'; export 'src/core/models/response/get_unread_count_response.dart'; @@ -142,6 +144,7 @@ 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/query_reminders_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'; @@ -150,6 +153,7 @@ export 'src/core/models/response/unblock_users_response.dart'; export 'src/core/models/response/unmute_users_response.dart'; export 'src/core/models/response/update_channel_partial_response.dart'; export 'src/core/models/response/update_member_partial_response.dart'; +export 'src/core/models/response/update_reminder_response.dart'; export 'src/core/models/response/update_user_group_response.dart'; export 'src/core/models/response/update_users_response.dart'; export 'src/core/models/role.dart'; diff --git a/packages/stream_chat/test/src/client/channel/channel_test.dart b/packages/stream_chat/test/src/client/channel/channel_test.dart index 1b95c0052d..66ca19f78f 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -1456,118 +1456,77 @@ void main() { }); }); - group('`.createReminder`', () { + test("Channel.createReminder sends the due date and returns the client's createReminder response", () async { const messageId = 'test-message-id'; + final remindAt = DateTime.utc(2024, 6, 15, 14, 30); + final response = CreateReminderResponse( + duration: '0.01ms', + reminder: MessageReminder(channelCid: channelCid, messageId: messageId, userId: 'test-user-id'), + ); + when( + () => client.createReminder(messageId, remindAt: remindAt), + ).thenAnswer((_) async => Result.success(response)); - setUp(() { - when( - () => client.createReminder( - messageId, - remindAt: any(named: 'remindAt'), - ), - ).thenAnswer( - (_) async => CreateReminderResponse() - ..reminder = MessageReminder( - messageId: messageId, - channelCid: channelCid, - userId: 'test-user-id', - remindAt: DateTime(2024, 6, 15, 14, 30), - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - ), - ); - }); - - test('should call client.createReminder', () async { - final res = await channel.createReminder(messageId); - - expect(res, isNotNull); - expect(res.reminder.messageId, messageId); + final res = await channel.createReminder(messageId, remindAt: remindAt); - verify(() => channel.client.createReminder(messageId)).called(1); - }); + expect(res.getOrNull(), response); + verify(() => client.createReminder(messageId, remindAt: remindAt)).called(1); + }); - test('with remindAt should pass remindAt to client', () async { - final remindAt = DateTime(2024, 6, 15, 14, 30); - final res = await channel.createReminder(messageId, remindAt: remindAt); + test("Channel.createReminder sends no due date and returns the client's createReminder response", () async { + const messageId = 'test-message-id'; + final response = CreateReminderResponse( + duration: '0.01ms', + reminder: MessageReminder(channelCid: channelCid, messageId: messageId, userId: 'test-user-id'), + ); + when(() => client.createReminder(messageId)).thenAnswer((_) async => Result.success(response)); - expect(res, isNotNull); - expect(res.reminder.messageId, messageId); - expect(res.reminder.remindAt, remindAt); + final res = await channel.createReminder(messageId); - verify( - () => channel.client.createReminder( - messageId, - remindAt: remindAt, - ), - ).called(1); - }); + expect(res.getOrNull(), response); + verify(() => client.createReminder(messageId)).called(1); }); - group('`.updateReminder`', () { + test("Channel.updateReminder sends the due date and returns the client's updateReminder response", () async { const messageId = 'test-message-id'; + final remindAt = DateTime.utc(2024, 8, 20, 16, 45); + final response = UpdateReminderResponse( + duration: '0.01ms', + reminder: MessageReminder(channelCid: channelCid, messageId: messageId, userId: 'test-user-id'), + ); + when( + () => client.updateReminder(messageId, remindAt: remindAt), + ).thenAnswer((_) async => Result.success(response)); - setUp(() { - when( - () => client.updateReminder( - messageId, - remindAt: any(named: 'remindAt'), - ), - ).thenAnswer( - (_) async => UpdateReminderResponse() - ..reminder = MessageReminder( - messageId: messageId, - channelCid: channelCid, - userId: 'test-user-id', - remindAt: DateTime(2024, 8, 20, 16, 45), - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - ), - ); - }); - - test('should call client.updateReminder', () async { - final res = await channel.updateReminder(messageId); - - expect(res, isNotNull); - expect(res.reminder.messageId, messageId); + final res = await channel.updateReminder(messageId, remindAt: remindAt); - verify(() => channel.client.updateReminder(messageId)).called(1); - }); + expect(res.getOrNull(), response); + verify(() => client.updateReminder(messageId, remindAt: remindAt)).called(1); + }); - test('with remindAt should pass remindAt to client', () async { - final remindAt = DateTime(2024, 8, 20, 16, 45); - final res = await channel.updateReminder(messageId, remindAt: remindAt); + test("Channel.updateReminder sends no due date and returns the client's updateReminder response", () async { + const messageId = 'test-message-id'; + final response = UpdateReminderResponse( + duration: '0.01ms', + reminder: MessageReminder(channelCid: channelCid, messageId: messageId, userId: 'test-user-id'), + ); + when(() => client.updateReminder(messageId)).thenAnswer((_) async => Result.success(response)); - expect(res, isNotNull); - expect(res.reminder.messageId, messageId); - expect(res.reminder.remindAt, remindAt); + final res = await channel.updateReminder(messageId); - verify( - () => channel.client.updateReminder( - messageId, - remindAt: remindAt, - ), - ).called(1); - }); + expect(res.getOrNull(), response); + verify(() => client.updateReminder(messageId)).called(1); }); - group('`.deleteReminder`', () { + test("Channel.deleteReminder returns the client's deleteReminder response", () async { const messageId = 'test-message-id'; + const response = DeleteReminderResponse(duration: '0.01ms'); + when(() => client.deleteReminder(messageId)).thenAnswer((_) async => const Result.success(response)); - setUp(() { - when(() => client.deleteReminder(messageId)).thenAnswer( - (_) async => EmptyResponse(), - ); - }); + final res = await channel.deleteReminder(messageId); - test('should call client.deleteReminder', () async { - final res = await channel.deleteReminder(messageId); - - expect(res, isNotNull); - - verify(() => channel.client.deleteReminder(messageId)).called(1); - }); + expect(res.getOrNull(), response); + verify(() => client.deleteReminder(messageId)).called(1); }); group('`.updateMessage`', () { diff --git a/packages/stream_chat/test/src/client/client_create_reminder_test.dart b/packages/stream_chat/test/src/client/client_create_reminder_test.dart new file mode 100644 index 0000000000..a48b2bb97e --- /dev/null +++ b/packages/stream_chat/test/src/client/client_create_reminder_test.dart @@ -0,0 +1,201 @@ +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 'message_fixtures.dart'; + +void main() { + setUpAll(() => registerFallbackValue(const api.CreateReminderRequest())); + + test('StreamChatClient.createReminder sends the due date and returns the created reminder', () async { + final request = api.CreateReminderRequest(remindAt: DateTime.utc(2026, 3)); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.createReminder(messageId: 'message-id', createReminderRequest: request), + ).thenAnswer( + (_) async => Result.success( + api.CreateReminderResponse( + duration: '0.01ms', + reminder: _generatedReminder(remindAt: DateTime.utc(2026, 3)), + ), + ), + ); + final client = _client(defaultApi); + + final res = await client.createReminder('message-id', remindAt: DateTime.utc(2026, 3)); + + final response = res.getOrNull()!; + final message = response.reminder.message!; + // A channel compares by identity, so it is checked on its own. + expect(message.sharedLocation?.channel?.cid, 'messaging:general'); + expect(message.draft?.channel?.cid, 'messaging:general'); + expect( + response, + CreateReminderResponse( + duration: '0.01ms', + reminder: _expectedReminder(expectedMessageLike(message), remindAt: DateTime.utc(2026, 3)), + ), + ); + verify(() => defaultApi.createReminder(messageId: 'message-id', createReminderRequest: request)).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.createReminder sends no due date and returns the bookmark', () async { + const request = api.CreateReminderRequest(); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.createReminder(messageId: 'message-id', createReminderRequest: request), + ).thenAnswer( + (_) async => Result.success( + api.CreateReminderResponse(duration: '0.01ms', reminder: _generatedReminder(remindAt: null)), + ), + ); + final client = _client(defaultApi); + + final res = await client.createReminder('message-id'); + + final response = res.getOrNull()!; + expect( + response, + CreateReminderResponse( + duration: '0.01ms', + reminder: _expectedReminder(expectedMessageLike(response.reminder.message!), remindAt: null), + ), + ); + verify(() => defaultApi.createReminder(messageId: 'message-id', createReminderRequest: request)).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test( + 'StreamChatClient.createReminder sends the message id and returns the reminder with reaction groups built from the counts', + () async { + const request = api.CreateReminderRequest(); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.createReminder(messageId: 'message-id', createReminderRequest: request), + ).thenAnswer( + (_) async => Result.success( + api.CreateReminderResponse( + duration: '0.01ms', + reminder: _generatedReminder(remindAt: null).copyWith(message: _generatedMessageWithCounts), + ), + ), + ); + final client = _client(defaultApi); + + final res = await client.createReminder('message-id'); + + final response = res.getOrNull()!; + // The dates of the reactions are unknown, so the groups share one. + final date = response.reminder.message!.reactionGroups!['love']!.firstReactionAt; + expect( + response, + CreateReminderResponse( + duration: '0.01ms', + reminder: _expectedReminder(_expectedMessageWithCounts(reactionDate: date), remindAt: null), + ), + ); + }, + ); + + test('StreamChatClient.createReminder returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.createReminder( + messageId: any(named: 'messageId'), + createReminderRequest: any(named: 'createReminderRequest'), + ), + ).thenAnswer((_) async => const Result.failure(error)); + final client = _client(defaultApi); + + final res = await client.createReminder('message-id'); + + expect(res.exceptionOrNull(), error); + }); +} + +// A reminder as a write answers it: with the message and the user, but no channel. +api.ReminderResponseData _generatedReminder({required DateTime? remindAt}) => api.ReminderResponseData( + channelCid: 'messaging:general', + createdAt: DateTime.utc(2026, 1, 7), + expiresAt: DateTime.utc(2027), + message: generatedMessage, + messageId: 'message-id', + remindAt: remindAt, + updatedAt: DateTime.utc(2026, 1, 8), + user: fakeUserResponse('sender'), + userId: 'sender', +); + +// A message without reaction groups, only the counts and scores they are built from. +final _generatedMessageWithCounts = api.MessageResponse( + attachments: const [], + cid: 'messaging:general', + createdAt: DateTime.utc(2026), + custom: const {}, + deletedReplyCount: 0, + html: '', + id: 'message-id', + latestReactions: const [], + mentionedChannel: false, + mentionedHere: false, + mentionedUsers: const [], + ownReactions: const [], + pinned: false, + reactionCounts: const {'love': 3, 'wow': 1}, + reactionScores: const {'love': 7, 'wow': 2}, + replyCount: 0, + restrictedVisibility: const [], + shadowed: false, + silent: false, + text: 'Hello', + type: 'regular', + updatedAt: DateTime.utc(2026), + user: fakeUserResponse('sender'), +); + +// The message [_generatedMessageWithCounts] maps to, with the date its reaction groups share. +Message _expectedMessageWithCounts({required DateTime reactionDate}) => Message( + id: 'message-id', + text: 'Hello', + mentionedChannel: false, + mentionedHere: false, + reactionGroups: { + 'love': ReactionGroup(count: 3, sumScores: 7, firstReactionAt: reactionDate, lastReactionAt: reactionDate), + 'wow': ReactionGroup(count: 1, sumScores: 2, firstReactionAt: reactionDate, lastReactionAt: reactionDate), + }, + latestReactions: const [], + ownReactions: const [], + createdAt: DateTime.utc(2026), + updatedAt: DateTime.utc(2026), + user: fakeUser('sender'), + extraData: const {'cid': 'messaging:general'}, + state: MessageState.sent, + restrictedVisibility: const [], + html: '', + deletedReplyCount: 0, +); + +MessageReminder _expectedReminder(Message message, {required DateTime? remindAt}) => MessageReminder( + channelCid: 'messaging:general', + messageId: 'message-id', + message: message, + userId: 'sender', + user: fakeUser('sender'), + remindAt: remindAt, + createdAt: DateTime.utc(2026, 1, 7), + updatedAt: DateTime.utc(2026, 1, 8), +); + +StreamChatClient _client(api.DefaultApi defaultApi) { + final client = StreamChatClient('test-api-key', defaultApi: defaultApi); + addTearDown(client.dispose); + return client; +} diff --git a/packages/stream_chat/test/src/client/client_delete_reminder_test.dart b/packages/stream_chat/test/src/client/client_delete_reminder_test.dart new file mode 100644 index 0000000000..3db40eaacf --- /dev/null +++ b/packages/stream_chat/test/src/client/client_delete_reminder_test.dart @@ -0,0 +1,41 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/open_api/api.dart' as api; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; + +void main() { + test('StreamChatClient.deleteReminder sends the message id and returns the response duration', () async { + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.deleteReminder(messageId: 'message-id'), + ).thenAnswer((_) async => const Result.success(api.DeleteReminderResponse(duration: '0.01ms'))); + final client = _client(defaultApi); + + final res = await client.deleteReminder('message-id'); + + expect(res.getOrNull(), const DeleteReminderResponse(duration: '0.01ms')); + verify(() => defaultApi.deleteReminder(messageId: 'message-id')).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.deleteReminder returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.deleteReminder(messageId: any(named: 'messageId')), + ).thenAnswer((_) async => const Result.failure(error)); + final client = _client(defaultApi); + + final res = await client.deleteReminder('message-id'); + + expect(res.exceptionOrNull(), error); + }); +} + +StreamChatClient _client(api.DefaultApi defaultApi) { + final client = StreamChatClient('test-api-key', defaultApi: defaultApi); + addTearDown(client.dispose); + return client; +} diff --git a/packages/stream_chat/test/src/client/client_query_reminders_test.dart b/packages/stream_chat/test/src/client/client_query_reminders_test.dart new file mode 100644 index 0000000000..f9bcac6320 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_query_reminders_test.dart @@ -0,0 +1,188 @@ +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 'message_fixtures.dart'; + +void main() { + setUpAll(() => registerFallbackValue(const api.QueryRemindersRequest())); + + test('StreamChatClient.queryReminders sends the filter, sort, limit and cursor and returns the page', () async { + const request = api.QueryRemindersRequest( + filter: { + 'channel_cid': {r'$eq': 'messaging:general'}, + }, + sort: [ + api.SortParamRequest(field: 'remind_at', direction: 1), + api.SortParamRequest(field: 'message_id', direction: -1), + ], + limit: 25, + next: 'next-cursor', + ); + + final defaultApi = MockDefaultApi(); + when(() => defaultApi.queryReminders(queryRemindersRequest: request)).thenAnswer( + (_) async => Result.success( + api.QueryRemindersResponse( + duration: '0.01ms', + next: 'later-cursor', + prev: 'earlier-cursor', + reminders: [_generatedReminder, _generatedBookmark], + ), + ), + ); + final client = _client(defaultApi); + + final res = await client.queryReminders( + filter: MessageReminderFilter.equal(MessageReminderFilterField.channelCid, 'messaging:general'), + sort: [ + MessageReminderSort.asc(MessageReminderSortField.remindAt), + MessageReminderSort.desc(MessageReminderSortField.messageId), + ], + limit: 25, + next: 'next-cursor', + ); + + final response = res.getOrNull()!; + final [reminder, bookmark] = response.reminders; + // A channel compares by identity, so it is checked on its own. + expect([reminder.channel?.cid, bookmark.channel?.cid], ['messaging:general', 'messaging:general']); + expect( + response, + QueryRemindersResponse( + duration: '0.01ms', + reminders: [_expectedReminder(reminder), _expectedBookmark(bookmark)], + next: 'later-cursor', + prev: 'earlier-cursor', + ), + ); + verify(() => defaultApi.queryReminders(queryRemindersRequest: request)).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.queryReminders sends the default limit of ten and returns the empty page', () async { + const request = api.QueryRemindersRequest(limit: 10); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.queryReminders(queryRemindersRequest: request), + ).thenAnswer((_) async => const Result.success(api.QueryRemindersResponse(duration: '0.01ms', reminders: []))); + final client = _client(defaultApi); + + final res = await client.queryReminders(); + + expect(res.getOrNull(), const QueryRemindersResponse(duration: '0.01ms', reminders: [])); + verify(() => defaultApi.queryReminders(queryRemindersRequest: request)).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.queryReminders sends the previous-page cursor and returns the earlier page', () async { + const request = api.QueryRemindersRequest(limit: 10, prev: 'prev-cursor'); + + final defaultApi = MockDefaultApi(); + when(() => defaultApi.queryReminders(queryRemindersRequest: request)).thenAnswer( + (_) async => Result.success( + api.QueryRemindersResponse(duration: '0.01ms', next: 'next-cursor', reminders: [_generatedBookmark]), + ), + ); + final client = _client(defaultApi); + + final res = await client.queryReminders(prev: 'prev-cursor'); + + final response = res.getOrNull()!; + final bookmark = response.reminders.single; + // A channel compares by identity, so it is checked on its own. + expect(bookmark.channel?.cid, 'messaging:general'); + expect( + response, + QueryRemindersResponse(duration: '0.01ms', reminders: [_expectedBookmark(bookmark)], next: 'next-cursor'), + ); + verify(() => defaultApi.queryReminders(queryRemindersRequest: request)).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.queryReminders returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.queryReminders(queryRemindersRequest: any(named: 'queryRemindersRequest')), + ).thenAnswer((_) async => const Result.failure(error)); + final client = _client(defaultApi); + + final res = await client.queryReminders(); + + expect(res.exceptionOrNull(), error); + }); +} + +// Reminders as a query answers them: with their channel, message and user. +final _generatedReminder = api.ReminderResponseData( + channel: _generatedChannel, + channelCid: 'messaging:general', + createdAt: DateTime.utc(2026, 1, 7), + expiresAt: DateTime.utc(2027), + message: generatedMessage, + messageId: 'message-id', + remindAt: DateTime.utc(2026, 3), + updatedAt: DateTime.utc(2026, 1, 8), + user: fakeUserResponse('sender'), + userId: 'sender', +); + +// A reminder with no due date. +final _generatedBookmark = api.ReminderResponseData( + channel: _generatedChannel, + channelCid: 'messaging:general', + createdAt: DateTime.utc(2026, 2, 7), + message: generatedLeafMessage('other-message-id'), + messageId: 'other-message-id', + updatedAt: DateTime.utc(2026, 2, 8), + user: fakeUserResponse('sender'), + userId: 'sender', +); + +final _generatedChannel = api.ChannelResponse( + cid: 'messaging:general', + createdAt: DateTime.utc(2026), + custom: const {}, + disabled: false, + frozen: false, + id: 'general', + type: 'messaging', + updatedAt: DateTime.utc(2026), +); + +// The reminder [_generatedReminder] maps to. Its channel compares by identity, and its message's attachment ids are +// created locally, so both are read off [actual]. +MessageReminder _expectedReminder(MessageReminder actual) => MessageReminder( + channelCid: 'messaging:general', + channel: actual.channel, + messageId: 'message-id', + message: expectedMessageLike(actual.message!), + userId: 'sender', + user: fakeUser('sender'), + remindAt: DateTime.utc(2026, 3), + createdAt: DateTime.utc(2026, 1, 7), + updatedAt: DateTime.utc(2026, 1, 8), +); + +// The reminder [_generatedBookmark] maps to, with its channel read off [actual]. +MessageReminder _expectedBookmark(MessageReminder actual) => MessageReminder( + channelCid: 'messaging:general', + channel: actual.channel, + messageId: 'other-message-id', + message: expectedLeafMessage('other-message-id'), + userId: 'sender', + user: fakeUser('sender'), + createdAt: DateTime.utc(2026, 2, 7), + updatedAt: DateTime.utc(2026, 2, 8), +); + +StreamChatClient _client(api.DefaultApi defaultApi) { + final client = StreamChatClient('test-api-key', defaultApi: defaultApi); + addTearDown(client.dispose); + return client; +} diff --git a/packages/stream_chat/test/src/client/client_update_reminder_test.dart b/packages/stream_chat/test/src/client/client_update_reminder_test.dart new file mode 100644 index 0000000000..d7909102b8 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_update_reminder_test.dart @@ -0,0 +1,118 @@ +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 'message_fixtures.dart'; + +void main() { + setUpAll(() => registerFallbackValue(const api.UpdateReminderRequest())); + + test('StreamChatClient.updateReminder sends the new due date and returns the updated reminder', () async { + final request = api.UpdateReminderRequest(remindAt: DateTime.utc(2026, 4)); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateReminder(messageId: 'message-id', updateReminderRequest: request), + ).thenAnswer( + (_) async => Result.success( + api.UpdateReminderResponse( + duration: '0.01ms', + reminder: _generatedReminder(remindAt: DateTime.utc(2026, 4)), + ), + ), + ); + final client = _client(defaultApi); + + final res = await client.updateReminder('message-id', remindAt: DateTime.utc(2026, 4)); + + final response = res.getOrNull()!; + expect( + response, + UpdateReminderResponse( + duration: '0.01ms', + reminder: _expectedReminder( + remindAt: DateTime.utc(2026, 4), + message: expectedMessageLike(response.reminder.message!), + ), + ), + ); + verify(() => defaultApi.updateReminder(messageId: 'message-id', updateReminderRequest: request)).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.updateReminder sends no due date and returns the bookmark', () async { + const request = api.UpdateReminderRequest(); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateReminder(messageId: 'message-id', updateReminderRequest: request), + ).thenAnswer( + (_) async => Result.success( + api.UpdateReminderResponse(duration: '0.01ms', reminder: _generatedReminder(remindAt: null)), + ), + ); + final client = _client(defaultApi); + + final res = await client.updateReminder('message-id'); + + final response = res.getOrNull()!; + expect( + response, + UpdateReminderResponse( + duration: '0.01ms', + reminder: _expectedReminder(remindAt: null, message: expectedMessageLike(response.reminder.message!)), + ), + ); + verify(() => defaultApi.updateReminder(messageId: 'message-id', updateReminderRequest: request)).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.updateReminder returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.updateReminder( + messageId: any(named: 'messageId'), + updateReminderRequest: any(named: 'updateReminderRequest'), + ), + ).thenAnswer((_) async => const Result.failure(error)); + final client = _client(defaultApi); + + final res = await client.updateReminder('message-id'); + + expect(res.exceptionOrNull(), error); + }); +} + +// A reminder as an update answers it: with the message and the user, but no channel. +api.ReminderResponseData _generatedReminder({required DateTime? remindAt}) => api.ReminderResponseData( + channelCid: 'messaging:general', + createdAt: DateTime.utc(2026, 1, 7), + expiresAt: DateTime.utc(2027), + message: generatedMessage, + messageId: 'message-id', + remindAt: remindAt, + updatedAt: DateTime.utc(2026, 1, 8), + user: fakeUserResponse('sender'), + userId: 'sender', +); + +MessageReminder _expectedReminder({required DateTime? remindAt, required Message message}) => MessageReminder( + channelCid: 'messaging:general', + messageId: 'message-id', + message: message, + userId: 'sender', + user: fakeUser('sender'), + remindAt: remindAt, + createdAt: DateTime.utc(2026, 1, 7), + updatedAt: DateTime.utc(2026, 1, 8), +); + +StreamChatClient _client(api.DefaultApi defaultApi) { + final client = StreamChatClient('test-api-key', defaultApi: defaultApi); + addTearDown(client.dispose); + return client; +} diff --git a/packages/stream_chat/test/src/client/message_fixtures.dart b/packages/stream_chat/test/src/client/message_fixtures.dart new file mode 100644 index 0000000000..aa78d0d916 --- /dev/null +++ b/packages/stream_chat/test/src/client/message_fixtures.dart @@ -0,0 +1,616 @@ +import 'package:stream_chat/open_api/api.dart' as api; +import 'package:stream_chat/stream_chat.dart'; + +import '../fakes.dart'; +import 'poll_fixtures.dart'; + +// Fixtures shared by the client tests whose responses carry messages. The generated message carries a value in every +// field, including the ones the SDK models leave out, so a field the mapping drops changes the compared object; its +// users are the minimal ones from fakes.dart. No two fields of a type hold the same value in all three messages (the +// message, the one it quotes and the leaf ones nested deeper), so a field mapped into the wrong slot changes it too. +// The quoted message carries minimal nested payloads that leave out their optional fields, to compare the defaults. +// Custom data includes keys named like the model's own fields, which the mapping leaves out. + +/// A generated message with every field set, nesting one level of messages. +final generatedMessage = api.MessageResponse( + attachments: [_generatedAttachment, _generatedImageAttachment], + cid: 'messaging:general', + command: 'giphy', + createdAt: DateTime.utc(2026, 1, 1), + custom: const {'priority': 'high', 'text': 'shadowed', 'html': 'shadowed'}, + deletedAt: DateTime.utc(2026, 1, 3), + deletedForMe: false, + deletedReplyCount: 2, + draft: _generatedDraft, + html: '

Hello

', + i18n: const {'fr_text': 'Bonjour', 'language': 'en'}, + id: 'message-id', + imageLabels: const { + 'cat.png': ['cat'], + }, + latestReactions: [_generatedReaction], + member: const api.ChannelMemberPartialResponse(channelRole: 'channel_moderator', notificationsMuted: true), + mentionedChannel: true, + mentionedChannelMembers: const { + 'mo': api.ChannelMemberPartialResponse( + channelRole: 'channel_member', + custom: {'nickname': 'Mo'}, + notificationsMuted: false, + ), + }, + mentionedGroupIds: const ['group-id'], + mentionedGroups: [ + api.UserGroupResponse( + createdAt: DateTime.utc(2025, 1, 1), + createdBy: 'creator', + description: 'The group', + id: 'group-id', + members: [ + api.UserGroupMember( + appPk: 1, + createdAt: DateTime.utc(2025, 1, 2), + groupId: 'group-id', + isAdmin: true, + userId: 'member', + ), + ], + name: 'Group', + teamId: 'blue', + updatedAt: DateTime.utc(2025, 1, 3), + ), + ], + mentionedHere: false, + mentionedRoles: const ['admin'], + mentionedUsers: [fakeUserResponse('mentioned')], + messageTextUpdatedAt: DateTime.utc(2026, 1, 2, 12), + mml: 'Hello', + moderation: const api.ModerationV2Response( + action: 'remove', + blocklistMatched: 'profanity', + blocklistsMatched: ['profanity'], + imageHarms: ['nudity'], + originalText: 'Hullo', + platformCircumvented: true, + semanticFilterMatched: 'spam', + textHarms: ['insult'], + ), + ownReactions: [_generatedOwnReaction], + parentId: 'parent-id', + pinExpires: DateTime.utc(2026, 2), + pinned: true, + pinnedAt: DateTime.utc(2026, 1, 4), + pinnedBy: fakeUserResponse('pinner'), + poll: generatedPoll, + pollId: 'poll-id', + quotedMessage: _generatedQuotedMessage, + quotedMessageId: 'quoted-id', + // The counts and scores the groups were built from; the groups take precedence. + reactionCounts: const {'love': 1}, + reactionGroups: { + 'love': api.ReactionGroupResponse( + count: 1, + firstReactionAt: DateTime.utc(2026, 1, 5), + lastReactionAt: DateTime.utc(2026, 1, 6), + latestReactionsBy: [ + api.ReactionGroupUserResponse(createdAt: DateTime.utc(2026, 1, 6), userId: 'reactor'), + ], + sumScores: 2, + ), + }, + reactionScores: const {'love': 2}, + reminder: api.ReminderResponseData( + channelCid: 'messaging:general', + createdAt: DateTime.utc(2026, 1, 7), + expiresAt: DateTime.utc(2027), + messageId: 'message-id', + remindAt: DateTime.utc(2026, 3), + updatedAt: DateTime.utc(2026, 1, 8), + userId: 'sender', + ), + replyCount: 3, + restrictedVisibility: const ['luke'], + shadowed: false, + sharedLocation: api.SharedLocationResponseData( + channel: _generatedChannel, + channelCid: 'messaging:general', + createdAt: DateTime.utc(2026, 1, 9), + createdByDeviceId: 'device-id', + endAt: DateTime.utc(2026, 1, 10), + latitude: 1.5, + longitude: 2.5, + message: generatedLeafMessage('message-id'), + messageId: 'message-id', + updatedAt: DateTime.utc(2026, 1, 9, 12), + userId: 'sender', + ), + showInChannel: true, + silent: true, + text: 'Hello', + threadParticipants: [fakeUserResponse('participant')], + type: 'deleted', + updatedAt: DateTime.utc(2026, 1, 2), + user: fakeUserResponse('sender'), +); + +/// The [Message] that [generatedMessage] maps to, with the values the mapping creates read off [actual]. +/// +/// Every received attachment gets a new local id, and a channel compares by identity, so the attachment ids and the +/// channels of the shared location and the draft are taken from [actual]; check those channels' `cid` on their own. +Message expectedMessageLike(Message actual) => Message( + id: 'message-id', + text: 'Hello', + type: 'deleted', + attachments: [ + _expectedAttachment(actual.attachments.first.id), + _expectedImageAttachment(actual.attachments.last.id), + ], + mentionedChannel: true, + mentionedGroupIds: const ['group-id'], + mentionedGroups: [ + UserGroup( + createdAt: DateTime.utc(2025, 1, 1), + createdBy: 'creator', + description: 'The group', + id: 'group-id', + members: [ + UserGroupMember(createdAt: DateTime.utc(2025, 1, 2), groupId: 'group-id', isAdmin: true, userId: 'member'), + ], + name: 'Group', + teamId: 'blue', + updatedAt: DateTime.utc(2025, 1, 3), + ), + ], + mentionedHere: false, + mentionedRoles: const ['admin'], + mentionedUsers: [fakeUser('mentioned')], + silent: true, + shadowed: false, + reactionGroups: { + 'love': ReactionGroup( + count: 1, + sumScores: 2, + firstReactionAt: DateTime.utc(2026, 1, 5), + lastReactionAt: DateTime.utc(2026, 1, 6), + ), + }, + latestReactions: [_expectedReaction], + ownReactions: [_expectedOwnReaction], + parentId: 'parent-id', + quotedMessage: _expectedQuotedMessage(actual.quotedMessage!.attachments.single.id), + quotedMessageId: 'quoted-id', + replyCount: 3, + threadParticipants: [fakeUser('participant')], + showInChannel: true, + command: 'giphy', + createdAt: DateTime.utc(2026, 1, 1), + updatedAt: DateTime.utc(2026, 1, 2), + deletedAt: DateTime.utc(2026, 1, 3), + deletedForMe: false, + messageTextUpdatedAt: DateTime.utc(2026, 1, 2, 12), + user: fakeUser('sender'), + pinned: true, + pinnedAt: DateTime.utc(2026, 1, 4), + pinExpires: DateTime.utc(2026, 2), + pinnedBy: fakeUser('pinner'), + poll: poll, + pollId: 'poll-id', + extraData: const { + 'priority': 'high', + 'cid': 'messaging:general', + }, + // Deleted, but not only for the current user. + state: MessageState.softDeleted, + i18n: const {'fr_text': 'Bonjour', 'language': 'en'}, + restrictedVisibility: const ['luke'], + moderation: const Moderation( + action: ModerationAction.remove, + originalText: 'Hullo', + textHarms: ['insult'], + imageHarms: ['nudity'], + blocklistMatched: 'profanity', + semanticFilterMatched: 'spam', + platformCircumvented: true, + ), + draft: _expectedDraft( + attachmentId: actual.draft!.message.attachments.single.id, + channel: actual.draft!.channel, + ), + reminder: MessageReminder( + channelCid: 'messaging:general', + messageId: 'message-id', + userId: 'sender', + remindAt: DateTime.utc(2026, 3), + createdAt: DateTime.utc(2026, 1, 7), + updatedAt: DateTime.utc(2026, 1, 8), + ), + channelRole: 'channel_moderator', + sharedLocation: Location( + channelCid: 'messaging:general', + channel: actual.sharedLocation!.channel, + messageId: 'message-id', + message: expectedLeafMessage('message-id'), + userId: 'sender', + latitude: 1.5, + longitude: 2.5, + createdByDeviceId: 'device-id', + endAt: DateTime.utc(2026, 1, 10), + createdAt: DateTime.utc(2026, 1, 9), + updatedAt: DateTime.utc(2026, 1, 9, 12), + ), + html: '

Hello

', + mml: 'Hello', + imageLabels: const { + 'cat.png': ['cat'], + }, + deletedReplyCount: 2, +); + +final _generatedChannel = api.ChannelResponse( + cid: 'messaging:general', + createdAt: DateTime.utc(2026), + custom: const {}, + disabled: false, + frozen: false, + id: 'general', + type: 'messaging', + updatedAt: DateTime.utc(2026), +); + +// A link preview, read as one whatever type it was sent with. +final _generatedAttachment = api.Attachment( + actions: const [api.Action(name: 'answer', style: 'primary', text: 'Send', type: 'button', value: 'yes')], + assetUrl: 'https://example.com/asset.gif', + authorIcon: 'https://example.com/author.png', + authorLink: 'https://example.com/author', + authorName: 'Author', + color: '#ff0000', + custom: const {'id': 'attachment-id', 'caption': 'A cat', 'title': 'shadowed'}, + fallback: 'A cat gif', + fields: const [api.Field(short: true, title: 'Size', value: 'L')], + footer: 'Footer', + footerIcon: 'https://example.com/footer.png', + giphy: api.Images( + fixedHeight: _imageData('fixed_height'), + fixedHeightDownsampled: _imageData('fixed_height_downsampled'), + fixedHeightStill: _imageData('fixed_height_still'), + fixedWidth: _imageData('fixed_width'), + fixedWidthDownsampled: _imageData('fixed_width_downsampled'), + fixedWidthStill: _imageData('fixed_width_still'), + original: _imageData('original'), + ), + imageUrl: 'https://example.com/image.gif', + ogScrapeUrl: 'https://example.com/scraped', + originalHeight: 300, + originalWidth: 400, + pretext: 'Pretext', + text: 'Attachment text', + thumbUrl: 'https://example.com/thumb.gif', + title: 'Cat', + titleLink: 'https://example.com/cat', + type: 'giphy', +); + +Attachment _expectedAttachment(String id) => Attachment( + id: id, + type: 'giphy', + titleLink: 'https://example.com/cat', + title: 'Cat', + thumbUrl: 'https://example.com/thumb.gif', + text: 'Attachment text', + pretext: 'Pretext', + ogScrapeUrl: 'https://example.com/scraped', + imageUrl: 'https://example.com/image.gif', + footerIcon: 'https://example.com/footer.png', + footer: 'Footer', + fields: const [ + {'short': true, 'title': 'Size', 'value': 'L'}, + ], + fallback: 'A cat gif', + color: '#ff0000', + authorName: 'Author', + authorLink: 'https://example.com/author', + authorIcon: 'https://example.com/author.png', + assetUrl: 'https://example.com/asset.gif', + actions: const [Action(name: 'answer', style: 'primary', text: 'Send', type: 'button', value: 'yes')], + originalWidth: 400, + originalHeight: 300, + extraData: { + 'id': 'attachment-id', + 'caption': 'A cat', + 'giphy': { + for (final rendition in const [ + 'fixed_height', + 'fixed_height_downsampled', + 'fixed_height_still', + 'fixed_width', + 'fixed_width_downsampled', + 'fixed_width_still', + 'original', + ]) + rendition: { + 'frames': '12', + 'height': '200', + 'size': '1024', + 'url': 'https://example.com/$rendition.gif', + 'width': '300', + }, + }, + }, + uploadState: const UploadState.success(), +); + +api.ImageData _imageData(String rendition) => api.ImageData( + frames: '12', + height: '200', + size: '1024', + url: 'https://example.com/$rendition.gif', + width: '300', +); + +const _generatedImageAttachment = api.Attachment( + custom: {}, + imageUrl: 'https://example.com/photo.png', + type: 'image', +); + +Attachment _expectedImageAttachment(String id) => Attachment( + id: id, + type: 'image', + imageUrl: 'https://example.com/photo.png', + uploadState: const UploadState.success(), +); + +final _generatedReaction = api.ReactionResponse( + createdAt: DateTime.utc(2026, 1, 5), + custom: const {'emoji_code': '❤️', 'mood': 'happy', 'type': 'shadowed'}, + messageId: 'message-id', + score: 2, + type: 'love', + updatedAt: DateTime.utc(2026, 1, 6), + user: fakeUserResponse('reactor'), + userId: 'reactor-id', +); + +final _expectedReaction = Reaction( + messageId: 'message-id', + type: 'love', + user: fakeUser('reactor'), + userId: 'reactor-id', + score: 2, + emojiCode: '❤️', + createdAt: DateTime.utc(2026, 1, 5), + updatedAt: DateTime.utc(2026, 1, 6), + extraData: const {'mood': 'happy'}, +); + +final _generatedOwnReaction = api.ReactionResponse( + createdAt: DateTime.utc(2026, 1, 4, 12), + custom: const {}, + messageId: 'message-id', + score: 1, + type: 'wow', + updatedAt: DateTime.utc(2026, 1, 4, 13), + user: fakeUserResponse('owner'), + userId: 'owner-id', +); + +final _expectedOwnReaction = Reaction( + messageId: 'message-id', + type: 'wow', + user: fakeUser('owner'), + userId: 'owner-id', + score: 1, + createdAt: DateTime.utc(2026, 1, 4, 12), + updatedAt: DateTime.utc(2026, 1, 4, 13), +); + +final _generatedDraft = api.DraftResponse( + channel: _generatedChannel, + channelCid: 'messaging:general', + createdAt: DateTime.utc(2026, 1, 11), + message: api.DraftPayloadResponse( + attachments: const [ + api.Attachment(custom: {'caption': 'Notes'}, title: 'notes.pdf', type: 'file'), + ], + custom: const {'mood': 'busy', 'text': 'shadowed'}, + html: '

Draft

', + id: 'draft-id', + mentionedUsers: [fakeUserResponse('mentioned')], + mml: 'Draft', + parentId: 'message-id', + pollId: 'draft-poll-id', + quotedMessageId: 'draft-quoted-id', + showInChannel: true, + silent: false, + text: 'Draft', + type: 'reply', + ), + parentId: 'message-id', + parentMessage: generatedLeafMessage('message-id'), + quotedMessage: generatedLeafMessage('draft-quoted-id'), +); + +Draft _expectedDraft({required String attachmentId, required ChannelModel? channel}) => Draft( + channelCid: 'messaging:general', + channel: channel, + createdAt: DateTime.utc(2026, 1, 11), + message: DraftMessage( + id: 'draft-id', + text: 'Draft', + type: 'reply', + attachments: [ + Attachment( + id: attachmentId, + type: 'file', + title: 'notes.pdf', + extraData: const {'caption': 'Notes'}, + uploadState: const UploadState.success(), + ), + ], + parentId: 'message-id', + showInChannel: true, + mentionedUsers: [fakeUser('mentioned')], + quotedMessageId: 'draft-quoted-id', + silent: false, + pollId: 'draft-poll-id', + extraData: const {'mood': 'busy', 'html': '

Draft

', 'mml': 'Draft'}, + ), + parentId: 'message-id', + parentMessage: expectedLeafMessage('message-id'), + quotedMessage: expectedLeafMessage('draft-quoted-id'), +); + +// A message with the fields the API always sends, a quoted message and poll id without the objects they point to, and +// minimal attachment, moderation, draft and shared location payloads that leave out their optional fields. Its custom data, and that of +// its attachment and draft, has keys named like fields left out. +final _generatedQuotedMessage = api.MessageResponse( + attachments: const [ + api.Attachment( + actions: [api.Action(name: 'reply', text: 'Reply', type: 'button')], + custom: {'note': 'kept', 'giphy': 'shadowed', 'file': 'shadowed', 'upload_state': 'shadowed'}, + type: 'file', + ), + ], + cid: 'messaging:general', + createdAt: DateTime.utc(2025, 12, 2), + custom: const { + 'tag': 'kept', + 'mml': 'shadowed', + 'image_labels': 'shadowed', + 'mentioned_channel_members': 'shadowed', + }, + deletedReplyCount: 1, + draft: api.DraftResponse( + channelCid: 'messaging:general', + createdAt: DateTime.utc(2025, 12, 3), + message: const api.DraftPayloadResponse( + custom: {'mood': 'calm', 'html': 'shadowed', 'mml': 'shadowed'}, + id: 'quoted-draft-id', + text: 'Later', + ), + ), + html: '

Quoted

', + id: 'quoted-id', + latestReactions: const [], + mentionedChannel: false, + mentionedHere: true, + mentionedUsers: const [], + moderation: const api.ModerationV2Response(action: 'shadow', originalText: 'Quote'), + ownReactions: const [], + pinned: false, + pollId: 'quoted-poll-id', + quotedMessageId: 'earlier-id', + reactionCounts: const {}, + reactionScores: const {}, + replyCount: 0, + restrictedVisibility: const [], + shadowed: true, + sharedLocation: api.SharedLocationResponseData( + channelCid: 'messaging:general', + createdAt: DateTime.utc(2025, 12, 4), + createdByDeviceId: 'other-device-id', + latitude: 3.5, + longitude: 4.5, + messageId: 'quoted-id', + updatedAt: DateTime.utc(2025, 12, 5), + userId: 'sender', + ), + silent: true, + text: 'Quoted', + type: 'regular', + updatedAt: DateTime.utc(2025, 12, 2), + user: fakeUserResponse('sender'), +); + +Message _expectedQuotedMessage(String attachmentId) => Message( + id: 'quoted-id', + text: 'Quoted', + attachments: [ + Attachment( + id: attachmentId, + type: 'file', + actions: const [Action(name: 'reply', style: 'default', text: 'Reply', type: 'button')], + extraData: const {'note': 'kept'}, + uploadState: const UploadState.success(), + ), + ], + mentionedChannel: false, + mentionedHere: true, + silent: true, + shadowed: true, + latestReactions: const [], + ownReactions: const [], + quotedMessageId: 'earlier-id', + createdAt: DateTime.utc(2025, 12, 2), + updatedAt: DateTime.utc(2025, 12, 2), + user: fakeUser('sender'), + pollId: 'quoted-poll-id', + extraData: const {'tag': 'kept', 'cid': 'messaging:general'}, + state: MessageState.sent, + restrictedVisibility: const [], + moderation: const Moderation(action: ModerationAction.shadow, originalText: 'Quote'), + sharedLocation: Location( + channelCid: 'messaging:general', + messageId: 'quoted-id', + userId: 'sender', + latitude: 3.5, + longitude: 4.5, + createdByDeviceId: 'other-device-id', + createdAt: DateTime.utc(2025, 12, 4), + updatedAt: DateTime.utc(2025, 12, 5), + ), + draft: Draft( + channelCid: 'messaging:general', + createdAt: DateTime.utc(2025, 12, 3), + message: DraftMessage(id: 'quoted-draft-id', text: 'Later', extraData: const {'mood': 'calm'}), + ), + html: '

Quoted

', + deletedReplyCount: 1, +); + +/// A generated message with the id [id] and only the fields the API always sends. +api.MessageResponse generatedLeafMessage(String id) => api.MessageResponse( + attachments: const [], + cid: 'messaging:general', + createdAt: DateTime.utc(2025, 12, 1), + custom: const {}, + deletedReplyCount: 0, + html: '

Earlier

', + id: id, + latestReactions: const [], + mentionedChannel: false, + mentionedHere: false, + mentionedUsers: const [], + ownReactions: const [], + pinned: true, + reactionCounts: const {}, + reactionScores: const {}, + replyCount: 0, + restrictedVisibility: const [], + shadowed: true, + silent: false, + text: 'Earlier', + type: 'regular', + updatedAt: DateTime.utc(2025, 12, 1), + user: fakeUserResponse('sender'), +); + +/// The [Message] that [generatedLeafMessage] maps to. +Message expectedLeafMessage(String id) => Message( + id: id, + text: 'Earlier', + mentionedChannel: false, + mentionedHere: false, + pinned: true, + shadowed: true, + latestReactions: const [], + ownReactions: const [], + createdAt: DateTime.utc(2025, 12, 1), + updatedAt: DateTime.utc(2025, 12, 1), + user: fakeUser('sender'), + extraData: const {'cid': 'messaging:general'}, + state: MessageState.sent, + restrictedVisibility: const [], + html: '

Earlier

', + deletedReplyCount: 0, +); diff --git a/packages/stream_chat/test/src/core/api/reminders_api_test.dart b/packages/stream_chat/test/src/core/api/reminders_api_test.dart deleted file mode 100644 index efe3d40050..0000000000 --- a/packages/stream_chat/test/src/core/api/reminders_api_test.dart +++ /dev/null @@ -1,272 +0,0 @@ -// ignore_for_file: avoid_redundant_argument_values - -import 'dart:convert'; - -import 'package:dio/dio.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat/src/core/api/reminders_api.dart'; -import 'package:stream_chat/src/core/api/requests.dart'; -import 'package:stream_chat/src/core/models/message_reminder.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 RemindersApi remindersApi; - - setUp(() { - remindersApi = RemindersApi(client); - }); - - group('queryReminders', () { - test('should query reminders without parameters', () async { - const path = '/reminders/query'; - - final reminders = [ - MessageReminder( - messageId: 'test-message-id-1', - channelCid: 'test-channel-cid-1', - userId: 'test-user-id', - remindAt: DateTime(2024, 1, 1), - createdAt: DateTime(2024, 1, 1), - updatedAt: DateTime(2024, 1, 1), - ), - MessageReminder( - messageId: 'test-message-id-2', - channelCid: 'test-channel-cid-2', - userId: 'test-user-id', - remindAt: DateTime(2024, 1, 2), - createdAt: DateTime(2024, 1, 2), - updatedAt: DateTime(2024, 1, 2), - ), - ]; - - when(() => client.post(path, data: jsonEncode({}))).thenAnswer( - (_) async => successResponse( - path, - data: { - 'reminders': reminders.map((r) => r.toJson()).toList(), - }, - ), - ); - - final res = await remindersApi.queryReminders(); - - expect(res, isNotNull); - expect(res.reminders, hasLength(2)); - expect(res.reminders.first.messageId, 'test-message-id-1'); - - verify(() => client.post(path, data: jsonEncode({}))).called(1); - verifyNoMoreInteractions(client); - }); - - test('should query reminders with filter, sort, and pagination', () async { - const path = '/reminders/query'; - final filter = MessageReminderFilter.equal(MessageReminderFilterField.channelCid, 'messaging:test'); - final sort = [MessageReminderSort.desc(MessageReminderSortField.remindAt)]; - const pagination = PaginationParams(limit: 10, offset: 5); - - final expectedPayload = jsonEncode({ - 'filter': filter, - 'sort': sort, - ...pagination.toJson(), - }); - - final reminders = List.generate( - 5, - (index) => MessageReminder( - messageId: 'test-message-id-$index', - channelCid: 'test-channel-cid-$index', - userId: 'test-user-id', - remindAt: DateTime(2024, 1, index + 1), - createdAt: DateTime(2024, 1, index + 1), - updatedAt: DateTime(2024, 1, index + 1), - ), - ); - - when(() => client.post(path, data: expectedPayload)).thenAnswer( - (_) async => successResponse( - path, - data: { - 'reminders': reminders.map((r) => r.toJson()).toList(), - }, - ), - ); - - final res = await remindersApi.queryReminders( - filter: filter, - sort: sort, - pagination: pagination, - ); - - expect(res, isNotNull); - expect(res.reminders, hasLength(5)); - - verify(() => client.post(path, data: expectedPayload)).called(1); - verifyNoMoreInteractions(client); - }); - }); - - group('createReminder', () { - test('should create reminder without remindAt', () async { - const messageId = 'test-message-id'; - const path = '/messages/$messageId/reminders'; - - final reminder = MessageReminder( - messageId: messageId, - channelCid: 'test-channel-cid', - userId: 'test-user-id', - remindAt: DateTime(2024, 1, 1), - createdAt: DateTime(2024, 1, 1), - updatedAt: DateTime(2024, 1, 1), - ); - - when(() => client.post(path, data: jsonEncode({}))).thenAnswer( - (_) async => successResponse( - path, - data: { - 'reminder': reminder.toJson(), - }, - ), - ); - - final res = await remindersApi.createReminder(messageId); - - expect(res, isNotNull); - expect(res.reminder.messageId, messageId); - - verify(() => client.post(path, data: jsonEncode({}))).called(1); - verifyNoMoreInteractions(client); - }); - - test('should create reminder with remindAt', () async { - const messageId = 'test-message-id'; - const path = '/messages/$messageId/reminders'; - final remindAt = DateTime(2024, 6, 15, 14, 30); - - final reminder = MessageReminder( - messageId: messageId, - channelCid: 'test-channel-cid', - userId: 'test-user-id', - remindAt: remindAt, - createdAt: DateTime(2024, 1, 1), - updatedAt: DateTime(2024, 1, 1), - ); - - final expectedPayload = jsonEncode({ - 'remind_at': remindAt.toUtc().toIso8601String(), - }); - - when(() => client.post(path, data: expectedPayload)).thenAnswer( - (_) async => successResponse( - path, - data: { - 'reminder': reminder.toJson(), - }, - ), - ); - - final res = await remindersApi.createReminder(messageId, remindAt: remindAt); - - expect(res, isNotNull); - expect(res.reminder.messageId, messageId); - expect(res.reminder.remindAt, remindAt); - - verify(() => client.post(path, data: expectedPayload)).called(1); - verifyNoMoreInteractions(client); - }); - }); - - group('updateReminder', () { - test('should update reminder without remindAt', () async { - const messageId = 'test-message-id'; - const path = '/messages/$messageId/reminders'; - - final reminder = MessageReminder( - messageId: messageId, - channelCid: 'test-channel-cid', - userId: 'test-user-id', - remindAt: DateTime(2024, 1, 1), - createdAt: DateTime(2024, 1, 1), - updatedAt: DateTime(2024, 1, 2), - ); - - when(() => client.patch(path, data: jsonEncode({}))).thenAnswer( - (_) async => successResponse( - path, - data: { - 'reminder': reminder.toJson(), - }, - ), - ); - - final res = await remindersApi.updateReminder(messageId); - - expect(res, isNotNull); - expect(res.reminder.messageId, messageId); - - verify(() => client.patch(path, data: jsonEncode({}))).called(1); - verifyNoMoreInteractions(client); - }); - - test('should update reminder with remindAt', () async { - const messageId = 'test-message-id'; - const path = '/messages/$messageId/reminders'; - final remindAt = DateTime(2024, 8, 20, 16, 45); - - final reminder = MessageReminder( - messageId: messageId, - channelCid: 'test-channel-cid', - userId: 'test-user-id', - remindAt: remindAt, - createdAt: DateTime(2024, 1, 1), - updatedAt: DateTime(2024, 1, 2), - ); - - final expectedPayload = jsonEncode({ - 'remind_at': remindAt.toUtc().toIso8601String(), - }); - - when(() => client.patch(path, data: expectedPayload)).thenAnswer( - (_) async => successResponse( - path, - data: { - 'reminder': reminder.toJson(), - }, - ), - ); - - final res = await remindersApi.updateReminder(messageId, remindAt: remindAt); - - expect(res, isNotNull); - expect(res.reminder.messageId, messageId); - expect(res.reminder.remindAt, remindAt); - - verify(() => client.patch(path, data: expectedPayload)).called(1); - verifyNoMoreInteractions(client); - }); - }); - - group('deleteReminder', () { - test('should delete reminder', () async { - const messageId = 'test-message-id'; - const path = '/messages/$messageId/reminders'; - - when(() => client.delete(path)).thenAnswer((_) async => successResponse(path, data: {})); - - final res = await remindersApi.deleteReminder(messageId); - - expect(res, isNotNull); - - verify(() => client.delete(path)).called(1); - verifyNoMoreInteractions(client); - }); - }); -} diff --git a/packages/stream_chat/test/src/core/models/converters/v1_json_converters_test.dart b/packages/stream_chat/test/src/core/models/converters/v1_json_converters_test.dart index 688c2e5f56..b0a4f77c2f 100644 --- a/packages/stream_chat/test/src/core/models/converters/v1_json_converters_test.dart +++ b/packages/stream_chat/test/src/core/models/converters/v1_json_converters_test.dart @@ -6,6 +6,7 @@ import 'package:stream_chat/src/core/models/converters/v1_json_converters.dart'; import 'package:stream_chat/src/core/models/device.dart'; import 'package:stream_chat/src/core/models/location.dart'; import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/message_reminder.dart'; import 'package:stream_chat/src/core/models/moderation.dart'; import 'package:stream_chat/src/core/models/push_provider.dart'; import 'package:stream_chat/src/core/models/reaction.dart'; @@ -446,4 +447,101 @@ void main() { expect(message.toJson()['shared_location'], {'latitude': 1.0, 'longitude': 2.0}); }); + + test('Message.fromJson reads every reminder field from its v1 keys', () { + const channelJson = {'id': 'general', 'type': 'messaging', 'cid': 'messaging:general'}; + const messageJson = {'id': 'message-id', 'text': 'Remember me'}; + const userJson = {'id': 'user-id'}; + + final message = Message.fromJson(const { + 'id': 'message-id', + 'reminder': { + 'channel_cid': 'messaging:general', + 'channel': channelJson, + 'message_id': 'message-id', + 'message': messageJson, + 'user_id': 'user-id', + 'user': userJson, + 'remind_at': '2024-06-15T14:30:00.000Z', + 'created_at': '2024-06-01T10:00:00.000Z', + 'updated_at': '2024-06-02T10:00:00.000Z', + }, + }); + + final reminder = message.reminder!; + // A channel compares by identity, so it is checked on its own. + expect(reminder.channel?.cid, 'messaging:general'); + expect( + reminder, + MessageReminder( + channelCid: 'messaging:general', + channel: reminder.channel, + messageId: 'message-id', + message: Message.fromJson(const {...messageJson}), + userId: 'user-id', + user: User.fromJson(const {...userJson}), + remindAt: DateTime.utc(2024, 6, 15, 14, 30), + createdAt: DateTime.utc(2024, 6, 1, 10), + updatedAt: DateTime.utc(2024, 6, 2, 10), + ), + ); + }); + + test('Event.fromJson reads reminder dates sent as epoch nanoseconds', () { + final event = Event.fromJson(const { + 'type': 'reminder.created', + 'reminder': { + 'channel_cid': 'messaging:general', + 'message_id': 'message-id', + 'user_id': 'user-id', + 'remind_at': 1718461800000000000, + 'created_at': 1717236000000000000, + 'updated_at': 1717322400000000000, + }, + }); + + expect(event.reminder?.remindAt, DateTime.utc(2024, 6, 15, 14, 30)); + expect(event.reminder?.createdAt, DateTime.utc(2024, 6, 1, 10)); + expect(event.reminder?.updatedAt, DateTime.utc(2024, 6, 2, 10)); + }); + + test("Event.toJson writes the reminder's ids and dates, without its channel, message or user", () { + final event = Event( + type: 'reminder.created', + reminder: MessageReminder( + channelCid: 'messaging:general', + channel: ChannelModel(cid: 'messaging:general'), + messageId: 'message-id', + message: Message(id: 'message-id'), + userId: 'user-id', + user: User(id: 'user-id'), + remindAt: DateTime.utc(2024, 6, 15, 14, 30), + createdAt: DateTime.utc(2024, 6, 1, 10), + updatedAt: DateTime.utc(2024, 6, 2, 10), + ), + ); + + expect(event.toJson()['reminder'], { + 'channel_cid': 'messaging:general', + 'message_id': 'message-id', + 'user_id': 'user-id', + 'remind_at': '2024-06-15T14:30:00.000Z', + 'created_at': '2024-06-01T10:00:00.000Z', + 'updated_at': '2024-06-02T10:00:00.000Z', + }); + }); + + test('Event.fromJson reads back a reminder without a due date written by toJson', () { + final reminder = MessageReminder( + channelCid: 'messaging:general', + messageId: 'message-id', + userId: 'user-id', + createdAt: DateTime.utc(2024, 6, 1, 10), + updatedAt: DateTime.utc(2024, 6, 2, 10), + ); + + final event = Event.fromJson(Event(type: 'reminder.updated', reminder: reminder).toJson()); + + expect(event.reminder, reminder); + }); } diff --git a/packages/stream_chat/test/src/core/models/message_reminder_test.dart b/packages/stream_chat/test/src/core/models/message_reminder_test.dart index 267239ad8f..53aa4986a0 100644 --- a/packages/stream_chat/test/src/core/models/message_reminder_test.dart +++ b/packages/stream_chat/test/src/core/models/message_reminder_test.dart @@ -81,42 +81,6 @@ void main() { expect(bookmarkReminder.userId, equals(userId)); }); - test('should correctly serialize to JSON', () { - final json = messageReminder.toJson(); - - expect(json['channel_cid'], equals(channelCid)); - expect(json['message_id'], equals(messageId)); - expect(json['user_id'], equals(userId)); - expect(json['remind_at'], isA()); - expect(json['created_at'], isA()); - expect(json['updated_at'], isA()); - - // These fields should not be included in JSON - expect(json.containsKey('channel'), isFalse); - expect(json.containsKey('message'), isFalse); - expect(json.containsKey('user'), isFalse); - }); - - test('should correctly deserialize from JSON', () { - final json = { - 'channel_cid': channelCid, - 'message_id': messageId, - 'user_id': userId, - 'remind_at': remindAt.toIso8601String(), - 'created_at': now.toIso8601String(), - 'updated_at': now.toIso8601String(), - }; - - final deserializedReminder = MessageReminder.fromJson(json); - - expect(deserializedReminder.channelCid, equals(channelCid)); - expect(deserializedReminder.messageId, equals(messageId)); - expect(deserializedReminder.userId, equals(userId)); - expect(deserializedReminder.remindAt, equals(remindAt)); - expect(deserializedReminder.createdAt, equals(now)); - expect(deserializedReminder.updatedAt, equals(now)); - }); - test('should implement equality correctly', () { final reminder1 = MessageReminder( channelCid: channelCid, @@ -259,6 +223,19 @@ void main() { expect(mergedReminder.updatedAt, equals(newUpdatedAt)); }); + test('MessageReminder.merge keeps the channel, message and user the other reminder lacks', () { + final channel = ChannelModel(cid: channelCid); + final message = Message(id: messageId, text: 'Remember me'); + final user = User(id: userId); + final listedReminder = messageReminder.copyWith(channel: channel, message: message, user: user); + // The server fills in a reminder's channel, message and user only on some paths, so an update can lack them. + final eventReminder = messageReminder.copyWith(updatedAt: now.add(const Duration(hours: 1))); + + final mergedReminder = listedReminder.merge(eventReminder); + + expect((mergedReminder.channel, mergedReminder.message, mergedReminder.user), (channel, message, user)); + }); + test('should return original instance when merging with null', () { final mergedReminder = messageReminder.merge(null); diff --git a/packages/stream_chat/test/src/core/models/message_test.dart b/packages/stream_chat/test/src/core/models/message_test.dart index 9b30ca8a10..391b3c3413 100644 --- a/packages/stream_chat/test/src/core/models/message_test.dart +++ b/packages/stream_chat/test/src/core/models/message_test.dart @@ -1,5 +1,7 @@ // ignore_for_file: avoid_redundant_argument_values, lines_longer_than_80_chars, deprecated_member_use_from_same_package +import 'dart:convert'; + import 'package:stream_chat/src/core/models/attachment.dart'; import 'package:stream_chat/src/core/models/message.dart'; import 'package:stream_chat/src/core/models/message_state.dart'; @@ -458,26 +460,195 @@ void main() { ); test( - 'synthesizes groups from legacy reaction_counts/reaction_scores ' - 'even when the score total is zero or negative', + 'Message.fromJson synthesizes groups from legacy reaction_counts/reaction_scores ' + 'for the types with a positive count, whatever their score', () { final message = Message.fromJson(const { - 'reaction_counts': {'like': 2, 'dislike': 3}, - 'reaction_scores': {'like': 0, 'dislike': -3}, + 'reaction_counts': {'like': 2, 'dislike': 3, 'wow': 0}, + 'reaction_scores': {'like': 0, 'dislike': -3, 'wow': 0}, }); - // Both groups must be retained because their count is positive, even - // though the summed scores are zero and negative respectively. - expect(message.reactionGroups, isNotNull); + // 'like' and 'dislike' are retained because their count is positive, + // even though the summed scores are zero and negative respectively; + // 'wow' has no reaction left. + expect(message.reactionGroups!.keys, unorderedEquals(['like', 'dislike'])); expect(message.reactionGroups!['like']!.count, 2); expect(message.reactionGroups!['like']!.sumScores, 0); expect(message.reactionGroups!['dislike']!.count, 3); expect(message.reactionGroups!['dislike']!.sumScores, -3); }, ); + + test('Message.fromJson prefers reaction_groups over legacy reaction_counts/reaction_scores', () { + final message = Message.fromJson(const { + 'reaction_groups': { + 'like': { + 'count': 1, + 'sum_scores': 2, + 'first_reaction_at': '2026-01-05T00:00:00.000Z', + 'last_reaction_at': '2026-01-06T00:00:00.000Z', + }, + }, + // The groups' dates tell them apart from groups built from these. + 'reaction_counts': {'like': 1}, + 'reaction_scores': {'like': 2}, + }); + + expect(message.reactionGroups, { + 'like': ReactionGroup( + count: 1, + sumScores: 2, + firstReactionAt: DateTime.utc(2026, 1, 5), + lastReactionAt: DateTime.utc(2026, 1, 6), + ), + }); + }); }); }); + test('Message stores in extraData only the HTML, markup, image labels and deleted reply count it is given', () { + const extraData = {'priority': 'high'}; + + final messages = [ + Message(extraData: extraData, html: '

Hi

'), + Message(extraData: extraData, mml: 'Hi'), + Message( + extraData: extraData, + imageLabels: const { + 'cat.png': ['cat'], + }, + ), + Message(extraData: extraData, deletedReplyCount: 2), + ]; + + expect(messages.map((message) => message.extraData), [ + {'priority': 'high', 'html': '

Hi

'}, + {'priority': 'high', 'mml': 'Hi'}, + { + 'priority': 'high', + 'image_labels': { + 'cat.png': ['cat'], + }, + }, + {'priority': 'high', 'deleted_reply_count': 2}, + ]); + }); + + test( + 'Message replaces the extraData entries of the HTML, markup, image labels and deleted reply count it is given', + () { + final message = Message( + extraData: const { + 'priority': 'high', + 'html': '

Old

', + 'mml': 'Old', + 'image_labels': { + 'dog.png': ['dog'], + }, + 'deleted_reply_count': 1, + }, + html: '

Hi

', + mml: 'Hi', + imageLabels: const { + 'cat.png': ['cat'], + }, + deletedReplyCount: 2, + ); + + expect(message.extraData, { + 'priority': 'high', + 'html': '

Hi

', + 'mml': 'Hi', + 'image_labels': { + 'cat.png': ['cat'], + }, + 'deleted_reply_count': 2, + }); + }, + ); + + test('Message.fromJson reads the HTML, markup, image labels and deleted reply count from extraData', () { + // Decoded JSON, as events and the offline cache deliver it, so the labels arrive as a List. + final message = Message.fromJson( + jsonDecode(''' + { + "id": "message-id", + "html": "

Hi

", + "mml": "Hi", + "image_labels": {"cat.png": ["cat", "animal"]}, + "deleted_reply_count": 2 + } + ''') + as Map, + ); + + expect(message.html, '

Hi

'); + expect(message.mml, 'Hi'); + expect(message.imageLabels, { + 'cat.png': ['cat', 'animal'], + }); + expect(message.deletedReplyCount, 2); + }); + + test( + 'Message.fromJson reads a message deleted only for the current user as deleted, even when it is soft-deleted', + () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'type': 'regular', + 'deleted_for_me': true, + 'deleted_at': '2026-01-03T00:00:00.000Z', + 'created_at': '2026-01-01T00:00:00.000Z', + 'updated_at': '2026-01-02T00:00:00.000Z', + }); + + expect((message.type, message.state), (MessageType.deleted, MessageState.deletedForMe)); + }, + ); + + test('Message.fromJson reads a deleted message as soft-deleted', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'type': 'deleted', + 'deleted_at': '2026-01-03T00:00:00.000Z', + 'created_at': '2026-01-01T00:00:00.000Z', + 'updated_at': '2026-01-02T00:00:00.000Z', + }); + + expect((message.type, message.state), (MessageType.deleted, MessageState.softDeleted)); + }); + + test('Message.fromJson reads a message updated after it was created as updated', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'type': 'regular', + 'created_at': '2026-01-01T00:00:00.000Z', + 'updated_at': '2026-01-02T00:00:00.000Z', + }); + + expect((message.type, message.state), (MessageType.regular, MessageState.updated)); + }); + + test('Message.fromJson reads a message never updated as sent', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'type': 'regular', + 'created_at': '2026-01-01T00:00:00.000Z', + 'updated_at': '2026-01-01T00:00:00.000Z', + }); + + expect((message.type, message.state), (MessageType.regular, MessageState.sent)); + }); + + test('Message reads no HTML, markup, image labels or deleted reply count when extraData has none', () { + final message = Message(); + + expect(message.html, isNull); + expect(message.mml, isNull); + expect(message.imageLabels, isNull); + expect(message.deletedReplyCount, isNull); + }); + group('MessageVisibility Extension Tests', () { group('hasRestrictedVisibility', () { test('should return false when restrictedVisibility is null', () { diff --git a/packages/stream_chat_flutter_core/lib/src/stream_message_reminder_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_message_reminder_list_controller.dart index 10db0cc0d7..e93c8e62c7 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_message_reminder_list_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_message_reminder_list_controller.dart @@ -105,58 +105,53 @@ class StreamMessageReminderListController extends PagedValueNotifier value = PagedValue.error(_chatException(error, 'Failed to load message reminders')), + ); } @override Future loadMore(String nextPageKey) async { final previousValue = value.asSuccess; - try { - final response = await client.queryReminders( - sort: _activeSort, - filter: _activeFilter, - pagination: PaginationParams(limit: limit, next: nextPageKey), - ); - - final results = response.reminders; - 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 message reminders', cause: error); - value = previousValue.copyWith(error: chatError); - } + final result = await client.queryReminders( + sort: _activeSort, + filter: _activeFilter, + limit: limit, + next: nextPageKey, + ); + + result.fold( + onSuccess: (response) { + final next = response.next; + value = PagedValue( + items: previousValue.items + response.reminders, + nextPageKey: next != null && next.isNotEmpty ? next : null, + ); + }, + onFailure: (error, _) { + value = previousValue.copyWith(error: _chatException(error, 'Failed to load more message reminders')); + }, + ); } + StreamChatException _chatException(Object error, String message) => switch (error) { + final StreamChatException error => error, + _ => StreamClientException(message: message, cause: error), + }; + /// Event listener, which can be set in order to listen /// [client] web-socket events. /// diff --git a/packages/stream_chat_flutter_core/test/stream_message_reminder_list_controller_test.dart b/packages/stream_chat_flutter_core/test/stream_message_reminder_list_controller_test.dart index e1c4967292..20715d8a8a 100644 --- a/packages/stream_chat_flutter_core/test/stream_message_reminder_list_controller_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_message_reminder_list_controller_test.dart @@ -66,10 +66,6 @@ List generateMessageReminders({ void main() { final client = MockClient(); - setUpAll(() { - registerFallbackValue(const PaginationParams()); - }); - setUp(() { when(client.on).thenAnswer((_) => const Stream.empty()); }); @@ -98,17 +94,18 @@ void main() { }); group('Initial loading', () { - test('successfully loads message reminders from API', () async { + test('StreamMessageReminderListController.doInitialLoad loads the first page and the cursor of the next', () async { final reminders = generateMessageReminders(); - final response = QueryRemindersResponse() - ..reminders = reminders - ..next = null; + final response = Result.success( + QueryRemindersResponse(duration: '0.01ms', reminders: reminders, next: 'next-cursor'), + ); when( () => client.queryReminders( filter: any(named: 'filter'), sort: any(named: 'sort'), - pagination: any(named: 'pagination'), + limit: any(named: 'limit'), + next: any(named: 'next'), ), ).thenAnswer((_) async => response); @@ -121,12 +118,13 @@ void main() { () => client.queryReminders( filter: any(named: 'filter'), sort: any(named: 'sort'), - pagination: any(named: 'pagination'), + limit: 30, ), ).called(1); expect(controller.value, isA>()); expect(controller.value.asSuccess.items, equals(reminders)); + expect(controller.value.asSuccess.nextPageKey, 'next-cursor'); }); test('an empty sort queries without a sort term', () async { @@ -136,13 +134,12 @@ void main() { () => client.queryReminders( filter: any(named: 'filter'), sort: any(named: 'sort'), - pagination: any(named: 'pagination'), + limit: any(named: 'limit'), + next: any(named: 'next'), ), ).thenAnswer((invocation) async { sorts.add(invocation.namedArguments[const Symbol('sort')]); - return QueryRemindersResponse() - ..reminders = generateMessageReminders() - ..next = null; + return Result.success(QueryRemindersResponse(duration: '0.01ms', reminders: generateMessageReminders())); }); final controller = StreamMessageReminderListController( @@ -156,15 +153,16 @@ void main() { expect(sorts.single, isEmpty); }); - test('handles a Stream failure properly', () async { + test('StreamMessageReminderListController.doInitialLoad reports a failed query as an error', () async { const chatError = StreamNetworkException(message: 'Network error'); when( () => client.queryReminders( filter: any(named: 'filter'), sort: any(named: 'sort'), - pagination: any(named: 'pagination'), + limit: any(named: 'limit'), + next: any(named: 'next'), ), - ).thenThrow(chatError); + ).thenAnswer((_) async => const Result.failure(chatError)); final controller = StreamMessageReminderListController(client: client); @@ -177,27 +175,28 @@ void main() { }); group('Pagination', () { - test('loadMore appends new reminders to existing items', () async { + test('StreamMessageReminderListController.loadMore appends the last page and stops paging', () async { const nextKey = 'next_page_token'; final existingReminders = generateMessageReminders(); - final additionalReminders = generateMessageReminders( - count: 1, - startId: 789, - texts: ['Reminder 3'], - channelCids: ['messaging:789'], - messageIds: ['message_789'], - userIds: ['user_789'], - ); + // Due after the existing ones, which come first in the default sort. + final additionalReminders = [ + generateMessageReminder( + channelCid: 'messaging:789', + messageId: 'message_789', + userId: 'user_789', + text: 'Reminder 3', + remindAt: DateTime.now().add(const Duration(hours: 3)), + ), + ]; - final response = QueryRemindersResponse() - ..reminders = additionalReminders - ..next = null; + final response = Result.success(QueryRemindersResponse(duration: '0.01ms', reminders: additionalReminders)); when( () => client.queryReminders( filter: any(named: 'filter'), sort: any(named: 'sort'), - pagination: any(named: 'pagination'), + limit: any(named: 'limit'), + next: any(named: 'next'), ), ).thenAnswer((_) async => response); @@ -212,44 +211,79 @@ void main() { await controller.loadMore(nextKey); await pumpEventQueue(); + verify( + () => client.queryReminders( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + limit: 10, + next: nextKey, + ), + ).called(1); + final mergedReminders = [...existingReminders, ...additionalReminders]; - expect( - controller.value.asSuccess.items.length, - equals(mergedReminders.length), - ); + expect(controller.value.asSuccess.items, equals(mergedReminders)); expect(controller.value.asSuccess.nextPageKey, isNull); }); - test('loadMore handles a Stream failure properly', () async { + test('StreamMessageReminderListController.loadMore keeps the cursor of the following page', () async { const nextKey = 'next_page_token'; - final existingReminders = generateMessageReminders(); - const chatError = StreamNetworkException(message: 'Network error'); - + const page = Result.success( + QueryRemindersResponse(duration: '0.01ms', reminders: [], next: 'later-cursor'), + ); when( () => client.queryReminders( filter: any(named: 'filter'), sort: any(named: 'sort'), - pagination: any(named: 'pagination'), + limit: any(named: 'limit'), + next: any(named: 'next'), ), - ).thenThrow(chatError); + ).thenAnswer((_) async => page); final controller = StreamMessageReminderListController.fromValue( - PagedValue( - items: existingReminders, - nextPageKey: nextKey, - ), + PagedValue(items: generateMessageReminders(), nextPageKey: nextKey), client: client, ); await controller.loadMore(nextKey); await pumpEventQueue(); - expect(controller.value.isSuccess, isTrue); - expect(controller.value.asSuccess.items, equals(existingReminders)); - expect(controller.value.asSuccess.error, equals(chatError)); + expect(controller.value.asSuccess.nextPageKey, 'later-cursor'); }); + + test( + 'StreamMessageReminderListController.loadMore keeps the loaded reminders and reports a failed query', + () async { + const nextKey = 'next_page_token'; + final existingReminders = generateMessageReminders(); + const chatError = StreamNetworkException(message: 'Network error'); + + when( + () => client.queryReminders( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + limit: any(named: 'limit'), + next: any(named: 'next'), + ), + ).thenAnswer((_) async => const Result.failure(chatError)); + + final controller = StreamMessageReminderListController.fromValue( + PagedValue( + items: existingReminders, + nextPageKey: nextKey, + ), + client: client, + ); + + await controller.loadMore(nextKey); + await pumpEventQueue(); + + expect(controller.value.isSuccess, isTrue); + expect(controller.value.asSuccess.items, equals(existingReminders)); + expect(controller.value.asSuccess.error, equals(chatError)); + }, + ); }); group('Message Reminder CRUD operations', () { @@ -361,12 +395,11 @@ void main() { () => client.queryReminders( filter: any(named: 'filter'), sort: any(named: 'sort'), - pagination: any(named: 'pagination'), + limit: any(named: 'limit'), + next: any(named: 'next'), ), ).thenAnswer( - (_) async => QueryRemindersResponse() - ..reminders = initialReminders - ..next = null, + (_) async => Result.success(QueryRemindersResponse(duration: '0.01ms', reminders: initialReminders)), ); }); From 514d3a77705b4b1f471861e6a40441fb722a8a61 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 9 Oct 2026 11:42:03 +0200 Subject: [PATCH 17/19] feat(llc): add Attachment.giphy Co-Authored-By: Claude Opus 5.5 --- openapi-migration/10-messages.md | 5 +- openapi-migration/tool/generate_plan.py | 5 +- packages/stream_chat/CHANGELOG.md | 1 + .../lib/src/core/models/attachment.dart | 11 ++++ .../repository/mapper/attachment_mapper.dart | 10 ++-- .../test/src/client/message_fixtures.dart | 39 +++++++------- .../test/src/core/models/attachment_test.dart | 53 +++++++++++++++++++ 7 files changed, 93 insertions(+), 31 deletions(-) diff --git a/openapi-migration/10-messages.md b/openapi-migration/10-messages.md index 8181bf267f..9802abb5de 100644 --- a/openapi-migration/10-messages.md +++ b/openapi-migration/10-messages.md @@ -118,8 +118,9 @@ an endpoint, so the definition of done below stays open. - **Moderation actions go through `ModerationAction.fromJson`,** so legacy names read as current ones. - **Attachments map at parity with `Attachment.fromJson`:** each gets a new local `id`, and the id the attachment was sent with stays in `extraData`; `giphy` and `fields` are written in their v1 JSON - shape, which `GiphyInfo` and the UI read. Making `Attachment` plain, and promoting those fields, is - left to this group. + shape, which `GiphyInfo` and the UI read. The Giphy renditions are promoted like `Message.html`: a + `giphy` constructor argument stored in `extraData`, read back through `Attachment.giphy`, a map in + that shape. Making `Attachment` plain, and typing the renditions and `fields`, is left to this group. - **A reminder or location nests its message one level deep,** so the recursion ends there. - **`MessageWithChannelResponse` gets its mapper with its first consumer** (getMessage, search), not ahead of it. diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 3b89f30c66..ee5b00ab5b 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -744,8 +744,9 @@ def match(verb, path): - **Moderation actions go through `ModerationAction.fromJson`,** so legacy names read as current ones. - **Attachments map at parity with `Attachment.fromJson`:** each gets a new local `id`, and the id the attachment was sent with stays in `extraData`; `giphy` and `fields` are written in their v1 JSON - shape, which `GiphyInfo` and the UI read. Making `Attachment` plain, and promoting those fields, is - left to this group. + shape, which `GiphyInfo` and the UI read. The Giphy renditions are promoted like `Message.html`: a + `giphy` constructor argument stored in `extraData`, read back through `Attachment.giphy`, a map in + that shape. Making `Attachment` plain, and typing the renditions and `fields`, is left to this group. - **A reminder or location nests its message one level deep,** so the recursion ends there. - **`MessageWithChannelResponse` gets its mapper with its first consumer** (getMessage, search), not ahead of it. diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 8792697ff2..f8d6bad4a1 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -172,6 +172,7 @@ - Added `ChannelModel.truncatedBy`, `autoTranslationEnabled` and `autoTranslationLanguage`, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, `futureChannelBanExpires`, `deletedAt` and `topLevelFields`; the constructors accept the new fields. - Added `User.deactivatedAt`, `deletedAt` and `shadowBanned`; the constructors accept the new fields. - Added `Message.html`, `mml`, `imageLabels` and `deletedReplyCount`, read from the message's `extraData`; the constructor accepts them. +- Added `Attachment.giphy`, the Giphy renditions read from the attachment's `extraData`; the constructor accepts them. 🔒 Security diff --git a/packages/stream_chat/lib/src/core/models/attachment.dart b/packages/stream_chat/lib/src/core/models/attachment.dart index 0960af959b..876e577825 100644 --- a/packages/stream_chat/lib/src/core/models/attachment.dart +++ b/packages/stream_chat/lib/src/core/models/attachment.dart @@ -4,6 +4,7 @@ import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:uuid/uuid.dart'; +import '../util/extension.dart'; import '../util/serializer.dart'; import 'action.dart'; import 'attachment_file.dart'; @@ -16,6 +17,8 @@ part 'attachment.g.dart'; @JsonSerializable(includeIfNull: false) class Attachment extends Equatable { /// Constructor used for json serialization + /// + /// The [giphy] argument, when given, is stored in [extraData], replacing the entry it reads. Attachment({ String? id, String? type, @@ -41,6 +44,7 @@ class Attachment extends Equatable { Map extraData = const {}, this.file, this.uploadState = const UploadState.preparing(), + Map? giphy, }) : id = id ?? const Uuid().v4(), _type = switch (type) { String() => AttachmentType(type), @@ -54,6 +58,7 @@ class Attachment extends Equatable { ...extraData, if (file?.size != null) 'file_size': file?.size, if (file?.mediaType != null) 'mime_type': file?.mediaType?.mimeType, + 'giphy': ?giphy, }; /// Create a new instance from a json @@ -179,6 +184,12 @@ class Attachment extends Equatable { @JsonKey(includeToJson: false, includeFromJson: false) String? get mimeType => extraData['mime_type'] as String?; + /// The renditions of this Giphy attachment, keyed by name, such as `original` or `fixed_height`. + /// + /// Each rendition holds its `url`, `width`, `height`, `size` and `frames`, as strings. + @JsonKey(includeToJson: false, includeFromJson: false) + Map? get giphy => extraData['giphy'].safeCast>(); + /// Known top level fields. /// Useful for [Serializer] methods. static const topLevelFields = [ diff --git a/packages/stream_chat/lib/src/repository/mapper/attachment_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/attachment_mapper.dart index 6fb3666e81..98df87a5fc 100644 --- a/packages/stream_chat/lib/src/repository/mapper/attachment_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/attachment_mapper.dart @@ -19,8 +19,8 @@ extension AttachmentMapper on api.Attachment { /// Converts this attachment into an [Attachment]. /// /// The attachment gets a new local [Attachment.id]; an id it was sent with is kept in [Attachment.extraData], beside - /// the custom data and the Giphy renditions, with custom data named like one of the attachment's own fields left - /// out. + /// the custom data and the [Attachment.giphy] renditions, with custom data named like one of the attachment's own + /// fields left out. Attachment toModel() => Attachment( type: type, titleLink: titleLink, @@ -42,10 +42,8 @@ extension AttachmentMapper on api.Attachment { actions: actions?.map((action) => action.toModel()).toList() ?? const [], originalWidth: originalWidth, originalHeight: originalHeight, - extraData: { - ...{...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), - if (giphy case final giphy?) 'giphy': giphy.toJson(), - }, + extraData: {...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), + giphy: giphy?.toJson(), uploadState: const UploadState.success(), ); } diff --git a/packages/stream_chat/test/src/client/message_fixtures.dart b/packages/stream_chat/test/src/client/message_fixtures.dart index aa78d0d916..a3a5750aeb 100644 --- a/packages/stream_chat/test/src/client/message_fixtures.dart +++ b/packages/stream_chat/test/src/client/message_fixtures.dart @@ -314,27 +314,24 @@ Attachment _expectedAttachment(String id) => Attachment( actions: const [Action(name: 'answer', style: 'primary', text: 'Send', type: 'button', value: 'yes')], originalWidth: 400, originalHeight: 300, - extraData: { - 'id': 'attachment-id', - 'caption': 'A cat', - 'giphy': { - for (final rendition in const [ - 'fixed_height', - 'fixed_height_downsampled', - 'fixed_height_still', - 'fixed_width', - 'fixed_width_downsampled', - 'fixed_width_still', - 'original', - ]) - rendition: { - 'frames': '12', - 'height': '200', - 'size': '1024', - 'url': 'https://example.com/$rendition.gif', - 'width': '300', - }, - }, + extraData: const {'id': 'attachment-id', 'caption': 'A cat'}, + giphy: { + for (final rendition in const [ + 'fixed_height', + 'fixed_height_downsampled', + 'fixed_height_still', + 'fixed_width', + 'fixed_width_downsampled', + 'fixed_width_still', + 'original', + ]) + rendition: { + 'frames': '12', + 'height': '200', + 'size': '1024', + 'url': 'https://example.com/$rendition.gif', + 'width': '300', + }, }, uploadState: const UploadState.success(), ); diff --git a/packages/stream_chat/test/src/core/models/attachment_test.dart b/packages/stream_chat/test/src/core/models/attachment_test.dart index 4417ef1173..4b37bfad3d 100644 --- a/packages/stream_chat/test/src/core/models/attachment_test.dart +++ b/packages/stream_chat/test/src/core/models/attachment_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:stream_chat/src/core/models/action.dart'; import 'package:stream_chat/src/core/models/attachment.dart'; import 'package:stream_chat/src/core/models/attachment_file.dart'; @@ -163,4 +165,55 @@ void main() { }); }); }); + + test('Attachment stores the Giphy renditions it is given in extraData', () { + final attachment = Attachment( + extraData: const {'caption': 'A cat'}, + giphy: const { + 'original': {'url': 'https://example.com/original.gif'}, + }, + ); + + expect(attachment.extraData, { + 'caption': 'A cat', + 'giphy': { + 'original': {'url': 'https://example.com/original.gif'}, + }, + }); + }); + + test('Attachment replaces the extraData entry of the Giphy renditions it is given', () { + final attachment = Attachment( + extraData: const { + 'giphy': { + 'original': {'url': 'https://example.com/old.gif'}, + }, + }, + giphy: const { + 'original': {'url': 'https://example.com/new.gif'}, + }, + ); + + expect(attachment.extraData, { + 'giphy': { + 'original': {'url': 'https://example.com/new.gif'}, + }, + }); + }); + + test('Attachment.fromJson reads the Giphy renditions from extraData', () { + // Decoded JSON, as events and the offline cache deliver it. + final attachment = Attachment.fromJson( + jsonDecode('{"type": "giphy", "giphy": {"original": {"url": "https://example.com/original.gif"}}}') + as Map, + ); + + expect(attachment.giphy, { + 'original': {'url': 'https://example.com/original.gif'}, + }); + }); + + test('Attachment reads no Giphy renditions when extraData has none', () { + expect(Attachment().giphy, isNull); + }); } From bf016c042c46b8651325fb8673a1c84e87cf89b3 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 9 Oct 2026 16:41:40 +0200 Subject: [PATCH 18/19] 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 e627c92bad90a26f6a4c7bba95e4b23d9f7ca2c1 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 9 Oct 2026 16:46:29 +0200 Subject: [PATCH 19/19] refactor(llc): name the message mapper files consistently and plan the custom data revisit Co-Authored-By: Claude Opus 5.5 --- openapi-migration/04-roles-guest-and-app.md | 2 +- openapi-migration/09-users.md | 4 +- openapi-migration/10-messages.md | 7 +-- openapi-migration/11-channels-and-members.md | 9 ++-- openapi-migration/15-partial-updates.md | 2 +- openapi-migration/16-channel-lifecycle.md | 2 +- openapi-migration/19-user-blocking.md | 2 +- openapi-migration/21-custom-data-rename.md | 13 +++++ openapi-migration/README.md | 12 ++--- openapi-migration/tool/generate_plan.py | 52 ++++++++++++++----- ...nt_mapper.dart => attachments_mapper.dart} | 0 .../src/repository/mapper/drafts_mapper.dart | 4 +- ...tion_mapper.dart => locations_mapper.dart} | 2 +- ...ssage_mapper.dart => messages_mapper.dart} | 22 ++++++-- .../repository/mapper/moderation_mapper.dart | 17 ------ ...tion_mapper.dart => reactions_mapper.dart} | 5 +- .../repository/mapper/reminders_mapper.dart | 2 +- 17 files changed, 99 insertions(+), 58 deletions(-) rename packages/stream_chat/lib/src/repository/mapper/{attachment_mapper.dart => attachments_mapper.dart} (100%) rename packages/stream_chat/lib/src/repository/mapper/{location_mapper.dart => locations_mapper.dart} (95%) rename packages/stream_chat/lib/src/repository/mapper/{message_mapper.dart => messages_mapper.dart} (82%) rename packages/stream_chat/lib/src/repository/mapper/{reaction_mapper.dart => reactions_mapper.dart} (84%) diff --git a/openapi-migration/04-roles-guest-and-app.md b/openapi-migration/04-roles-guest-and-app.md index 8753aa331e..d48028d7ec 100644 --- a/openapi-migration/04-roles-guest-and-app.md +++ b/openapi-migration/04-roles-guest-and-app.md @@ -100,7 +100,7 @@ non-date value). v10's flat response shadowed them, so the same guest connected; confirmed live. - **Not a regression: a custom `ban_expires` that isn't a date** fails the connect in v10 and now alike, because the socket's `me` lets it through into `OwnUser.fromJson`. -- **`User` is not restructured here.** `user_mapper.dart` maps the generated types onto today's `User`, +- **`User` is not restructured here.** `users_mapper.dart` maps the generated types onto today's `User`, following [01-foundation](01-foundation.md). ## Risks diff --git a/openapi-migration/09-users.md b/openapi-migration/09-users.md index 48fb9c651b..a786359c14 100644 --- a/openapi-migration/09-users.md +++ b/openapi-migration/09-users.md @@ -27,7 +27,7 @@ ## Decisions to make -- `User` and `OwnUser` are public, persisted, and embedded in nearly every other response. This group restructures them, last: the mappers in `user_mapper.dart` already map the generated types onto the current class for every group before it, and stay. See [01-foundation](01-foundation.md). +- `User` and `OwnUser` are public, persisted, and embedded in nearly every other response. This group restructures them, last: the mappers in `users_mapper.dart` already map the generated types onto the current class for every group before it, and stay. See [01-foundation](01-foundation.md). - `PrivacySettings` and the push-preference sub-shapes — decide per type. - `UserResponse.toModel()` and `FullUserResponse.toModel()` drop custom data named like one of the user's own fields (`_shadowedCustomKeys`): `deactivated_at`, `deleted_at` and `shadow_banned` are refilled from the typed fields, and the `OwnUser`-only keys and `revoke_tokens_issued_before` are left out, where v1 kept them in a plain user's `extraData`. Revisit once the mapper serves plain users. - **Decide `User`, `FullUserResponse` and `OwnUser` together, here:** which private fields a plain user exposes, whether the caller's own entry maps to `OwnUser`, and what is kept in `extraData` once `User` drops `fromJson` and `toJson`. One input, verified live while migrating [06](06-reminders.md): `blocked_user_ids`, which v1 left in every nested user's `extraData`, arrives as `[]` on every user nested in a message or reminder, on v1 and v2 alike and even for the caller with real blocks; only connect and `queryUsers` return the caller's real list. Writing it back into `extraData` in `UserResponse.toModel()` restores v10 parity for nested users at no information cost, but changes every group's mapped users, so it was deferred to this decision. @@ -63,6 +63,6 @@ name states its subject and behaviour. - [ ] Temporary adapters owned by this group (`DeviceV1JsonConverter`) are deleted and removed from the table in `README.md`. -- [ ] `user_mapper.dart` maps onto the restructured `User`, and its `TODO(openapi-migration)` note is +- [ ] `users_mapper.dart` maps onto the restructured `User`, and its `TODO(openapi-migration)` note is gone. - [ ] A user mapped from `UserResponse` and the same user decoded from v1 / WebSocket JSON compare equal. diff --git a/openapi-migration/10-messages.md b/openapi-migration/10-messages.md index 9802abb5de..307f4d3880 100644 --- a/openapi-migration/10-messages.md +++ b/openapi-migration/10-messages.md @@ -96,11 +96,11 @@ an endpoint, so the definition of done below stays open. keeps v10's request shape: coordinates, device and end date), `ChannelState.activeLiveLocations`, `GetActiveLiveLocationsResponse` and `updateLiveLocation`'s response. `stream_chat_persistence` stores locations as table rows, so no codec is needed. -- **`message_mapper.dart` maps `MessageResponse` onto today's json_serializable `Message`** through +- **`messages_mapper.dart` maps `MessageResponse` onto today's json_serializable `Message`** through its constructor, with the generated attachment, reaction, reaction group, moderation, shared location, reminder, draft and draft payload types, each mapped in - its own file (`attachment_mapper.dart`, `reaction_mapper.dart`, `moderation_mapper.dart`, - `location_mapper.dart`, `drafts_mapper.dart`, `reminders_mapper.dart`). Its first + its own file (`attachments_mapper.dart`, `reactions_mapper.dart`, `locations_mapper.dart`, + `drafts_mapper.dart`, `reminders_mapper.dart`), the moderation beside the message itself. Its first consumer is [06](06-reminders.md). `Message.fromJson` and the mapper share the type and state derivation and the reaction groups built from counts and scores when a payload has none (`lib/src/core/util/message_decoding.dart`, internal). @@ -129,6 +129,7 @@ an endpoint, so the definition of done below stays open. - `message_api.dart` also holds the four draft methods, which belong to group 07 — leave them alone here. - Attachment `custom`/`extraData` promotion is the known hard part of the whole migration. +- Requests that send custom data drop the keys named like one of the model's own fields, as every request mapper does until [21](21-custom-data-rename.md) revisits it. - Message send has offline and retry paths through `stream_chat_persistence` that must keep working. - `MessageDeleteScope` has to be reconciled with `DeleteType`, which [08](08-moderation-and-blocklists.md) added. It is named for the scope of a delete — `deleteForMe` vs `deleteForAll` — but carries a `hard` bool, which is the same axis `DeleteType` models, in the same words, minus `pruning`. `deleteMessage(hard: true)` therefore cannot express a pruning delete at all, and `softDeleteForAll` / `hardDeleteForAll` read as two spellings of `DeleteType.soft` / `DeleteType.hard`. Decide whether the scope keeps a `DeleteType` field or the two stay separate arguments; either way the public type changes, so it belongs in this group rather than a later fix. diff --git a/openapi-migration/11-channels-and-members.md b/openapi-migration/11-channels-and-members.md index d83dd13acc..f8ccd654ce 100644 --- a/openapi-migration/11-channels-and-members.md +++ b/openapi-migration/11-channels-and-members.md @@ -60,7 +60,7 @@ - **The generated channel and member types map onto today's classes.** `lib/src/repository/mapper/channel_mapper.dart` maps `ChannelResponse`, `ChannelConfigWithInfo`, `ChannelMemberResponse`, `Command` and `ChatPreferences` onto the json_serializable `ChannelModel`, - `ChannelConfig`, `Member`, `Command` and `ChatPreferences`, the way `user_mapper.dart` does for + `ChannelConfig`, `Member`, `Command` and `ChatPreferences`, the way `users_mapper.dart` does for `User`. [15](15-partial-updates.md) uses it first; groups 06, 07 and 14 will reuse it. This group restructures those classes and re-points the mapper. - **Every field `ChannelResponse` and `ChannelMemberResponse` declare is reachable on ours.** The @@ -75,10 +75,10 @@ `createdAt` / `updatedAt`, which ours do not model. - **The mapper writes the server fields where v1 JSON puts them,** into `extraData` under their wire keys. Custom data named like one of those fields or a top-level field is dropped, as - `user_mapper.dart` does. A mapped channel or member still differs from a v1 decode in three ways, + `users_mapper.dart` does. A mapped channel or member still differs from a v1 decode in three ways, all for group 09 or this group to settle: dates in `extraData` are formatted by the client rather than kept as the server sent them (see the risk below); `truncatedBy`, `createdBy` and each member's - user go through `user_mapper.dart`, which drops the user fields `User` does not model; and + user go through `users_mapper.dart`, which drops the user fields `User` does not model; and `mute_expires_at` and `hide_messages_before` are left out, where v1 would keep them in `extraData` if the server sent them on the channel. - **The mapper is tested through the client,** by [15](15-partial-updates.md)'s @@ -89,9 +89,10 @@ - `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. +- Requests that send custom data drop the keys named like one of the model's own fields, as every request mapper does until [21](21-custom-data-rename.md) revisits it. - **A zero timestamp decodes differently on v2.** v1 sends Go's zero time as `0001-01-01T00:00:00Z`; v2 encodes timestamps as epoch nanoseconds and sends `0`, which decodes as 1970-01-01. A truncated channel shows it: its `last_message_at` is zero time, so the same channel answers year 1 from a v1 query or WebSocket event and 1970 from `/api/v2/chat/channels`. Nothing in the SDK behaves differently today, since both precede `createdAt` (`lastUpdatedAt` falls back to it, and the read fallback and the `max` against new messages agree), but the two sources disagree on the value. Normalize it deliberately when `queryChannels` moves, and check the other timestamps the server can send as zero. - **Dates the mapper writes into `extraData` are formatted differently from v1.** v1 keeps the server's string (`2020-01-29T03:22:47.63613Z`); the mapper writes `toIso8601String()` (`2020-01-29T03:22:47.636130Z`). The getters return the same `DateTime`, but `Member` compares `extraData`, so the same member from a v1 payload and from the mapper compares unequal. Promoting the fields removes it. -- [08](08-moderation-and-blocklists.md)'s `muteChannel` drops `channelMute`, `channelMutes` and `ownUser` because the generated `ChannelMute` carries a `ChannelResponse?` and a `UserResponse?` where ours needs a non-nullable `ChannelModel` and `User`. `channel_mapper.dart` covers the channel side now; it needs 09 as well. Decide the null case there too — ours are non-nullable, the generated ones are not, the same question [14](14-banned-users.md) records for `BanResponse.user`. +- [08](08-moderation-and-blocklists.md)'s `muteChannel` drops `channelMute`, `channelMutes` and `ownUser` because the generated `ChannelMute` carries a `ChannelResponse?` and a `UserResponse?` where ours needs a non-nullable `ChannelModel` and `User`. `channels_mapper.dart` covers the channel side now; it needs 09 as well. Decide the null case there too — ours are non-nullable, the generated ones are not, the same question [14](14-banned-users.md) records for `BanResponse.user`. ## Definition of done diff --git a/openapi-migration/15-partial-updates.md b/openapi-migration/15-partial-updates.md index bd50877d33..279fa34f94 100644 --- a/openapi-migration/15-partial-updates.md +++ b/openapi-migration/15-partial-updates.md @@ -25,7 +25,7 @@ - **Split out of [11](11-channels-and-members.md), ahead of it.** The partial updates read nothing into client state — the `channel.updated` and `member.updated` events do that — so they can move - before the queries and prove `channel_mapper.dart` on real calls. + before the queries and prove `channels_mapper.dart` on real calls. - **The full update stays in 11, so the pair is split on purpose.** `updateChannel` takes and answers a `Message`, and the generated `UpdateChannelRequest.message` / `UpdateChannelResponse.message` need the `MessageRequest` and `MessageResponse` mappers [10](10-messages.md) writes; its `data` is diff --git a/openapi-migration/16-channel-lifecycle.md b/openapi-migration/16-channel-lifecycle.md index b15c5ff40b..2398840213 100644 --- a/openapi-migration/16-channel-lifecycle.md +++ b/openapi-migration/16-channel-lifecycle.md @@ -26,7 +26,7 @@ - **Split out of [11](11-channels-and-members.md), ahead of it.** None of the three reads anything into client state — the `channel.hidden`, `channel.visible` and `channel.deleted` events do that — - and the one channel they answer goes through `channel_mapper.dart`. + and the one channel they answer goes through `channels_mapper.dart`. - **The v2 routes are the v1 handlers.** `lib/chat/routes.go` mounts hide, show and delete in the shared `coreRoutes`; none is gated, in beta or deprecated. - **Moved off `ChannelApi`:** `hideChannel`, `showChannel` and `deleteChannel`, each now a diff --git a/openapi-migration/19-user-blocking.md b/openapi-migration/19-user-blocking.md index b86badf2fe..1448edaa1f 100644 --- a/openapi-migration/19-user-blocking.md +++ b/openapi-migration/19-user-blocking.md @@ -1,6 +1,6 @@ # 19 — User Blocking -**Goal:** Move blocking and unblocking a user, and listing the blocked users, ahead of [09](09-users.md): they embed `User` only through the existing `user_mapper.dart`, and nothing persists them. +**Goal:** Move blocking and unblocking a user, and listing the blocked users, ahead of [09](09-users.md): they embed `User` only through the existing `users_mapper.dart`, and nothing persists them. **Size:** 0 hand-written method(s) across 0 file(s) → 3 generated operation(s). diff --git a/openapi-migration/21-custom-data-rename.md b/openapi-migration/21-custom-data-rename.md index 4e65cf4de4..32cff1e35c 100644 --- a/openapi-migration/21-custom-data-rename.md +++ b/openapi-migration/21-custom-data-rename.md @@ -23,6 +23,16 @@ - **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. +- **How a request treats custom data named like one of the model's own fields.** Every request mapper drops it today, for consistency, not because it matches v10. v10 flattened `extraData` into the body after the fields, so a key named like a field set that field, failed with a 400 for a wrong type or a reserved name, was dropped, or was stored as custom data, depending on the key and the endpoint. v2 never rejects it: it stores it as custom data, drops it, or lets it override the field (a draft's `id`, `text`, `type`, `html` and `mml`, an attachment's single-word string fields, a user's `image`). Measured live on drafts, attachments, polls, poll options and users. The options: + 1. Drop it (today). Simple, but silently loses what v10 stored as custom data or used to set a field. + 2. Send everything as custom data. Nothing is dropped, but a key v10 set a field with becomes custom data, and v2 overrides some fields with it. + 3. Reproduce v10: a key named like a request field sets that field, over the model's value; the rest is sent as custom data. + 4. Like 3, but the model's value wins: the key only fills a field the model leaves unset. + 5. Drop it, and log the dropped keys in debug builds. + 6. Assert in debug builds that `extraData` holds no key named like a field. + 7. Keep such keys out of `extraData` when a model is built, so no mapper sees them. + 8. Ask the backend to reject or strip such custom keys consistently; v2 overriding a field with custom data looks like a server bug. + 9. Settle it as part of this rename's break. ## Risks @@ -31,6 +41,9 @@ ## Definition of done +- [ ] Re-run the live probe of custom data named like a model field for every request that sends custom + data: each key flattened over v1 and nested in `custom` over v2, comparing what the server stores. +- [ ] One rule for custom data named like a model field, applied by every request mapper. - [ ] 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. diff --git a/openapi-migration/README.md b/openapi-migration/README.md index 649d3ca83e..b53a732f26 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -165,9 +165,9 @@ includes a named response that carries only `duration` today, such as `HideChann | `DataSerializable` | `UserGroup`, `UserGroupMember`, `ReactionGroup`, `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) | -| `channel_mapper.dart` (kept, re-pointed) | today's `ChannelModel`, `ChannelConfig` and `Member`, which still read and write JSON | [11](11-channels-and-members.md) | -| `message_mapper.dart`, `attachment_mapper.dart`, `reaction_mapper.dart`, `location_mapper.dart`, `drafts_mapper.dart` (kept, re-pointed) | today's `Message`, `Attachment`, `Reaction`, `Location`, `Draft` and `DraftMessage`, which still read and write JSON | [10](10-messages.md) | +| `users_mapper.dart` (kept, re-pointed) | today's `User`, which still reads and writes JSON | [09](09-users.md) | +| `channels_mapper.dart` (kept, re-pointed) | today's `ChannelModel`, `ChannelConfig` and `Member`, which still read and write JSON | [11](11-channels-and-members.md) | +| `messages_mapper.dart`, `attachments_mapper.dart`, `reactions_mapper.dart`, `locations_mapper.dart`, `drafts_mapper.dart` (kept, re-pointed) | today's `Message`, `Attachment`, `Reaction`, `Location`, `Draft` and `DraftMessage`, which still read and write JSON | [10](10-messages.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`. @@ -187,10 +187,10 @@ surfaces before it reaches `Message` and `ChannelState`: - **14** is `queryBannedUsers`, split out of 08 because it is the only moderation call that answers with a model. The `User` mappers it needs landed with group 04. - **10–11** are the core of the SDK, and carry the `custom` / `extraData` promotion problem. -- **15** is the partial channel and member updates, split out of 11 so `channel_mapper.dart` lands on calls that +- **15** is the partial channel and member updates, split out of 11 so `channels_mapper.dart` lands on calls that read nothing into `ChannelState`. It landed first; the full update waits in 11 for group 10's message mappers. - **16** is hiding, showing and deleting a channel, split out of 11 after 15 because they answer nothing - `channel_mapper.dart` cannot map. + `channels_mapper.dart` cannot map. - **17** is the read and delivery receipts, split out of 11 after 16. They answer only a `duration` and a read event, which waits for group 10's message mappers. - **18** is the current user's unread counts, split out of 09 because it embeds no `User` and nothing persists it. @@ -201,7 +201,7 @@ surfaces before it reaches `Message` and `ChannelState`: `User` as getters over `extraData`. - **12** comes late because it needs its own hand-written multipart client and is the highest-traffic path in the SDK. -- **09** is last. Every group before it maps users through `user_mapper.dart` onto today's `User`; 09 migrates the +- **09** is last. Every group before it maps users through `users_mapper.dart` onto today's `User`; 09 migrates the user endpoints and restructures `User` and `OwnUser` themselves, when every parent that embeds them has moved. - **21** renames `extraData` to `custom` on every model at once. The groups keep `extraData`, so the SDK stays consistent until then; it runs after 09. diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 90351727fc..89f6dec520 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -313,7 +313,7 @@ def match(verb, path): non-date value). v10's flat response shadowed them, so the same guest connected; confirmed live. - **Not a regression: a custom `ban_expires` that isn't a date** fails the connect in v10 and now alike, because the socket's `me` lets it through into `OwnUser.fromJson`. - - **`User` is not restructured here.** `user_mapper.dart` maps the generated types onto today's `User`, + - **`User` is not restructured here.** `users_mapper.dart` maps the generated types onto today's `User`, following [01-foundation](01-foundation.md). """), done=DONE.replace('- [ ]', '- [x]'), @@ -600,7 +600,7 @@ def match(verb, path): '[19](19-user-blocking.md) blocking users, and [20](20-user-updates.md) updating users.', decisions=[ '`User` and `OwnUser` are public, persisted, and embedded in nearly every other response. This group ' - 'restructures them, last: the mappers in `user_mapper.dart` already map the generated types onto the ' + 'restructures them, last: the mappers in `users_mapper.dart` already map the generated types onto the ' 'current class for every group before it, and stay. See [01-foundation](01-foundation.md).', '`PrivacySettings` and the push-preference sub-shapes — decide per type.', '`UserResponse.toModel()` and `FullUserResponse.toModel()` drop custom data named like one of the user\'s ' @@ -651,7 +651,7 @@ def match(verb, path): done=DONE + ( '- [ ] Temporary adapters owned by this group (`DeviceV1JsonConverter`) are deleted and removed from\n' ' the table in `README.md`.\n' - '- [ ] `user_mapper.dart` maps onto the restructured `User`, and its `TODO(openapi-migration)` note is\n' + '- [ ] `users_mapper.dart` maps onto the restructured `User`, and its `TODO(openapi-migration)` note is\n' ' gone.\n' '- [ ] A user mapped from `UserResponse` and the same user decoded from v1 / WebSocket JSON compare equal.\n' ), @@ -678,6 +678,8 @@ def match(verb, path): '`message_api.dart` also holds the four draft methods, which belong to group 07 — leave them alone ' 'here.', 'Attachment `custom`/`extraData` promotion is the known hard part of the whole migration.', + 'Requests that send custom data drop the keys named like one of the model\'s own fields, as every ' + 'request mapper does until [21](21-custom-data-rename.md) revisits it.', 'Message send has offline and retry paths through `stream_chat_persistence` that must keep working.', '`MessageDeleteScope` has to be reconciled with `DeleteType`, which [08](08-moderation-and-blocklists.md) added. It is named for the scope of a delete — `deleteForMe` vs `deleteForAll` — but carries a `hard` bool, which is the same axis `DeleteType` models, in the same words, minus `pruning`. `deleteMessage(hard: true)` therefore cannot express a pruning delete at all, and `softDeleteForAll` / `hardDeleteForAll` read as two spellings of `DeleteType.soft` / `DeleteType.hard`. Decide whether the scope keeps a `DeleteType` field or the two stay separate arguments; either way the public type changes, so it belongs in this group rather than a later fix.', ], @@ -724,11 +726,11 @@ def match(verb, path): keeps v10's request shape: coordinates, device and end date), `ChannelState.activeLiveLocations`, `GetActiveLiveLocationsResponse` and `updateLiveLocation`'s response. `stream_chat_persistence` stores locations as table rows, so no codec is needed. - - **`message_mapper.dart` maps `MessageResponse` onto today's json_serializable `Message`** through + - **`messages_mapper.dart` maps `MessageResponse` onto today's json_serializable `Message`** through its constructor, with the generated attachment, reaction, reaction group, moderation, shared location, reminder, draft and draft payload types, each mapped in - its own file (`attachment_mapper.dart`, `reaction_mapper.dart`, `moderation_mapper.dart`, - `location_mapper.dart`, `drafts_mapper.dart`, `reminders_mapper.dart`). Its first + its own file (`attachments_mapper.dart`, `reactions_mapper.dart`, `locations_mapper.dart`, + `drafts_mapper.dart`, `reminders_mapper.dart`), the moderation beside the message itself. Its first consumer is [06](06-reminders.md). `Message.fromJson` and the mapper share the type and state derivation and the reaction groups built from counts and scores when a payload has none (`lib/src/core/util/message_decoding.dart`, internal). @@ -811,6 +813,8 @@ def match(verb, path): '`queryChannels` drives the channel list controllers and the offline cache; a shape change here is ' 'felt everywhere.', 'Channel `custom`/`extraData` promotion, same class of problem as messages.', + 'Requests that send custom data drop the keys named like one of the model\'s own fields, as every ' + 'request mapper does until [21](21-custom-data-rename.md) revisits it.', '**A zero timestamp decodes differently on v2.** v1 sends Go\'s zero time as ' '`0001-01-01T00:00:00Z`; v2 encodes timestamps as epoch nanoseconds and sends `0`, which decodes as ' '1970-01-01. A truncated channel shows it: its `last_message_at` is zero time, so the same channel ' @@ -826,7 +830,7 @@ def match(verb, path): 'the fields removes it.', '[08](08-moderation-and-blocklists.md)\'s `muteChannel` drops `channelMute`, `channelMutes` and ' '`ownUser` because the generated `ChannelMute` carries a `ChannelResponse?` and a `UserResponse?` ' - 'where ours needs a non-nullable `ChannelModel` and `User`. `channel_mapper.dart` covers the ' + 'where ours needs a non-nullable `ChannelModel` and `User`. `channels_mapper.dart` covers the ' 'channel side now; it needs 09 as well. ' 'Decide the null case there too — ours are non-nullable, the generated ones are not, the same ' 'question [14](14-banned-users.md) records for `BanResponse.user`.', @@ -835,7 +839,7 @@ def match(verb, path): - **The generated channel and member types map onto today's classes.** `lib/src/repository/mapper/channel_mapper.dart` maps `ChannelResponse`, `ChannelConfigWithInfo`, `ChannelMemberResponse`, `Command` and `ChatPreferences` onto the json_serializable `ChannelModel`, - `ChannelConfig`, `Member`, `Command` and `ChatPreferences`, the way `user_mapper.dart` does for + `ChannelConfig`, `Member`, `Command` and `ChatPreferences`, the way `users_mapper.dart` does for `User`. [15](15-partial-updates.md) uses it first; groups 06, 07 and 14 will reuse it. This group restructures those classes and re-points the mapper. - **Every field `ChannelResponse` and `ChannelMemberResponse` declare is reachable on ours.** The @@ -850,10 +854,10 @@ def match(verb, path): `createdAt` / `updatedAt`, which ours do not model. - **The mapper writes the server fields where v1 JSON puts them,** into `extraData` under their wire keys. Custom data named like one of those fields or a top-level field is dropped, as - `user_mapper.dart` does. A mapped channel or member still differs from a v1 decode in three ways, + `users_mapper.dart` does. A mapped channel or member still differs from a v1 decode in three ways, all for group 09 or this group to settle: dates in `extraData` are formatted by the client rather than kept as the server sent them (see the risk below); `truncatedBy`, `createdBy` and each member's - user go through `user_mapper.dart`, which drops the user fields `User` does not model; and + user go through `users_mapper.dart`, which drops the user fields `User` does not model; and `mute_expires_at` and `hide_messages_before` are left out, where v1 would keep them in `extraData` if the server sent them on the channel. - **The mapper is tested through the client,** by [15](15-partial-updates.md)'s @@ -971,7 +975,7 @@ def match(verb, path): taken=textwrap.dedent("""\ - **Split out of [11](11-channels-and-members.md), ahead of it.** The partial updates read nothing into client state — the `channel.updated` and `member.updated` events do that — so they can move - before the queries and prove `channel_mapper.dart` on real calls. + before the queries and prove `channels_mapper.dart` on real calls. - **The full update stays in 11, so the pair is split on purpose.** `updateChannel` takes and answers a `Message`, and the generated `UpdateChannelRequest.message` / `UpdateChannelResponse.message` need the `MessageRequest` and `MessageResponse` mappers [10](10-messages.md) writes; its `data` is @@ -1029,7 +1033,7 @@ def match(verb, path): taken=textwrap.dedent("""\ - **Split out of [11](11-channels-and-members.md), ahead of it.** None of the three reads anything into client state — the `channel.hidden`, `channel.visible` and `channel.deleted` events do that — - and the one channel they answer goes through `channel_mapper.dart`. + and the one channel they answer goes through `channels_mapper.dart`. - **The v2 routes are the v1 handlers.** `lib/chat/routes.go` mounts hide, show and delete in the shared `coreRoutes`; none is gated, in beta or deprecated. - **Moved off `ChannelApi`:** `hideChannel`, `showChannel` and `deleteChannel`, each now a @@ -1122,7 +1126,7 @@ def match(verb, path): hand=[], match=only_ops('POST /api/v2/users/block', 'GET /api/v2/users/block', 'POST /api/v2/users/unblock'), goal='Move blocking and unblocking a user, and listing the blocked users, ahead of [09](09-users.md): they ' - 'embed `User` only through the existing `user_mapper.dart`, and nothing persists them.', + 'embed `User` only through the existing `users_mapper.dart`, and nothing persists them.', decisions=[], taken=textwrap.dedent("""\ - **Split out of [09](09-users.md), ahead of it, as one slice.** Blocking and unblocking are a pair, @@ -1201,8 +1205,30 @@ def match(verb, path): '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.', + '**How a request treats custom data named like one of the model\'s own fields.** Every request mapper ' + 'drops it today, for consistency, not because it matches v10. v10 flattened `extraData` into the body ' + 'after the fields, so a key named like a field set that field, failed with a 400 for a wrong type or a ' + 'reserved name, was dropped, or was stored as custom data, depending on the key and the endpoint. v2 ' + 'never rejects it: it stores it as custom data, drops it, or lets it override the field (a draft\'s ' + '`id`, `text`, `type`, `html` and `mml`, an attachment\'s single-word string fields, a user\'s `image`). ' + 'Measured live on drafts, attachments, polls, poll options and users. The options:\n' + ' 1. Drop it (today). Simple, but silently loses what v10 stored as custom data or used to set a field.\n' + ' 2. Send everything as custom data. Nothing is dropped, but a key v10 set a field with becomes custom ' + 'data, and v2 overrides some fields with it.\n' + ' 3. Reproduce v10: a key named like a request field sets that field, over the model\'s value; the rest ' + 'is sent as custom data.\n' + ' 4. Like 3, but the model\'s value wins: the key only fills a field the model leaves unset.\n' + ' 5. Drop it, and log the dropped keys in debug builds.\n' + ' 6. Assert in debug builds that `extraData` holds no key named like a field.\n' + ' 7. Keep such keys out of `extraData` when a model is built, so no mapper sees them.\n' + ' 8. Ask the backend to reject or strip such custom keys consistently; v2 overriding a field with ' + 'custom data looks like a server bug.\n' + ' 9. Settle it as part of this rename\'s break.', ], done=textwrap.dedent("""\ + - [ ] Re-run the live probe of custom data named like a model field for every request that sends custom + data: each key flattened over v1 and nested in `custom` over v2, comparing what the server stores. + - [ ] One rule for custom data named like a model field, applied by every request mapper. - [ ] 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. diff --git a/packages/stream_chat/lib/src/repository/mapper/attachment_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/attachments_mapper.dart similarity index 100% rename from packages/stream_chat/lib/src/repository/mapper/attachment_mapper.dart rename to packages/stream_chat/lib/src/repository/mapper/attachments_mapper.dart diff --git a/packages/stream_chat/lib/src/repository/mapper/drafts_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/drafts_mapper.dart index 93bbf4d33e..79afbc1ff2 100644 --- a/packages/stream_chat/lib/src/repository/mapper/drafts_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/drafts_mapper.dart @@ -2,9 +2,9 @@ import '../../../open_api/api.dart' as api; import '../../core/models/draft.dart'; import '../../core/models/draft_message.dart'; import '../../core/models/message.dart'; -import 'attachment_mapper.dart'; +import 'attachments_mapper.dart'; import 'channels_mapper.dart'; -import 'message_mapper.dart'; +import 'messages_mapper.dart'; import 'users_mapper.dart'; // TODO(openapi-migration): re-point these mappers in group 10. diff --git a/packages/stream_chat/lib/src/repository/mapper/location_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/locations_mapper.dart similarity index 95% rename from packages/stream_chat/lib/src/repository/mapper/location_mapper.dart rename to packages/stream_chat/lib/src/repository/mapper/locations_mapper.dart index f2e1764708..2cf708ad38 100644 --- a/packages/stream_chat/lib/src/repository/mapper/location_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/locations_mapper.dart @@ -1,7 +1,7 @@ import '../../../open_api/api.dart' as api; import '../../core/models/location.dart'; import 'channels_mapper.dart'; -import 'message_mapper.dart'; +import 'messages_mapper.dart'; // TODO(openapi-migration): re-point this mapper in group 10. diff --git a/packages/stream_chat/lib/src/repository/mapper/message_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/messages_mapper.dart similarity index 82% rename from packages/stream_chat/lib/src/repository/mapper/message_mapper.dart rename to packages/stream_chat/lib/src/repository/mapper/messages_mapper.dart index 92c8eeb096..811d4f2054 100644 --- a/packages/stream_chat/lib/src/repository/mapper/message_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/messages_mapper.dart @@ -1,12 +1,12 @@ import '../../../open_api/api.dart' as api; import '../../core/models/message.dart'; +import '../../core/models/moderation.dart'; import '../../core/util/message_decoding.dart'; -import 'attachment_mapper.dart'; +import 'attachments_mapper.dart'; import 'drafts_mapper.dart'; -import 'location_mapper.dart'; -import 'moderation_mapper.dart'; +import 'locations_mapper.dart'; import 'polls_mapper.dart'; -import 'reaction_mapper.dart'; +import 'reactions_mapper.dart'; import 'reminders_mapper.dart'; import 'user_groups_mapper.dart'; import 'users_mapper.dart'; @@ -87,3 +87,17 @@ extension MessageResponseMapper on api.MessageResponse { deletedReplyCount: deletedReplyCount, ).withDerivedState(); } + +/// Maps a generated [api.ModerationV2Response] to a [Moderation]. +extension ModerationV2ResponseMapper on api.ModerationV2Response { + /// Converts this response into a [Moderation]. + Moderation toModel() => Moderation( + action: ModerationAction.fromJson(action), + originalText: originalText, + textHarms: textHarms, + imageHarms: imageHarms, + blocklistMatched: blocklistMatched, + semanticFilterMatched: semanticFilterMatched, + platformCircumvented: platformCircumvented ?? false, + ); +} diff --git a/packages/stream_chat/lib/src/repository/mapper/moderation_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/moderation_mapper.dart index f5180cae26..fd756d53a0 100644 --- a/packages/stream_chat/lib/src/repository/mapper/moderation_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/moderation_mapper.dart @@ -1,6 +1,5 @@ import '../../../open_api/api.dart' as api; import '../../core/models/delete_type.dart'; -import '../../core/models/moderation.dart'; import '../../core/models/response/flag_response.dart'; import '../../core/models/response/mute_users_response.dart'; import '../../core/models/response/unmute_users_response.dart'; @@ -37,19 +36,3 @@ extension DeleteTypeRequestMapper on DeleteType { /// Converts this delete type into an [api.BanRequestDeleteMessages]. api.BanRequestDeleteMessages toRequest() => api.BanRequestDeleteMessages.fromJson(rawType); } - -// TODO(openapi-migration): re-point this mapper in group 10. - -/// Maps a generated [api.ModerationV2Response] to a [Moderation]. -extension ModerationV2ResponseMapper on api.ModerationV2Response { - /// Converts this response into a [Moderation]. - Moderation toModel() => Moderation( - action: ModerationAction.fromJson(action), - originalText: originalText, - textHarms: textHarms, - imageHarms: imageHarms, - blocklistMatched: blocklistMatched, - semanticFilterMatched: semanticFilterMatched, - platformCircumvented: platformCircumvented ?? false, - ); -} diff --git a/packages/stream_chat/lib/src/repository/mapper/reaction_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/reactions_mapper.dart similarity index 84% rename from packages/stream_chat/lib/src/repository/mapper/reaction_mapper.dart rename to packages/stream_chat/lib/src/repository/mapper/reactions_mapper.dart index 19c4e8ba2b..d70c70da75 100644 --- a/packages/stream_chat/lib/src/repository/mapper/reaction_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/reactions_mapper.dart @@ -8,6 +8,9 @@ import 'users_mapper.dart'; /// Maps a generated [api.ReactionResponse] to a [Reaction]. extension ReactionResponseMapper on api.ReactionResponse { + // Custom keys named like one of the reaction's own fields. + static const _shadowedCustomKeys = {...Reaction.topLevelFields}; + /// Converts this response into a [Reaction]. /// /// Custom data named like one of the reaction's own fields is left out of [Reaction.extraData]. @@ -20,7 +23,7 @@ extension ReactionResponseMapper on api.ReactionResponse { emojiCode: custom['emoji_code'].safeCast(), createdAt: createdAt, updatedAt: updatedAt, - extraData: {...custom}..removeWhere((key, _) => Reaction.topLevelFields.contains(key)), + extraData: {...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), ); } diff --git a/packages/stream_chat/lib/src/repository/mapper/reminders_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/reminders_mapper.dart index 2d5e51126f..518e0825f0 100644 --- a/packages/stream_chat/lib/src/repository/mapper/reminders_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/reminders_mapper.dart @@ -5,7 +5,7 @@ import '../../core/models/response/delete_reminder_response.dart'; import '../../core/models/response/query_reminders_response.dart'; import '../../core/models/response/update_reminder_response.dart'; import 'channels_mapper.dart'; -import 'message_mapper.dart'; +import 'messages_mapper.dart'; import 'users_mapper.dart'; /// Maps a generated [api.CreateReminderResponse] to a [CreateReminderResponse].