diff --git a/.claude/skills/openapi-migration/SKILL.md b/.claude/skills/openapi-migration/SKILL.md index e3ee0e7bad..dccd18b813 100644 --- a/.claude/skills/openapi-migration/SKILL.md +++ b/.claude/skills/openapi-migration/SKILL.md @@ -208,6 +208,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:** @@ -459,6 +462,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 @@ -490,6 +526,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/migrations/v11-migration.md b/migrations/v11-migration.md index 3750dd75c9..c682a34b85 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -30,7 +30,10 @@ onto Stream's OpenAPI-generated API client. - [Moderation](#moderation) - [App Settings](#app-settings) - [Guest Users](#guest-users) + - [Polls](#polls) + - [Message Reminders](#message-reminders) - [File Upload](#file-upload) + - [Messages](#messages) - [User Groups](#user-groups) - [Link Previews](#link-previews) - [Partial Updates](#partial-updates) @@ -83,12 +86,15 @@ 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 | +| [**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 | | [**Read Receipts**](#read-receipts) | Marking read, unread and delivered return a `Result` instead of throwing; `ChannelDeliveryReporter`'s callback returns a `Result` | | [**Unread Counts**](#unread-counts) | `getUnreadCount` returns a `Result` instead of throwing; the response and its `UnreadCounts*` models no longer decode JSON and compare by value | | [**User Blocking**](#user-blocking) | `blockUser`, `unblockUser` and `getBlockedUsers` (was `queryBlockedUsers`) return a `Result` instead of throwing; their responses are renamed `BlockUsersResponse` and `GetBlockedUsersResponse`, `unblockUser` answers a new `UnblockUsersResponse`, and `UserBlock`'s fields are all non-nullable | +| [**Messages**](#messages) | `Moderation`, `ReactionGroup`, `Action`, `Reaction` and `Location` no longer decode from or encode to JSON; `Action` compares by value | | [**User Updates**](#user-updates) | `updateUser` and `updateUsers` return a `Result` instead of throwing; `partialUpdateUser(s)` becomes `updateUserPartial` / `updateUsersPartial` and `PartialUpdateUserRequest` becomes `UpdateUserPartialRequest`; `updateUser` no longer sends `role`, `teams` or `teamsRole`, and the returned users no longer carry their private fields in `extraData` | | _(filled in per feature as PRs land)_ | | @@ -227,6 +233,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 | +| `VotingVisibility` (enum) | `VotingVisibility` (extension type over `String`) | `retyped` | `VotingVisibility.public` and `.anonymous` are unchanged; a `switch` needs a default arm, and `.name` / `.values` are gone — read `.rawType` | +| `Poll.fromJson` / `.toJson`, `PollOption.fromJson` / `.toJson`, `PollVote.fromJson` / `.toJson` | — | `removed` | The models are plain classes; construct them directly. `Message.poll` and the poll events still decode from the same keys | +| — | `PollOption.fromData` / `.toData` | `added` | Read and write only the format the offline database stores; not a codec for API payloads | +| `CreatePollResponse()..poll = …` and the other response setters | `PollResponse(duration: …, poll: …)` | `retyped` | The responses are plain classes with a const constructor and final fields | +| poll responses' `duration` (`String?`) | `String` | `retyped` | Always present; drop any `!` or `?? ''` | +| `Poll` / `PollOption` / `PollVote extends Equatable`, `props` | value `==` | `removed` | `Poll` and `PollOption` now compare `extraData` too | +| `PollVote.isAnswer` (a field set in the constructor) | the same name, as a getter | `retyped` | Computed from `answerText` on each read | +| `StreamChatApi.polls` (`PollsApi`) | `StreamChatClient`'s poll methods | `removed` | The endpoints moved to the generated client | | `sendImage` / `sendFile` / `uploadImage` / `uploadFile` → `Future` and siblings | `Future>` | `retyped` | On `StreamChatClient`, `Channel` and `AttachmentFileUploader`. Returns a `Result` instead of throwing | | `deleteImage` / `deleteFile` / `removeImage` / `removeFile` → `Future` | `Future>` | `retyped` | On `StreamChatClient`, `Channel` and `AttachmentFileUploader`. Returns a `Result` instead of throwing | | `SendAttachmentResponse`, `SendFileResponse`, `SendImageResponse`, `UploadImageResponse`, `UploadFileResponse` | `UploadedFile` (`stream_core`) | `removed` | `.file` becomes `.fileUrl`; `.thumbUrl` is unchanged; `duration` is gone | @@ -280,6 +302,16 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `UserBlock extends Equatable`, `UserBlock.props` | `UserBlock` (value `==`, `copyWith`) | `removed` | Equality is unchanged; `props` is gone and `UserBlock` is no longer an `Equatable` | | `UserBlock.blockedUser` (`User?`), `.userId` / `.blockedUserId` (`String?`), `.createdAt` (`DateTime?`) | `User`, `String`, `DateTime` — required in the constructor | `retyped` | The server always sends them; drop any `!`, `?.` or `?? …` | | `StreamChatApi.user.blockUser` / `unblockUser` / `queryBlockedUsers` | `StreamChatClient.blockUser` / `unblockUser` / `getBlockedUsers` | `removed` | The endpoints moved to the generated client | +| `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` | +| `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` | +| `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` | | `StreamChatClient.updateUser` / `updateUsers` → `Future` | `Future>` | `retyped` | Returns a `Result` instead of throwing | | `StreamChatClient.partialUpdateUser` | `StreamChatClient.updateUserPartial` | `renamed` | Same arguments | | `StreamChatClient.partialUpdateUser` → `Future` | `updateUserPartial` → `Future>` | `retyped` | Returns a `Result` instead of throwing | @@ -293,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)_ | | | | --- @@ -1080,6 +1123,123 @@ 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`. + +**`VotingVisibility` is an extension type over its wire string.** `VotingVisibility.public` and +`VotingVisibility.anonymous` read the same, and a visibility the SDK does not name is kept rather than rejected. +A `switch` over it needs a default arm, and `.name` / `.values` are gone — read `.rawType`. + +**`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 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. + +### 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 @@ -1120,6 +1280,33 @@ StreamChatClient(apiKey, attachmentFileUploaderProvider: (httpClient) => MyUploa StreamChatClient(apiKey, attachmentFileUploaderProvider: (dio) => MyUploader(dio)); ``` +### Messages + +**`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. + +**`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. + +**`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 +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 @@ -1134,7 +1321,7 @@ Work top to bottom; each item is independently verifiable. - [ ] Replace `ChatErrorCode` comparisons with `StreamErrorCode` constants - [ ] Apply every row of the [Symbol Map](#symbol-map) - [ ] Re-check custom data access: fields that used to arrive in `extraData` may now be typed properties -- [ ] If you implement `AttachmentFileUploader`, return a `Result` from it — see [File Upload](#file-upload) +- [ ] If you implement `AttachmentFileUploader`, review its section under [Feature Areas](#feature-areas) - [ ] If you persist models yourself, re-check nullability as endpoints move to the generated types, which are nullable wherever the API allows it - [ ] Replace `sort: null` on any list controller with `XSort.empty` if you relied on server ordering — see [Sorting](#sorting) diff --git a/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 e8df714c1d..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 @@ -113,6 +113,8 @@ hand-written, with the reason. - [x] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [x] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [x] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels + included ([README rule 2](README.md#domain-models)). - [x] `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 39991be852..11b0d28653 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,62 @@ | `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. + 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`. +- **`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 21](21-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 +97,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. -- [ ] `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] 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. -- [ ] `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/06-reminders.md b/openapi-migration/06-reminders.md index f2ccb1b74e..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,17 +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. -- [ ] `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] 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. -- [ ] `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/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 6703cfcff6..a786359c14 100644 --- a/openapi-migration/09-users.md +++ b/openapi-migration/09-users.md @@ -27,9 +27,10 @@ ## 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. - **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,6 +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 `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 @@ -47,6 +49,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. @@ -59,5 +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 e7912f4088..307f4d3880 100644 --- a/openapi-migration/10-messages.md +++ b/openapi-migration/10-messages.md @@ -49,14 +49,87 @@ - `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. -- `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. +- 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. - Expose `MarkReadResponse.event`. [17](17-read-receipts.md) dropped it because its thread carries a `MessageResponse`; the mapper marks it with a TODO. +## 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. +- **`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. +- **`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. +- **`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. +- **`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. +- **`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 (`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). +- **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. 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. + ## Risks - `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. @@ -66,6 +139,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 65ce297757..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 @@ -99,6 +100,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 3b5c2b0fa9..3c21aa4875 100644 --- a/openapi-migration/12-uploads-cdn.md +++ b/openapi-migration/12-uploads-cdn.md @@ -74,6 +74,8 @@ hand-written, with the reason. - [x] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [x] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [x] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels + included ([README rule 2](README.md#domain-models)). - [x] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [x] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. diff --git a/openapi-migration/13-push-preferences.md b/openapi-migration/13-push-preferences.md index 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/15-partial-updates.md b/openapi-migration/15-partial-updates.md index 4743982d6b..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 @@ -74,6 +74,8 @@ hand-written, with the reason. - [x] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [x] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [x] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels + included ([README rule 2](README.md#domain-models)). - [x] `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/16-channel-lifecycle.md b/openapi-migration/16-channel-lifecycle.md index 1090ebf40e..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 @@ -52,6 +52,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/17-read-receipts.md b/openapi-migration/17-read-receipts.md index 152c7b6298..446dcd372c 100644 --- a/openapi-migration/17-read-receipts.md +++ b/openapi-migration/17-read-receipts.md @@ -61,6 +61,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/18-unread-counts.md b/openapi-migration/18-unread-counts.md index 7b3298f10e..89671afc84 100644 --- a/openapi-migration/18-unread-counts.md +++ b/openapi-migration/18-unread-counts.md @@ -46,6 +46,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/19-user-blocking.md b/openapi-migration/19-user-blocking.md index 4249adadbe..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). @@ -55,6 +55,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/20-user-updates.md b/openapi-migration/20-user-updates.md index 0cce36a308..b65d9780c1 100644 --- a/openapi-migration/20-user-updates.md +++ b/openapi-migration/20-user-updates.md @@ -62,6 +62,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/21-custom-data-rename.md b/openapi-migration/21-custom-data-rename.md new file mode 100644 index 0000000000..32cff1e35c --- /dev/null +++ b/openapi-migration/21-custom-data-rename.md @@ -0,0 +1,51 @@ +# 21 — `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. +- **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 + +- `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 + +- [ ] 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. +- [ ] 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 2e2d2ac530..b53a732f26 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -16,8 +16,8 @@ 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 | ☐ | -| [06](06-reminders.md) | Message Reminders | 4 | 4 | ☐ | +| [05](05-polls.md) | Polls | 0 | 13 | ☑ | +| [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 | ☐ | @@ -32,8 +32,9 @@ generated operations in scope, the decisions that group has to make, its risks, | [18](18-unread-counts.md) | Unread Counts — split out of 09 | 0 | 1 | ☑ | | [19](19-user-blocking.md) | User Blocking — split out of 09 | 0 | 3 | ☑ | | [20](20-user-updates.md) | User Updates — split out of 09 | 0 | 2 | ☑ | +| [21](21-custom-data-rename.md) | `extraData` → `custom`, every model at once | — | — | ☐ | -**Coverage:** 64 hand-written methods across 10 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). @@ -105,6 +106,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 follow the spec's, unless the spec's name is awkward or describes something else,** and calls the caller sees as siblings share one scheme: `updateChannelPartial` and `updateMemberPartial` answer `UpdateChannelPartialResponse` and `UpdateMemberPartialResponse`, where v10 had `PartialUpdateChannelResponse`, @@ -151,9 +156,18 @@ 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) | -| `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) | +| `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) | +| `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) | +| `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`. @@ -173,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. @@ -187,8 +201,10 @@ 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. ## Prerequisites diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 36c45449a9..89f6dec520 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -91,6 +91,8 @@ def match(verb, 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. @@ -311,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]'), @@ -322,19 +324,67 @@ def match(verb, 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. + 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`. + - **`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 21](21-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.', @@ -347,13 +397,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( @@ -523,13 +600,21 @@ 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 ' '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 ' @@ -556,12 +641,19 @@ def match(verb, 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 `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' ' 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' ), ), dict( @@ -576,12 +668,9 @@ def match(verb, 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 ' - '`Attachment.toJson` writes when sending and `toData` writes to persistence. It becomes a plain model ' - 'with `Attachment`; group 04 left it alone.', 'Expose `MarkReadResponse.event`. [17](17-read-receipts.md) dropped it because its thread carries a ' '`MessageResponse`; the mapper marks it with a TODO.', ], @@ -589,9 +678,83 @@ 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.', ], + 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. + - **`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. + - **`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. + - **`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. + - **`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. + - **`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 (`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). + - **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. 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. + """), ), dict( num='11', slug='channels-and-members', title='Channels, Members & Sync', @@ -650,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 ' @@ -665,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`.', @@ -674,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 @@ -689,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 @@ -810,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 @@ -868,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 @@ -961,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, @@ -1030,6 +1195,52 @@ def match(verb, path): risks=[], done=DONE.replace('- [ ]', '- [x]'), ), + dict( + num='21', 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.', + '**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. + - [ ] 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 af0b2e58f6..d644d01717 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -78,6 +78,16 @@ - `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` and `ConnectGuestUserResponse` are removed; call `StreamChatClient.connectGuestUser` instead. +- The poll calls on `StreamChatClient` and `Channel` return a `Result` instead of throwing. +- `CreatePollResponse`, `GetPollResponse` and `UpdatePollResponse` are replaced by `PollResponse`; `CreatePollOptionResponse`, `GetPollOptionResponse` and `UpdatePollOptionResponse` by `PollOptionResponse`; and `CastPollVoteResponse` and `RemovePollVoteResponse` by `PollVoteResponse`. +- `queryPolls` and `queryPollVotes` take `limit` (10 when omitted), `next` and `prev` instead of `PaginationParams`, and their responses carry a `prev` cursor. +- `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. +- `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`. @@ -111,6 +121,11 @@ - `UserBlock` no longer decodes from or encodes to JSON and no longer extends `Equatable`. - `UserBlock.blockedUser`, `userId`, `blockedUserId` and `createdAt` are required and non-nullable. - `StreamChatApi.user.blockUser`, `unblockUser` and `queryBlockedUsers` are removed; call `blockUser`, `unblockUser` and `getBlockedUsers` on `StreamChatClient` instead. +- `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. +- `Location` no longer decodes from or encodes to JSON and no longer extends `Equatable`, so `props` is removed; it still compares by value. - `StreamChatClient.updateUser` and `updateUsers` return a `Result` instead of throwing. - `StreamChatClient.partialUpdateUser` and `partialUpdateUsers` are renamed `updateUserPartial` and `updateUsersPartial`, and return a `Result` instead of throwing. - `PartialUpdateUserRequest` is renamed `UpdateUserPartialRequest`. @@ -119,6 +134,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 @@ -152,6 +172,8 @@ - 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. +- 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/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index cea2df14a1..e497053f48 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -1429,44 +1429,56 @@ 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 created.flatMapAsync((response) => _sendPollMessage(response.poll, messageText)); + } + + 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); + } } - /// Updates the [poll] in this channel. - Future updatePoll(Poll poll) { + /// Updates [poll] to match the settings and options it carries. + /// + /// [Poll.options] becomes the poll's full list of options, each matched by its id, so an existing option missing + /// from it is deleted. New options are added through [createPollOption]. + 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, ) { @@ -1478,8 +1490,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, @@ -1488,7 +1503,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( @@ -1500,8 +1517,10 @@ class Channel { ); } - /// Add a new answer to the given [poll]. - Future addPollAnswer( + /// 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( Message message, Poll poll, { required String answerText, @@ -1516,17 +1535,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( @@ -1538,29 +1559,35 @@ 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 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. + 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, ); } - /// Create a reminder for the given [messageId]. + /// Creates a reminder on the message with the id [messageId]. + /// + /// The reminder is due at [remindAt]. Without it, the reminder is 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 created as a - /// bookmark type instead. - Future createReminder( + /// Throws a [StateError] if this channel is not initialized. + Future> createReminder( String messageId, { DateTime? remindAt, }) { @@ -1571,12 +1598,12 @@ class Channel { ); } - /// Update an existing reminder with the given [reminderId]. + /// Updates the 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 updated as a - /// bookmark type instead. - Future updateReminder( + /// The reminder becomes due at [remindAt]. Without it, the reminder becomes a bookmark, which is never due. + /// + /// Throws a [StateError] if this channel is not initialized. + Future> updateReminder( String messageId, { DateTime? remindAt, }) { @@ -1587,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 3c8891a91c..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'; @@ -75,6 +77,12 @@ import '../core/models/response/list_user_groups_response.dart'; import '../core/models/response/mark_delivered_response.dart'; import '../core/models/response/mark_read_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/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'; @@ -82,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'; @@ -98,6 +107,8 @@ import '../repository/channels_repository.dart'; 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'; @@ -203,6 +214,8 @@ class StreamChatClient { _devicesRepository = DevicesRepository(api); _userGroupsRepository = UserGroupsRepository(api); _usersRepository = UsersRepository(api); + _pollsRepository = PollsRepository(api); + _remindersRepository = RemindersRepository(api); _generalRepository = GeneralRepository(api); _moderationRepository = ModerationRepository(api); _channelsRepository = ChannelsRepository(api); @@ -248,6 +261,8 @@ class StreamChatClient { late final DevicesRepository _devicesRepository; 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; @@ -1656,115 +1671,114 @@ class StreamChatClient { threadId: 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 with the id [pollId]. + Future> getPoll(String pollId) => _pollsRepository.getPoll(pollId); - /// Updates a Poll - Future updatePoll(Poll poll) => _chatApi.polls.updatePoll(poll); + /// Updates [poll] to match the settings and options it carries. + /// + /// [Poll.options] becomes the poll's full list of options, each matched by its id, so an existing option missing + /// from it is deleted. New options are added through [createPollOption]. + Future> updatePoll(Poll poll) => _pollsRepository.updatePoll(poll); - /// Partially updates a Poll by [pollId]. + /// Changes some of the settings of the poll with the id [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 with the id [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 with the id [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 with the id [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 an option of a poll, identified by [optionId] and [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 with the id [pollId]. + /// + /// The [option] is found by its id. + 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 an option from a poll, identified by [optionId] and [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 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( 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( + /// 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( 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 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, - ) => _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]. At most one of [next] and [prev] may be given. + 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 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. + 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); /// Creates [user], or replaces the data of the existing user with the same id. /// @@ -2306,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/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/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/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 9eae1c6a3c..632b2fc8be 100644 --- a/packages/stream_chat/lib/src/core/api/responses.dart +++ b/packages/stream_chat/lib/src/core/api/responses.dart @@ -4,14 +4,11 @@ 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'; 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'; @@ -128,6 +125,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. @@ -154,6 +152,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 @@ -369,114 +368,6 @@ class ChannelStateResponse extends _BaseResponse { static ChannelStateResponse fromJson(Map json) => _$ChannelStateResponseFromJson(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 { @@ -545,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 { @@ -610,6 +467,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 5b73fa7e37..6394d1e3c4 100644 --- a/packages/stream_chat/lib/src/core/api/responses.g.dart +++ b/packages/stream_chat/lib/src/core/api/responses.g.dart @@ -25,27 +25,19 @@ 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() ?? []; @@ -54,37 +46,30 @@ QueryUsersResponse _$QueryUsersResponseFromJson(Map json) => Qu ..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() ?? [] + (json['reactions'] as List?) + ?.map((e) => const ReactionV1JsonConverter().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() ?? []; -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); + ..reaction = const ReactionV1JsonConverter().fromJson(json['reaction'] as Map); -UpdateMessageResponse _$UpdateMessageResponseFromJson( - Map json, -) => UpdateMessageResponse() +UpdateMessageResponse _$UpdateMessageResponseFromJson(Map json) => UpdateMessageResponse() ..duration = json['duration'] as String? ..message = Message.fromJson(json['message'] as Map); @@ -97,9 +82,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?) @@ -109,32 +92,24 @@ 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); -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() ?? [] @@ -150,17 +125,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() ?? [] @@ -169,9 +140,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 = @@ -180,79 +149,15 @@ ChannelStateResponse _$ChannelStateResponseFromJson( ..watcherCount = (json['watcher_count'] as num?)?.toInt() ?? 0 ..read = (json['read'] as List?)?.map((e) => Read.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?; @@ -270,56 +175,24 @@ 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? - ..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) => const LocationV1JsonConverter().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 1631e9c8cb..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 @@ -6,9 +6,7 @@ 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'; import 'user_api.dart'; @@ -46,10 +44,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; @@ -66,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/api/user_api.dart b/packages/stream_chat/lib/src/core/api/user_api.dart index d5c6e4557a..a61b3bafd3 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'; @@ -65,6 +66,6 @@ class UserApi { }), ); - return Location.fromJson(response.data); + return const LocationV1JsonConverter().fromJson(response.data); } } 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..876e577825 100644 --- a/packages/stream_chat/lib/src/core/models/attachment.dart +++ b/packages/stream_chat/lib/src/core/models/attachment.dart @@ -4,9 +4,11 @@ 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'; +import 'converters/v1_json_converters.dart'; import 'response/og_attachment_response.dart'; part 'attachment.g.dart'; @@ -15,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, @@ -40,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), @@ -53,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 @@ -140,6 +146,7 @@ class Attachment extends Equatable { final String? assetUrl; /// Actions from a command + @ActionV1JsonConverter() final List? actions; /// The original width of the attached image. @@ -177,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/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/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 ea45f042f5..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 @@ -2,14 +2,181 @@ 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 '../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'; +import '../poll_vote.dart'; import '../push_provider.dart'; +import '../reaction.dart'; +import '../reaction_group.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. +/// 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 [Location] to and from its v1 keys. +/// +/// [toJson] writes the request shape: the coordinates, the device and the end date, without the channel, message, +/// user or the other dates. +// TODO(openapi-migration): remove in group 10 +@internal +class LocationV1JsonConverter 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, +/// score, emoji code and custom data, without the message, user or dates. +// TODO(openapi-migration): remove once WebSocket events and messages decode v2 payloads +@internal +class ReactionV1JsonConverter 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 [MessageReminder] to and from its v1 keys. +/// +/// [toJson] writes the ids and the dates, without the channel, message or user. +// TODO(openapi-migration): remove once WebSocket events and messages decode v2 payloads +@internal +class MessageReminderV1JsonConverter 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 @@ -41,9 +208,51 @@ 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, + ); +} + +/// 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(); +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( @@ -65,3 +274,133 @@ 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, + nameI18n: _translations(json['name_i18n']), + description: json['description'] as String?, + descriptionI18n: _translations(json['description_i18n']), + 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 and the server's + // translations are 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.rawType, + '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, + textI18n: _translations(json['text_i18n']), + 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?, + answerTextI18n: _translations(json['answer_text_i18n']), + 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, + }, +); + +Map? _translations(Object? json) => switch (json) { + final Map translations => Map.from(translations), + _ => 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/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 8ca0232d50..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,49 +178,18 @@ 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) + @JsonKey(includeToJson: false, fromJson: reactionGroupsFromV1Json) final Map? reactionGroups; /// 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. @@ -301,6 +296,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. @@ -322,7 +318,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. @@ -335,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) { @@ -355,6 +352,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. @@ -364,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 b5d5d2b59d..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,14 +23,12 @@ 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(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 @@ -55,24 +53,22 @@ 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: 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: 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) => { @@ -93,6 +89,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/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/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/lib/src/core/models/poll.dart b/packages/stream_chat/lib/src/core/models/poll.dart index de883dbf8f..7c874a351a 100644 --- a/packages/stream_chat/lib/src/core/models/poll.dart +++ b/packages/stream_chat/lib/src/core/models/poll.dart @@ -1,17 +1,15 @@ import 'package:collection/collection.dart'; -import 'package:equatable/equatable.dart'; -import 'package:json_annotation/json_annotation.dart'; -import 'package:meta/meta.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(); @@ -19,25 +17,17 @@ class _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(copyWith: false) +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, @@ -66,14 +56,12 @@ 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 translations of [name], keyed as `_text`, plus the @@ -81,111 +69,118 @@ class Poll extends Equatable { /// /// Filled in by the server when the poll is sent to a channel with /// automatic translation enabled. - @JsonKey(includeToJson: false) + @override final Map? nameI18n; - /// The description of the poll. + /// A longer explanation of the question. + @override final String? description; /// The translations of [description], in the same shape as [nameI18n]. - @JsonKey(includeToJson: false) + @override final Map? descriptionI18n; - /// 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. + @override + late final List 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. + @override + late final List 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. + @override + late final List 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, @@ -264,9 +259,7 @@ class Poll extends Equatable { ); } - /// Known top level fields. - /// - /// Useful for [Serializer] methods. + /// The keys a poll carries besides its custom data. static const topLevelFields = [ 'id', 'name', @@ -292,32 +285,6 @@ class Poll extends Equatable { 'created_by_id', 'created_by', ]; - - @override - List get props => [ - id, - name, - nameI18n, - description, - descriptionI18n, - options, - votingVisibility, - enforceUniqueVote, - maxVotesAllowed, - allowUserSuggestedOptions, - allowAnswers, - isClosed, - voteCountsByOption, - ownVotesAndAnswers, - voteCount, - answersCount, - latestVotesByOption, - latestAnswers, - createdById, - createdBy, - createdAt, - updatedAt, - ]; } /// A filter for a poll query. @@ -404,7 +371,7 @@ class PollFilterField extends FilterField { /// **Supported operators:** `$eq` static final votingVisibility = PollFilterField( 'voting_visibility', - (it) => _$VotingVisibilityEnumMap[it.votingVisibility], + (it) => it.votingVisibility.rawType, ); /// 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..bd37b1eac1 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/poll.freezed.dart @@ -0,0 +1,112 @@ +// 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; + Map? get nameI18n; + String? get description; + Map? get descriptionI18n; + 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 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; + + @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) && + const DeepCollectionEquality().equals(other.nameI18n, nameI18n) && + (identical(other.description, description) || other.description == description) && + const DeepCollectionEquality().equals(other.descriptionI18n, descriptionI18n) && + const DeepCollectionEquality().equals(other.options, options) && + (identical(other.votingVisibility, votingVisibility) || other.votingVisibility == votingVisibility) && + (identical(other.enforceUniqueVote, enforceUniqueVote) || other.enforceUniqueVote == enforceUniqueVote) && + (identical(other.maxVotesAllowed, maxVotesAllowed) || other.maxVotesAllowed == maxVotesAllowed) && + (identical(other.allowUserSuggestedOptions, allowUserSuggestedOptions) || + other.allowUserSuggestedOptions == allowUserSuggestedOptions) && + (identical(other.allowAnswers, allowAnswers) || other.allowAnswers == allowAnswers) && + (identical(other.isClosed, isClosed) || other.isClosed == isClosed) && + (identical(other.answersCount, answersCount) || other.answersCount == answersCount) && + const DeepCollectionEquality().equals(other.voteCountsByOption, voteCountsByOption) && + const DeepCollectionEquality().equals(other.latestVotesByOption, latestVotesByOption) && + const DeepCollectionEquality().equals(other.latestVotes, latestVotes) && + const DeepCollectionEquality().equals(other.latestAnswers, latestAnswers) && + const DeepCollectionEquality().equals(other.ownVotesAndAnswers, ownVotesAndAnswers) && + (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) && + (identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt) && + const DeepCollectionEquality().equals(other.extraData, extraData)); + } + + @override + int get hashCode => Object.hashAll([ + runtimeType, + id, + name, + const DeepCollectionEquality().hash(nameI18n), + description, + const DeepCollectionEquality().hash(descriptionI18n), + const DeepCollectionEquality().hash(options), + votingVisibility, + enforceUniqueVote, + maxVotesAllowed, + allowUserSuggestedOptions, + allowAnswers, + isClosed, + 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, + updatedAt, + const DeepCollectionEquality().hash(extraData), + ]); + + @override + String toString() { + return 'Poll(id: $id, name: $name, nameI18n: $nameI18n, description: $description, descriptionI18n: $descriptionI18n, options: $options, votingVisibility: $votingVisibility, enforceUniqueVote: $enforceUniqueVote, maxVotesAllowed: $maxVotesAllowed, allowUserSuggestedOptions: $allowUserSuggestedOptions, allowAnswers: $allowAnswers, isClosed: $isClosed, answersCount: $answersCount, voteCountsByOption: $voteCountsByOption, latestVotesByOption: $latestVotesByOption, latestVotes: $latestVotes, latestAnswers: $latestAnswers, ownVotesAndAnswers: $ownVotesAndAnswers, voteCount: $voteCount, ownVotes: $ownVotes, ownAnswers: $ownAnswers, createdById: $createdById, createdBy: $createdBy, createdAt: $createdAt, updatedAt: $updatedAt, extraData: $extraData)'; + } +} 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 11927e1b16..0000000000 --- a/packages/stream_chat/lib/src/core/models/poll.g.dart +++ /dev/null @@ -1,76 +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, - nameI18n: (json['name_i18n'] as Map?)?.map( - (k, e) => MapEntry(k, e as String), - ), - description: json['description'] as String?, - descriptionI18n: (json['description_i18n'] as Map?)?.map( - (k, e) => MapEntry(k, e 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 bc6bd1a791..ab3083547e 100644 --- a/packages/stream_chat/lib/src/core/models/poll_option.dart +++ b/packages/stream_chat/lib/src/core/models/poll_option.dart @@ -1,7 +1,8 @@ -import 'package:equatable/equatable.dart'; -import 'package:json_annotation/json_annotation.dart'; -import '../util/serializer.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import '../../db/data_serializable.dart'; + +part 'poll_option.freezed.dart'; part 'poll_option.g.dart'; class _NullConst { @@ -10,12 +11,15 @@ class _NullConst { const _nullConst = _NullConst(); -/// {@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(copyWith: false) +// 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, @@ -23,16 +27,17 @@ class PollOption extends Equatable { 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; /// The translations of [text], keyed as `_text`, plus the @@ -40,15 +45,13 @@ class PollOption extends Equatable { /// /// Filled in by the server when the poll is sent to a channel with /// automatic translation enabled. - @JsonKey(includeToJson: false) + @override final Map? textI18n; - /// 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, @@ -62,15 +65,15 @@ class PollOption extends Equatable { 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, textI18n]; + /// 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..3b544086e1 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/poll_option.freezed.dart @@ -0,0 +1,45 @@ +// 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 textI18n; + Map get extraData; + + @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.textI18n, textI18n) && + const DeepCollectionEquality().equals(other.extraData, extraData)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + id, + text, + const DeepCollectionEquality().hash(textI18n), + const DeepCollectionEquality().hash(extraData), + ); + + @override + String toString() { + return 'PollOption(id: $id, text: $text, textI18n: $textI18n, extraData: $extraData)'; + } +} diff --git a/packages/stream_chat/lib/src/core/models/poll_option.g.dart b/packages/stream_chat/lib/src/core/models/poll_option.g.dart index ba01b08839..43ce8d5ea2 100644 --- a/packages/stream_chat/lib/src/core/models/poll_option.g.dart +++ b/packages/stream_chat/lib/src/core/models/poll_option.g.dart @@ -9,14 +9,13 @@ part of 'poll_option.dart'; PollOption _$PollOptionFromJson(Map json) => PollOption( id: json['id'] as String?, text: json['text'] as String, - textI18n: (json['text_i18n'] as Map?)?.map( - (k, e) => MapEntry(k, e as String), - ), + textI18n: (json['text_i18n'] as Map?)?.map((k, e) => MapEntry(k, e as String)), extraData: json['extra_data'] as Map? ?? const {}, ); Map _$PollOptionToJson(PollOption instance) => { 'id': ?instance.id, 'text': instance.text, + 'text_i18n': ?instance.textI18n, 'extra_data': instance.extraData, }; diff --git a/packages/stream_chat/lib/src/core/models/poll_vote.dart b/packages/stream_chat/lib/src/core/models/poll_vote.dart index 21ebab6c48..8af5705240 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(copyWith: false) +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, @@ -26,23 +29,25 @@ 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; /// The translations of [answerText], keyed as `_text`, plus the @@ -50,36 +55,29 @@ class PollVote extends Equatable { /// /// Filled in by the server when the answer is added to a poll in a channel /// with automatic translation enabled. - @JsonKey(includeToJson: false) + @override final Map? answerTextI18n; - /// 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, @@ -103,19 +101,8 @@ class PollVote extends Equatable { user: user ?? this.user, ); - @override - List get props => [ - id, - pollId, - optionId, - isAnswer, - answerText, - answerTextI18n, - 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..90f67fbf02 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/poll_vote.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 'poll_vote.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$PollVote { + String? get id; + String? get pollId; + String? get optionId; + String? get answerText; + Map? get answerTextI18n; + DateTime get createdAt; + DateTime get updatedAt; + String? get userId; + User? get user; + + @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) && + const DeepCollectionEquality().equals(other.answerTextI18n, answerTextI18n) && + (identical(other.createdAt, createdAt) || other.createdAt == createdAt) && + (identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt) && + (identical(other.userId, userId) || other.userId == userId) && + (identical(other.user, user) || other.user == user)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + id, + pollId, + optionId, + answerText, + const DeepCollectionEquality().hash(answerTextI18n), + createdAt, + updatedAt, + userId, + user, + ); + + @override + String toString() { + return 'PollVote(id: $id, pollId: $pollId, optionId: $optionId, answerText: $answerText, answerTextI18n: $answerTextI18n, createdAt: $createdAt, updatedAt: $updatedAt, userId: $userId, user: $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 b8ce2074e5..0000000000 --- a/packages/stream_chat/lib/src/core/models/poll_vote.g.dart +++ /dev/null @@ -1,27 +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?, - answerTextI18n: (json['answer_text_i18n'] as Map?)?.map( - (k, e) => MapEntry(k, e 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/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/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/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/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..3ed047a17a --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/poll_vote_response.dart @@ -0,0 +1,25 @@ +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. + /// + /// 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/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/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/models/voting_visibility.dart b/packages/stream_chat/lib/src/core/models/voting_visibility.dart new file mode 100644 index 0000000000..bea1b6ec21 --- /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 rawType) 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/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/attachments_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/attachments_mapper.dart new file mode 100644 index 0000000000..98df87a5fc --- /dev/null +++ b/packages/stream_chat/lib/src/repository/mapper/attachments_mapper.dart @@ -0,0 +1,61 @@ +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 [Attachment.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)), + 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..79afbc1ff2 --- /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 'attachments_mapper.dart'; +import 'channels_mapper.dart'; +import 'messages_mapper.dart'; +import 'users_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/locations_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/locations_mapper.dart new file mode 100644 index 0000000000..2cf708ad38 --- /dev/null +++ b/packages/stream_chat/lib/src/repository/mapper/locations_mapper.dart @@ -0,0 +1,24 @@ +import '../../../open_api/api.dart' as api; +import '../../core/models/location.dart'; +import 'channels_mapper.dart'; +import 'messages_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/messages_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/messages_mapper.dart new file mode 100644 index 0000000000..811d4f2054 --- /dev/null +++ b/packages/stream_chat/lib/src/repository/mapper/messages_mapper.dart @@ -0,0 +1,103 @@ +import '../../../open_api/api.dart' as api; +import '../../core/models/message.dart'; +import '../../core/models/moderation.dart'; +import '../../core/util/message_decoding.dart'; +import 'attachments_mapper.dart'; +import 'drafts_mapper.dart'; +import 'locations_mapper.dart'; +import 'polls_mapper.dart'; +import 'reactions_mapper.dart'; +import 'reminders_mapper.dart'; +import 'user_groups_mapper.dart'; +import 'users_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(); +} + +/// 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/polls_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/polls_mapper.dart new file mode 100644 index 0000000000..831a20e31a --- /dev/null +++ b/packages/stream_chat/lib/src/repository/mapper/polls_mapper.dart @@ -0,0 +1,190 @@ +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 'users_mapper.dart'; + +/// Maps a generated [api.PollResponseData] to a [Poll]. +extension PollResponseDataMapper on api.PollResponseData { + // Custom keys named like one of the poll's own fields. + static const _shadowedCustomKeys = {...Poll.topLevelFields}; + + /// Converts this poll into a [Poll]. + /// + /// Custom data named like one of the poll's own fields is left out of [Poll.extraData]. + Poll toModel() => Poll( + id: id, + name: name, + nameI18n: nameI18n, + description: description, + descriptionI18n: descriptionI18n, + 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: {...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 data named like one of the option's own fields is left out of [PollOption.extraData]. + PollOption toModel() => PollOption( + id: id, + text: text, + textI18n: textI18n, + extraData: {...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), + ); +} + +/// 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, + answerTextI18n: answerTextI18n, + 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. 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]. + /// + /// 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: _customData, + ); + + /// 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: _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: _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: _customData); + + /// Converts this option into an [api.CreatePollOptionRequest]. + api.CreatePollOptionRequest toCreatePollOptionRequest() => api.CreatePollOptionRequest( + text: text, + 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: _customData); + + Map get _customData => + {...extraData}..removeWhere((key, _) => PollOptionResponseDataMapper._shadowedCustomKeys.contains(key)); +} diff --git a/packages/stream_chat/lib/src/repository/mapper/reactions_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/reactions_mapper.dart new file mode 100644 index 0000000000..d70c70da75 --- /dev/null +++ b/packages/stream_chat/lib/src/repository/mapper/reactions_mapper.dart @@ -0,0 +1,39 @@ +import '../../../open_api/api.dart' as api; +import '../../core/models/reaction.dart'; +import '../../core/models/reaction_group.dart'; +import '../../core/util/extension.dart'; +import 'users_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 { + // 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]. + 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, _) => _shadowedCustomKeys.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..518e0825f0 --- /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 'channels_mapper.dart'; +import 'messages_mapper.dart'; +import 'users_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/mapper/sort_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/sort_mapper.dart new file mode 100644 index 0000000000..e821224f31 --- /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 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/lib/src/repository/polls_repository.dart b/packages/stream_chat/lib/src/repository/polls_repository.dart new file mode 100644 index 0000000000..077ebbf499 --- /dev/null +++ b/packages/stream_chat/lib/src/repository/polls_repository.dart @@ -0,0 +1,181 @@ +import 'package:stream_core/stream_core.dart' show PatternMatching, Result; + +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'; +import 'mapper/sort_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 with the id [pollId]. + Future> getPoll(String pollId) async { + final result = await _api.getPoll(pollId: pollId); + + return result.map((response) => response.toModel()); + } + + /// Updates [poll] to match the settings and options it carries. + /// + /// [Poll.options] becomes the poll's full list of options, each matched by its id. + Future> updatePoll(Poll poll) async { + final result = await _api.updatePoll(updatePollRequest: poll.toUpdatePollRequest()); + + return result.map((response) => response.toModel()); + } + + /// Sets the fields in [set] and removes the fields named in [unset] on the poll with the id [pollId]. + 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 with the id [pollId]. + Future> deletePoll(String pollId) async { + final result = await _api.deletePoll(pollId: pollId); + + return result.ignoreValue(); + } + + /// Adds [option] to the poll with the id [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 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 with the id [pollId]. + /// + /// The [option] is matched by its id. + Future> updatePollOption(String pollId, PollOption option) async { + final result = await _api.updatePollOption( + pollId: pollId, + updatePollOptionRequest: option.toUpdatePollOptionRequest(), + ); + + return result.map((response) => response.toModel()); + } + + /// 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 an option of a poll sent in a message, identified by [optionId], [pollId] and [messageId]. + Future> castPollVote( + String messageId, + String pollId, { + required String optionId, + }) => _castPollVote(messageId, pollId, api.VoteData(optionId: optionId)); + + /// Adds [answerText] as an answer to a poll sent in a message, identified by [pollId] and [messageId]. + 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 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); + + 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; at most one of them may be given. + 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((it) => it.toRequest()).toList(), + limit: limit, + next: next, + prev: prev, + ), + ); + + return result.map((response) => response.toModel()); + } + + /// Fetches one page of the votes and answers matching [filter] on the poll with the id [pollId], ordered by [sort]. + /// + /// [next] and [prev] are the cursors a previous page returned; at most one of them may be given. + 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((it) => it.toRequest()).toList(), + limit: limit, + next: next, + prev: prev, + ), + ); + + return result.map((response) => response.toModel()); + } +} 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 e20d1fc247..c8b1ddbc00 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'; @@ -103,9 +104,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 @@ -115,6 +118,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 @@ -178,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 bdc619305b..8d489ff901 100644 --- a/packages/stream_chat/lib/src/ws/events/event.g.dart +++ b/packages/stream_chat/lib/src/ws/events/event.g.dart @@ -15,11 +15,17 @@ 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), + 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), @@ -45,17 +51,16 @@ 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, - ), + : 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,11 +82,17 @@ 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(), + 'reaction': ?_$JsonConverterToJson, Reaction>( + instance.reaction, + const ReactionV1JsonConverter().toJson, + ), 'total_unread_count': ?instance.totalUnreadCount, 'unread_channels': ?instance.unreadChannels, 'online': ?instance.online, @@ -99,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, @@ -110,6 +124,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 +134,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 4c726f14a3..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'; @@ -137,6 +139,12 @@ export 'src/core/models/response/mark_delivered_response.dart'; export 'src/core/models/response/mark_read_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/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'; @@ -145,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'; @@ -159,6 +168,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/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/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/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_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 016f49b006..0bc55ab6ed 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', () { @@ -1119,6 +1120,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 { @@ -2489,6 +2494,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', 'id': 'pasta'}), + 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', 'text': 'Pasta'}), + ); + + expect(result, const Result.success(_pizzaResponse)); + }, + ); + + test('StreamChatClient.createPollOption returns the failure without throwing', () async { + when( + () => defaultApi.createPollOption( + pollId: 'poll-id', + createPollOptionRequest: any(named: 'createPollOptionRequest'), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.createPollOption('poll-id', const PollOption(text: 'Pizza')); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.getPollOption sends the poll and option ids and returns the option', () async { + when( + () => defaultApi.getPollOption(pollId: 'poll-id', optionId: 'pizza'), + ).thenAnswer((_) async => const Result.success(_generatedPizzaResponse)); + + final result = await client.getPollOption('poll-id', 'pizza'); + + expect(result, const Result.success(_pizzaResponse)); + }); + + test('StreamChatClient.getPollOption returns the failure without throwing', () async { + when( + () => defaultApi.getPollOption(pollId: 'poll-id', optionId: 'pizza'), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.getPollOption('poll-id', 'pizza'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test( + "StreamChatClient.getPollOption keeps 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'; @@ -2982,340 +3558,6 @@ void main() { verifyNoMoreInteractions(fakeChatApi.message); }); - 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('`.getActiveLiveLocations`', () async { final locations = [ Location( @@ -6179,3 +6421,26 @@ 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', 'text': 'Pasta'}), + PollOption(text: 'Sushi'), + ], + votingVisibility: VotingVisibility.anonymous, + enforceUniqueVote: false, + maxVotesAllowed: 2, + allowAnswers: true, + allowUserSuggestedOptions: true, + voteCount: 7, + // Custom data named like one of the poll's own fields is left out of the requests. + extraData: const {'topic': 'food', 'name': 'Dinner?'}, +); + +const _generatedPizzaResponse = api.PollOptionResponse(duration: '4.21ms', pollOption: generatedPizza); + +const _pizzaResponse = PollOptionResponse(duration: '4.21ms', pollOption: pizza); diff --git a/packages/stream_chat/test/src/client/client_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..a3a5750aeb --- /dev/null +++ b/packages/stream_chat/test/src/client/message_fixtures.dart @@ -0,0 +1,613 @@ +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: 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(), +); + +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/client/poll_fixtures.dart b/packages/stream_chat/test/src/client/poll_fixtures.dart new file mode 100644 index 0000000000..31796622cb --- /dev/null +++ b/packages/stream_chat/test/src/client/poll_fixtures.dart @@ -0,0 +1,160 @@ +import 'package:stream_chat/open_api/api.dart' as api; +import 'package:stream_chat/stream_chat.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 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 {'language': 'en'}, + 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', + answerTextI18n: const {'language': 'en'}, + 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', + answerTextI18n: const {'fr': "N'importe quoi"}, + 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', textI18n: {'it': '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?', + nameI18n: const {'it': 'Pranzo?'}, + description: 'Pick one', + descriptionI18n: const {'it': 'Scegline uno'}, + 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/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/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/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/api/stream_chat_api_test.dart b/packages/stream_chat/test/src/core/api/stream_chat_api_test.dart index 23ef98e53b..13e49ddba2 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/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/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); + }); } 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..237528049d --- /dev/null +++ b/packages/stream_chat/test/src/core/models/converters/poll_v1_json_test.dart @@ -0,0 +1,274 @@ +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?', + nameI18n: const {'it': 'Pranzo?'}, + description: 'Pick one', + descriptionI18n: const {'it': 'Scegline uno'}, + options: const [ + PollOption(id: 'pizza', text: 'Pizza', textI18n: {'it': '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, without the translations', () { + final event = Event( + type: EventType.pollUpdated, + poll: Poll( + id: 'poll-id', + name: 'Lunch?', + nameI18n: const {'it': 'Pranzo?'}, + description: 'Pick one', + descriptionI18n: const {'it': 'Scegline uno'}, + options: const [ + PollOption(id: 'pizza', text: 'Pizza', textI18n: {'it': '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', + answerTextI18n: const {'fr': "N'importe quoi"}, + 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', + 'answer_text_i18n': {'fr': "N'importe quoi"}, + '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?', + 'name_i18n': {'it': 'Pranzo?'}, + 'description': 'Pick one', + 'description_i18n': {'it': 'Scegline uno'}, + 'options': [ + { + 'id': 'pizza', + 'text': 'Pizza', + 'text_i18n': {'it': '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/converters/v1_json_converters_test.dart b/packages/stream_chat/test/src/core/models/converters/v1_json_converters_test.dart index c8354353f7..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 @@ -1,11 +1,24 @@ +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/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'; +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(); @@ -112,4 +125,423 @@ 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); + }); + + 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); + }); + + 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); + }); + + 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), + ), + ]); + }); + + 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}); + }); + + 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/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; 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/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, 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..433cab319a --- /dev/null +++ b/packages/stream_chat/test/src/core/models/poll_option_test.dart @@ -0,0 +1,45 @@ +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, its translations and the custom data under their stored keys', () { + const option = PollOption(id: 'pizza', text: 'Pizza', textI18n: {'it': 'Pizza'}, extraData: {'color': 'red'}); + + expect(option.toData(), { + 'id': 'pizza', + 'text': 'Pizza', + 'text_i18n': {'it': 'Pizza'}, + 'extra_data': {'color': 'red'}, + }); + }); + + test('PollOption.toData leaves out an id and translations that are null', () { + const option = PollOption(text: 'Pizza'); + + expect(option.toData().keys, unorderedEquals(['text', 'extra_data'])); + }); + + test('PollOption.fromData reads back the option written by toData', () { + const option = PollOption(id: 'pizza', text: 'Pizza', textI18n: {'it': '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'}))); + }); + + 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 74cc23480c..6b87abaacc 100644 --- a/packages/stream_chat/test/src/core/models/poll_test.dart +++ b/packages/stream_chat/test/src/core/models/poll_test.dart @@ -1,210 +1,131 @@ -// 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); - }); - - test('parses the server translations of the name and description', () { - final poll = Poll.fromJson({ - ...jsonFixture('poll.json'), - 'name_i18n': const {'language': 'en', 'nl_text': 'toets'}, - 'description_i18n': const {'language': 'en', 'nl_text': 'omschrijving'}, - }); - - expect(poll.nameI18n, {'language': 'en', 'nl_text': 'toets'}); - expect(poll.descriptionI18n, {'language': 'en', 'nl_text': 'omschrijving'}); - }); - - test('parses the server translation of an option', () { - final poll = Poll.fromJson({ - ...jsonFixture('poll.json'), - 'options': const [ - { - 'id': 'option1', - 'text': 'option1 text', - 'text_i18n': {'language': 'en', 'nl_text': 'optie1 tekst'}, - }, - ], - }); - - expect(poll.options.single.textI18n, {'language': 'en', 'nl_text': 'optie1 tekst'}); - }); - - test('parses the server translation of an answer', () { - final poll = Poll.fromJson({ - ...jsonFixture('poll.json'), - 'latest_answers': const [ - { - 'id': 'answer1', - 'answer_text': 'great', - 'answer_text_i18n': {'language': 'en', 'nl_text': 'geweldig'}, - }, - ], - }); - - expect(poll.latestAnswers.single.answerTextI18n, {'language': 'en', 'nl_text': 'geweldig'}); - }); - - test('keeps the server translations out of the extra data', () { - final poll = Poll.fromJson({ - ...jsonFixture('poll.json'), - 'name_i18n': const {'language': 'en', 'nl_text': 'toets'}, - 'description_i18n': const {'language': 'en', 'nl_text': 'omschrijving'}, - }); - - expect(poll.extraData, isNot(contains('name_i18n'))); - expect(poll.extraData, isNot(contains('description_i18n'))); - }); - - test('does not send the server translations back when serialized', () { - final poll = Poll( - name: 'test', - nameI18n: const {'language': 'en', 'nl_text': 'toets'}, - descriptionI18n: const {'language': 'en', 'nl_text': 'omschrijving'}, - options: const [ - PollOption(text: 'option1 text', textI18n: {'language': 'en', 'nl_text': 'optie1 tekst'}), - ], - ); - - final json = poll.toJson(); - - expect(json, isNot(contains('name_i18n'))); - expect(json, isNot(contains('description_i18n'))); - }); - - test('keeps the server translation of an option out of its extra data', () { - final option = PollOption.fromJson(const { - 'id': 'option1', - 'text': 'option1 text', - 'text_i18n': {'language': 'en', 'nl_text': 'optie1 tekst'}, - }); - - expect(option.extraData, isNot(contains('text_i18n'))); - }); - - test('PollOption.toJson leaves out the server translation', () { - const option = PollOption( - id: 'option1', - text: 'option1 text', - textI18n: {'language': 'en', 'nl_text': 'optie1 tekst'}, - ); - - expect(option.toJson(), isNot(contains('text_i18n'))); - }); - - 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.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(id: null, description: null, createdAt: null, updatedAt: null); + + expect(copy, poll); + }); + + 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 7cac30ea2d..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 @@ -5,41 +5,48 @@ import 'package:test/test.dart'; import '../../utils.dart'; void main() { - group('src/models/poll_vote', () { - test('does not send the server translation of the answer back when serialized', () { - final pollVote = PollVote( - answerText: 'great', - answerTextI18n: const {'language': 'en', 'nl_text': 'geweldig'}, - ); + test('PollVote.isAnswer is true for a vote that carries answer text', () { + final answer = createTestPollVote(answerText: 'Anything'); - expect(pollVote.toJson(), isNot(contains('answer_text_i18n'))); - }); + 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('PollVote.copyWith keeps a field passed as null', () { + final vote = createTestPollVote(id: 'vote-id', optionId: 'pizza', userId: 'user-id'); - group('PollVoteSortField', () { - test('id orders alphabetically', () { - expectOrders( - PollVoteSortField.id, - createTestPollVote(id: 'a-vote', optionId: 'o'), - createTestPollVote(id: 'b-vote', optionId: 'o'), - ); - }); + final copy = vote.copyWith(id: null, optionId: null, userId: null, createdAt: null); - 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)), - ); - }); + expect(copy, vote); + }); + + 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('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('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/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/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( diff --git a/packages/stream_chat/test/src/fakes.dart b/packages/stream_chat/test/src/fakes.dart index e65ab48e05..792eb7b8e7 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/test/src/message_widget/components/stream_message_content_poll_translation_test.dart b/packages/stream_chat_flutter/test/src/message_widget/components/stream_message_content_poll_translation_test.dart index 5a88b08977..834c49d294 100644 --- a/packages/stream_chat_flutter/test/src/message_widget/components/stream_message_content_poll_translation_test.dart +++ b/packages/stream_chat_flutter/test/src/message_widget/components/stream_message_content_poll_translation_test.dart @@ -307,22 +307,28 @@ MockChannel _mockChannel({List? comments}) { any(), filter: any(named: 'filter'), sort: any(named: 'sort'), - pagination: any(named: 'pagination'), + limit: any(named: 'limit'), + next: any(named: 'next'), ), ).thenAnswer( - (_) async => QueryPollVotesResponse() - ..votes = - comments ?? - [ - PollVote( - id: 'answer-1', - answerText: 'I like yellow', - answerTextI18n: const {'language': 'en', 'nl_text': 'Ik hou van geel'}, - ), - ] - ..next = null, + (_) async => Result.success( + QueryPollVotesResponse( + duration: '4.21ms', + votes: + comments ?? + [ + PollVote( + id: 'answer-1', + answerText: 'I like yellow', + answerTextI18n: const {'language': 'en', 'nl_text': 'Ik hou van geel'}, + ), + ], + ), + ), ); - when(() => channel.castPollVote(any(), any(), any())).thenAnswer((_) async => CastPollVoteResponse()); + when( + () => channel.castPollVote(any(), any(), any()), + ).thenAnswer((_) async => const Result.success(PollVoteResponse(duration: '4.21ms'))); return channel; } diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index db1356e8b9..98244c7e4e 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -14,6 +14,10 @@ - A list controller sorted each loaded page with an unstable sort, so rows it called equal — members sharing a `created_at`, say — were reordered on every page append. They keep the order they arrived in now. +🔄 Changed + +- `StreamPollVoteListController` pages with the poll vote query's `limit` and `next` cursor rather than `PaginationParams`. + ## Upcoming 🐞 Fixed diff --git a/packages/stream_chat_flutter_core/lib/src/stream_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/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_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)), ); }); 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..bbb392b388 --- /dev/null +++ b/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart @@ -0,0 +1,166 @@ +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)); + }); + + 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( + 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 e2a6ae74ef..364390476b 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/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()), ), ); } 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..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 @@ -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.rawType; } - -const _votingVisibilityEnumMap = { - VotingVisibility.anonymous: 'anonymous', - VotingVisibility.public: 'public', -}; 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 3f6e93a83e..099241851d 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/poll_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/poll_mapper.dart @@ -18,10 +18,7 @@ extension PollEntityX on PollEntity { nameI18n: nameI18n, description: description, descriptionI18n: descriptionI18n, - 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, @@ -52,9 +49,7 @@ extension PollX on Poll { nameI18n: nameI18n, description: description, descriptionI18n: descriptionI18n, - // `PollOption.toJson` leaves out the server-owned `text_i18n`, as it is - // also the payload sent to the API; the cache has to keep it. - options: options.map((it) => jsonEncode({...it.toJson(), 'text_i18n': ?it.textI18n})).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 a3a453dd9d..76ad85635a 100644 --- a/packages/stream_chat_persistence/test/src/mapper/poll_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/poll_mapper_test.dart @@ -56,10 +56,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 +88,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 +151,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 +173,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);